fix(backup): send required public DAV upload marker
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29
This commit is contained in:
codex 2026-10-03 21:20:21 +02:00
parent 33ff82210e
commit 50da37cf50
7 changed files with 65 additions and 1 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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)

View file

@ -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')

View file

@ -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

View file

@ -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()

View file

@ -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.