fix(backup): send required public DAV upload marker
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29
This commit is contained in:
parent
33ff82210e
commit
50da37cf50
7 changed files with 65 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue