diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-09 14:28:40 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-09 14:28:40 +0200 |
| commit | 7e2d078fe1870d256ae47781bee6ac4f454edf24 (patch) | |
| tree | 05689049fd48f01ebbdb9995d5189cba15cee052 /packages/meshbay-hub/tests/test_security_headers.py | |
| parent | 813d18424ec57963bb56e6f40824a2db0ccce50d (diff) | |
| parent | e6f895c473a0b19e7b186889c1836d3945bc880b (diff) | |
| download | meshbay-7e2d078fe1870d256ae47781bee6ac4f454edf24.tar.gz | |
Merge branch 'fix/large-download-paths'
Concurrent-transfer limits, with the queue, the pause and the flag day.
A node now caps how many transfers it runs at once (8 downloads, 8 uploads,
node-wide) and how many one member may run in one group (2 by default,
operator-signed). Beyond that the node answers "queued" and the client waits its
turn, visibly, in the transfers panel — and a slot that frees starts whatever is
next, skipping past a member who is at their own cap rather than letting them
stall everyone behind them.
Browsing is never subject to a slot: not the poster grid, not the covers, not
opening a photo to look at it. That is structural — a transfer is what the
transfers widget shows — and the exemption is bounded rather than open, at two
files in flight per session, because an exemption with no bound is a leaseless
branch under another name.
Transfers can be cancelled, and now paused and resumed. A paused one holds
nothing: its slot goes back at once and resuming rejoins the queue at the tail.
Uploads survive the connection that started them and resume where the node
stopped, asked for inside the seal rather than on a clear message. What they
leave behind when they are abandoned is reaped, which closes a disk leak that
predates this work.
MNP 3.0 makes the lease compulsory and refuses 2.x at the handshake, with the
desktop client checking `client.minimum` before connecting so an un-updated one
says "update" instead of failing every connection in a protocol vocabulary.
Fourteen defects were found on the way, eight of them by a person clicking
Download and pasting a console — none of which 2075 tests could reach. Section
12 of ~/next/improve-downloads.md is that report, including the three this work
introduced itself and the one that turned out to be caused by an instruction to
hard-reload after each deployment.
Node suite 1209 passed, hub suite 866 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-hub/tests/test_security_headers.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_security_headers.py | 74 |
1 files changed, 71 insertions, 3 deletions
diff --git a/packages/meshbay-hub/tests/test_security_headers.py b/packages/meshbay-hub/tests/test_security_headers.py index b4d7e6d..44dd7e8 100644 --- a/packages/meshbay-hub/tests/test_security_headers.py +++ b/packages/meshbay-hub/tests/test_security_headers.py @@ -24,7 +24,7 @@ async def test_the_spa_shell_carries_the_policy(client): r = await client.get("/") assert r.headers["content-security-policy"] == CSP assert r.headers["x-content-type-options"] == "nosniff" - assert r.headers["x-frame-options"] == "DENY" + assert r.headers["x-frame-options"] == "SAMEORIGIN" assert "referrer-policy" in r.headers @@ -42,12 +42,17 @@ async def test_even_a_404_carries_the_headers(client): # cannot be framed or content-sniffed either. r = await client.get("/no/such/path") assert r.status_code == 404 - assert r.headers["x-frame-options"] == "DENY" + assert r.headers["x-frame-options"] == "SAMEORIGIN" def test_the_policy_is_locked_down_where_it_matters(): assert "default-src 'none'" in CSP # covers object-src, etc. - assert _directive(CSP, "frame-ancestors") == "frame-ancestors 'none'" + # `'self'`, not `'none'`: every foreign origin is still refused, which is + # the whole of the clickjacking protection. What `'self'` adds is this + # origin framing itself, which the streamed download needs — see + # test_the_streamed_download_frame_is_allowed. Under `'none'` Firefox + # blocked it and large downloads there had no path to disk at all. + assert _directive(CSP, "frame-ancestors") == "frame-ancestors 'self'" assert _directive(CSP, "base-uri") == "base-uri 'none'" script = _directive(CSP, "script-src") @@ -63,3 +68,66 @@ def test_recaptcha_is_the_only_external_origin(): for tok in part.strip().split()[1:]: if tok.startswith(("http://", "https://")): assert tok in hosts, f"unexpected external origin in CSP: {tok}" + + +def test_the_streamed_download_frame_is_allowed(): + """ + `frame-src` must carry `'self'`, and this is not a preference. + + The streamed-download path works by navigating a hidden iframe to + `/_mbdl/<id>` so the service worker is asked for the response it is already + holding. `frame-src` was tightened to reCAPTCHA's two origins when the + captcha needed a frame, and nobody connected the two: Chrome refused the + frame, the worker was never asked, and the page waited out its timeout for + a download that could not happen. On Firefox and Safari that is the *only* + way to write a large file to disk — there is no File System Access API and + OPFS is capped at 10% of the volume — so the whole path was dead, silently, + on the deployed hub. + + Found by clicking Download three times and watching nothing happen, with + the reason in the browser console and nowhere else. + """ + frame_src = _directive(CSP, "frame-src") + assert "'self'" in frame_src, ( + "the same-origin download frame is blocked; large downloads fall back " + "to memory, or are refused outright above the ceiling") + # And still no wildcard: `'self'` is what the download needs, nothing more. + assert "*" not in frame_src + + +def test_no_foreign_origin_may_frame_this_page(): + """The clickjacking property, stated separately from how it is spelled. + + `frame-ancestors` moved from `'none'` to `'self'` so the streamed download + could frame its own URL. That must not become a list of origins, and it must + never become `*`: the threat is a foreign page framing this one and stealing + clicks, and `'self'` is the most permissive value that still refuses every + one of them. + """ + value = _directive(CSP, "frame-ancestors").split(" ", 1)[1].strip() + assert value in ("'none'", "'self'"), ( + f"frame-ancestors is {value!r}: anything naming an origin lets that " + f"origin frame this page") + + +def test_the_two_framing_headers_agree(): + """X-Frame-Options and CSP must say the same thing. + + They did not: the CSP let this origin frame itself (which the streamed + download needs) while `X-Frame-Options: DENY` forbade all framing. The spec + says a browser must ignore the header when frame-ancestors is present, and + counting on that while shipping a contradiction is how an afternoon goes: + the CSP was fixed, the download stayed broken, and the header was why. + + Checked as a pair rather than one value apiece, because the defect was the + disagreement and either one alone reads as correct. + """ + import asyncio + + from meshbay_hub.app import create_app # noqa: F401 (import check) + + ancestors = _directive(CSP, "frame-ancestors").split(" ", 1)[1].strip() + expected = {"'none'": "DENY", "'self'": "SAMEORIGIN"}[ancestors] + assert expected == "SAMEORIGIN", ( + "if frame-ancestors goes back to 'none', X-Frame-Options must go back " + "to DENY in app.py — and the streamed download will stop working again") |