From 50da37cf50016b03a7e6a537e87772a72979aba1 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 3 Oct 2026 21:20:21 +0200 Subject: [PATCH] fix(backup): send required public DAV upload marker Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29 --- lib/railiance-backup-common.sh | 2 ++ openbao/root-grant/README.md | 11 +++++++ scripts/migrate_nextcloud_backup_account.py | 3 ++ scripts/root_custody_finish.py | 3 +- tests/test_backup_credentials.py | 1 + tests/test_root_custody_finish.py | 31 +++++++++++++++++++ ...9-platform-root-custody-record-delivery.md | 15 +++++++++ 7 files changed, 65 insertions(+), 1 deletion(-) diff --git a/lib/railiance-backup-common.sh b/lib/railiance-backup-common.sh index c4b627c..190cf04 100644 --- a/lib/railiance-backup-common.sh +++ b/lib/railiance-backup-common.sh @@ -66,6 +66,7 @@ railiance_backup_nc_upload() { status="$(printf 'url = "%s"\nuser = "%s:"\nupload-file = "%s"\n' \ "$config_url" "$config_token" "$config_file" | \ curl --disable --silent --fail --proto '=https' --max-time 600 \ + --header 'X-Requested-With: XMLHttpRequest' \ --output /dev/null --write-out '%{http_code}' --config - 2>/dev/null)" \ || { echo "ERROR: Nextcloud upload failed" >&2; return 1; } case "$status" in @@ -91,6 +92,7 @@ try: chunks = iter(lambda: source.read(1024 * 1024), b'') req = urllib.request.Request(url, data=chunks, method="PUT") req.add_header("Authorization", "Basic " + base64.b64encode(f"{token}:".encode()).decode()) + req.add_header("X-Requested-With", "XMLHttpRequest") req.add_header("Content-Type", "application/octet-stream") req.add_header("Content-Length", str(os.fstat(source.fileno()).st_size)) with urllib.request.build_opener(NoRedirect()).open(req, timeout=600) as resp: diff --git a/openbao/root-grant/README.md b/openbao/root-grant/README.md index ff9d3e8..5c56ebd 100644 --- a/openbao/root-grant/README.md +++ b/openbao/root-grant/README.md @@ -216,3 +216,14 @@ and decrypts the downloaded artifact with the independent key file. Only match and decryption booleans are recorded; the private key is never read into logs or replaced. A missing/different escrow is an explicit fixed-code recovery failure, not a request to create or reset a key implicitly. + +The 2026-10-03 finish receipt returned HTTP401 at public upload after current +baseline and independent escrow verification passed. Corrected clients include +Nextcloud's required `X-Requested-With: XMLHttpRequest` header for public DAV +non-GET operations. See [official DAV documentation](https://docs.nextcloud.com/server/stable/developer_manual/client_apis/WebDAV/basic.html) +and [stable31 public handler](https://github.com/nextcloud/server/blob/stable31/apps/dav/appinfo/v2/publicremote.php). +Live causality is unproved until a fresh attended finish run succeeds. This +changes request protocol only; preserve lane/token/permissions and do not rotate +credentials or fall back to wider account uploads. Failure receipts now retain +current lane KV version. The historical exposed share differs from current +lane; separate historical owner/revocation evidence remains unverified. diff --git a/scripts/migrate_nextcloud_backup_account.py b/scripts/migrate_nextcloud_backup_account.py index ea98e4a..0b061aa 100644 --- a/scripts/migrate_nextcloud_backup_account.py +++ b/scripts/migrate_nextcloud_backup_account.py @@ -40,6 +40,9 @@ class NoRedirect(urllib.request.HTTPRedirectHandler): def request(url, method='GET', body=None, auth=None, headers=None): require(url.startswith(HOST + '/'), 'unapproved_provider_origin') headers = dict(headers or {}) + # Public DAV rejects non-GET requests without AJAX marker when federation is disabled. + if url.startswith(HOST + '/public.php/dav/') and method != 'GET': + headers['X-Requested-With'] = 'XMLHttpRequest' if auth: headers['Authorization'] = 'Basic ' + base64.b64encode((auth[0] + ':' + auth[1]).encode()).decode() req = urllib.request.Request(url, data=body, method=method, headers=headers) diff --git a/scripts/root_custody_finish.py b/scripts/root_custody_finish.py index 0568e91..5688095 100644 --- a/scripts/root_custody_finish.py +++ b/scripts/root_custody_finish.py @@ -94,7 +94,7 @@ def roundtrip(bundle, lane, operator, network=None, get_quota=None, progress=lam raise FinishFailure('backup_size_or_quota_invalid') name='root-custody-history-'+datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')+'-'+uuid.uuid4().hex+'.json.age' progress('provider_upload',artifact=name) - status,_=network(upload+'/forgejo/'+name,'PUT',encrypted,(values['NC_WEBDAV_TOKEN'],''),{'Content-Type':'application/octet-stream','If-None-Match':'*'}) + status,_=network(upload+'/forgejo/'+name,'PUT',encrypted,(values['NC_WEBDAV_TOKEN'],''),{'Content-Type':'application/octet-stream','If-None-Match':'*','X-Requested-With':'XMLHttpRequest'}) progress('provider_upload',upload_http_status=status) if status!=201: raise FinishFailure('new_artifact_upload_failed') @@ -196,6 +196,7 @@ def main(receipt_reserved=False): digest=state['digest'],current_live_baseline_matches=True) progress('backup_lane_read') lane=bao.read(LANE) + result['lane_kv_version']=lane['metadata']['version'] progress('backup_operator_read') operator=bao.read(OPERATOR_PATH)['data'] progress('historical_share_comparison') diff --git a/tests/test_backup_credentials.py b/tests/test_backup_credentials.py index 8f715db..38e61a0 100644 --- a/tests/test_backup_credentials.py +++ b/tests/test_backup_credentials.py @@ -39,6 +39,7 @@ RAILIANCE_BACKUP_NC_TOKEN=fixture-secret-marker RAILIANCE_BACKUP_NC_WEBDAV_URL=https://example.invalid/fixture-secret-marker curl() { [[ "$*" != *fixture-secret-marker* ]] || return 9 + [[ "$*" == *"X-Requested-With: XMLHttpRequest"* ]] || return 10 local config config="$(cat)" [[ "$config" == *'user = "fixture-secret-marker:"'* ]] || return 8 diff --git a/tests/test_root_custody_finish.py b/tests/test_root_custody_finish.py index 1224de3..595dd9b 100644 --- a/tests/test_root_custody_finish.py +++ b/tests/test_root_custody_finish.py @@ -50,6 +50,7 @@ class FinishTests(unittest.TestCase): def memory_provider(url,method='GET',body=None,auth=None,headers=None): if method=='PUT': self.assertEqual(headers['If-None-Match'],'*') + self.assertEqual(headers['X-Requested-With'],'XMLHttpRequest') stored['encrypted']=body return 201,b'' return 200,stored['encrypted'] @@ -130,6 +131,7 @@ class MainIntegrationTests(unittest.TestCase): receipt=json.loads(self.receipt.read_text()) self.assertEqual(receipt['stage'],'provider_upload');self.assertEqual(receipt['upload_http_status'],403) self.assertIn('artifact',receipt);self.assertNotIn('NEVER-LOG',self.receipt.read_text()) + self.assertEqual(receipt['lane_kv_version'],4) def test_download_failure_preserves_successful_created_artifact(self): def failure(url,method='GET',body=None,auth=None,headers=None): return (201,b'') if method=='PUT' else (401,b'RAW-SECRET') @@ -213,3 +215,32 @@ class MainIntegrationTests(unittest.TestCase): if __name__=='__main__':unittest.main() + +class PublicDavWireTests(unittest.TestCase): + def test_public_upload_marker_reaches_http_server_owner_get_unchanged(self): + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + import threading + import migrate_nextcloud_backup_account as migration + seen=[] + class Handler(BaseHTTPRequestHandler): + def log_message(self,*args):pass + def do_PUT(self): + seen.append(dict(self.headers)) + self.rfile.read(int(self.headers['Content-Length'])) + self.send_response(201 if self.headers.get('X-Requested-With')=='XMLHttpRequest' else 401) + self.end_headers() + def do_GET(self): + seen.append(dict(self.headers));self.send_response(200);self.end_headers() + server=ThreadingHTTPServer(('127.0.0.1',0),Handler) + worker=threading.Thread(target=server.serve_forever,daemon=True);worker.start() + origin='http://127.0.0.1:'+str(server.server_port) + try: + with patch.object(migration,'HOST',origin): + code,_=migration.request(origin+'/public.php/dav/filesdrop/FAKE/forgejo/new.age','PUT',b'fake-encrypted-fixture',('FAKE','')) + self.assertEqual(code,201) + code,_=migration.request(origin+'/remote.php/dav/files/Backup/fixture','GET',auth=('Backup','FAKE-PASSWORD')) + self.assertEqual(code,200) + self.assertEqual(seen[0]['X-Requested-With'],'XMLHttpRequest') + self.assertNotIn('X-Requested-With',seen[1]) + finally: + server.shutdown();server.server_close();worker.join() diff --git a/workplans/RPF-WP-0049-platform-root-custody-record-delivery.md b/workplans/RPF-WP-0049-platform-root-custody-record-delivery.md index 54041b0..ac20737 100644 --- a/workplans/RPF-WP-0049-platform-root-custody-record-delivery.md +++ b/workplans/RPF-WP-0049-platform-root-custody-record-delivery.md @@ -201,3 +201,18 @@ provider protocol relaxation is made. Private full-main tests with actual age and fake owner/provider I/O verify success and each meaningful failure boundary. T05 still waits on actual governed provider roundtrip; exact command unchanged, with a fresh receipt path mandatory. + +## Upload 401 protocol fix — 2026-10-03 + +Fresh attended receipt reached provider_upload and received HTTP401. Current +source and independently held escrow recipient passed; historical exposed share +differs from current lane, with its owner/revocation still unverified separately. +No credential rotation or account fallback is performed. Nextcloud public DAV +requires X-Requested-With: XMLHttpRequest for non-GET requests unless outgoing +federation is enabled. The custody helper and shared Python/curl/fallback clients +omitted it; source now supplies that documented header and preserves lane KV +version in failed receipts. A real loopback HTTP server verifies the transmitted +header and unchanged owner read request. This explains a plausible rejection +mechanism, not proved live causality. T05 remains blocked on a fresh attended +finish receipt from corrected source; shared workload recovery proof must also +be refreshed after normal source deployment, not inferred from this test.