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' \
|
status="$(printf 'url = "%s"\nuser = "%s:"\nupload-file = "%s"\n' \
|
||||||
"$config_url" "$config_token" "$config_file" | \
|
"$config_url" "$config_token" "$config_file" | \
|
||||||
curl --disable --silent --fail --proto '=https' --max-time 600 \
|
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)" \
|
--output /dev/null --write-out '%{http_code}' --config - 2>/dev/null)" \
|
||||||
|| { echo "ERROR: Nextcloud upload failed" >&2; return 1; }
|
|| { echo "ERROR: Nextcloud upload failed" >&2; return 1; }
|
||||||
case "$status" in
|
case "$status" in
|
||||||
|
|
@ -91,6 +92,7 @@ try:
|
||||||
chunks = iter(lambda: source.read(1024 * 1024), b'')
|
chunks = iter(lambda: source.read(1024 * 1024), b'')
|
||||||
req = urllib.request.Request(url, data=chunks, method="PUT")
|
req = urllib.request.Request(url, data=chunks, method="PUT")
|
||||||
req.add_header("Authorization", "Basic " + base64.b64encode(f"{token}:".encode()).decode())
|
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-Type", "application/octet-stream")
|
||||||
req.add_header("Content-Length", str(os.fstat(source.fileno()).st_size))
|
req.add_header("Content-Length", str(os.fstat(source.fileno()).st_size))
|
||||||
with urllib.request.build_opener(NoRedirect()).open(req, timeout=600) as resp:
|
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
|
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
|
or replaced. A missing/different escrow is an explicit fixed-code recovery
|
||||||
failure, not a request to create or reset a key implicitly.
|
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):
|
def request(url, method='GET', body=None, auth=None, headers=None):
|
||||||
require(url.startswith(HOST + '/'), 'unapproved_provider_origin')
|
require(url.startswith(HOST + '/'), 'unapproved_provider_origin')
|
||||||
headers = dict(headers or {})
|
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:
|
if auth:
|
||||||
headers['Authorization'] = 'Basic ' + base64.b64encode((auth[0] + ':' + auth[1]).encode()).decode()
|
headers['Authorization'] = 'Basic ' + base64.b64encode((auth[0] + ':' + auth[1]).encode()).decode()
|
||||||
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
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')
|
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'
|
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)
|
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)
|
progress('provider_upload',upload_http_status=status)
|
||||||
if status!=201:
|
if status!=201:
|
||||||
raise FinishFailure('new_artifact_upload_failed')
|
raise FinishFailure('new_artifact_upload_failed')
|
||||||
|
|
@ -196,6 +196,7 @@ def main(receipt_reserved=False):
|
||||||
digest=state['digest'],current_live_baseline_matches=True)
|
digest=state['digest'],current_live_baseline_matches=True)
|
||||||
progress('backup_lane_read')
|
progress('backup_lane_read')
|
||||||
lane=bao.read(LANE)
|
lane=bao.read(LANE)
|
||||||
|
result['lane_kv_version']=lane['metadata']['version']
|
||||||
progress('backup_operator_read')
|
progress('backup_operator_read')
|
||||||
operator=bao.read(OPERATOR_PATH)['data']
|
operator=bao.read(OPERATOR_PATH)['data']
|
||||||
progress('historical_share_comparison')
|
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
|
RAILIANCE_BACKUP_NC_WEBDAV_URL=https://example.invalid/fixture-secret-marker
|
||||||
curl() {
|
curl() {
|
||||||
[[ "$*" != *fixture-secret-marker* ]] || return 9
|
[[ "$*" != *fixture-secret-marker* ]] || return 9
|
||||||
|
[[ "$*" == *"X-Requested-With: XMLHttpRequest"* ]] || return 10
|
||||||
local config
|
local config
|
||||||
config="$(cat)"
|
config="$(cat)"
|
||||||
[[ "$config" == *'user = "fixture-secret-marker:"'* ]] || return 8
|
[[ "$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):
|
def memory_provider(url,method='GET',body=None,auth=None,headers=None):
|
||||||
if method=='PUT':
|
if method=='PUT':
|
||||||
self.assertEqual(headers['If-None-Match'],'*')
|
self.assertEqual(headers['If-None-Match'],'*')
|
||||||
|
self.assertEqual(headers['X-Requested-With'],'XMLHttpRequest')
|
||||||
stored['encrypted']=body
|
stored['encrypted']=body
|
||||||
return 201,b''
|
return 201,b''
|
||||||
return 200,stored['encrypted']
|
return 200,stored['encrypted']
|
||||||
|
|
@ -130,6 +131,7 @@ class MainIntegrationTests(unittest.TestCase):
|
||||||
receipt=json.loads(self.receipt.read_text())
|
receipt=json.loads(self.receipt.read_text())
|
||||||
self.assertEqual(receipt['stage'],'provider_upload');self.assertEqual(receipt['upload_http_status'],403)
|
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.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 test_download_failure_preserves_successful_created_artifact(self):
|
||||||
def failure(url,method='GET',body=None,auth=None,headers=None):
|
def failure(url,method='GET',body=None,auth=None,headers=None):
|
||||||
return (201,b'') if method=='PUT' else (401,b'RAW-SECRET')
|
return (201,b'') if method=='PUT' else (401,b'RAW-SECRET')
|
||||||
|
|
@ -213,3 +215,32 @@ class MainIntegrationTests(unittest.TestCase):
|
||||||
|
|
||||||
|
|
||||||
if __name__=='__main__':unittest.main()
|
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.
|
and fake owner/provider I/O verify success and each meaningful failure boundary.
|
||||||
T05 still waits on actual governed provider roundtrip; exact command unchanged,
|
T05 still waits on actual governed provider roundtrip; exact command unchanged,
|
||||||
with a fresh receipt path mandatory.
|
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