aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-08 22:53:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-08 22:53:35 +0200
commita15c3912008b7dc444c7fc6a2b4ccf782c647215 (patch)
tree01aa10720b9650d1a071a943a9a5be2cbf41d453 /packages
parent4b94468d24913c3071b48eeefb43367f4f5cd523 (diff)
downloadmeshbay-a15c3912008b7dc444c7fc6a2b4ccf782c647215.tar.gz
fix(hub): let this origin frame its own download URL
Three headers govern whether a page may be framed, and all three had to be wrong for the streamed download to work — so fixing them one at a time cost an afternoon of redeploys and retests. They were visible together in a single `curl -I` against the deployed hub, which is where this should have started. The streamed-download path navigates a hidden iframe to `/_mbdl/<id>` so the service worker is asked for the response it is holding. On Firefox and Safari that is the only way to write a large file to disk: neither has the File System Access API, and OPFS is capped at 10% of the volume's size (measured on Firefox 154: 389,233,459 bytes of a 3,892,334,592-byte volume, refused to the byte), which a film exceeds. - `frame-src` was reCAPTCHA's two origins with no `'self'`, so the frame could not be loaded at all. Added when the captcha needed a frame; nobody connected the two. - `frame-ancestors 'none'` forbids all framing, this origin included. - `X-Frame-Options: DENY` says the same in an older dialect. The spec says a browser must ignore it when frame-ancestors is present — relying on that while shipping a header that contradicts our own policy is asking to be surprised, and we were: the CSP was fixed and the download stayed broken. `'self'` and `SAMEORIGIN` refuse every foreign origin exactly as `'none'` and `DENY` do. The clickjacking property is untouched; what they additionally allow is this origin framing itself, which is the only thing the download needed. Pinned three ways: `frame-src` must carry `'self'`, `frame-ancestors` must be `'none'` or `'self'` and never name an origin, and the two framing headers must agree — the defect was the disagreement, and either one read as correct alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py26
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py17
-rw-r--r--packages/meshbay-hub/tests/test_security_headers.py74
3 files changed, 111 insertions, 6 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 3cfb208..96b93bb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -111,8 +111,30 @@ CSP = "; ".join([
"font-src 'self'",
"connect-src 'self' https: wss:",
"worker-src 'self'",
- f"frame-src {_RECAPTCHA_SRC}",
- "frame-ancestors 'none'",
+ # `'self'` is not decoration: the streamed-download path works by navigating
+ # a hidden iframe to `/_mbdl/<id>` so the service worker is asked for the
+ # response it is holding. Without it Chrome refuses the frame, the worker is
+ # never asked, and the page waits out its timeout for a download that cannot
+ # happen — on Firefox and Safari that is the *only* way to write a large
+ # file to disk, so the whole path was dead. Added when reCAPTCHA needed a
+ # frame, which is why nobody connected the two.
+ f"frame-src 'self' {_RECAPTCHA_SRC}",
+ # `'self'`, not `'none'`, and the difference is one same-origin iframe.
+ #
+ # The threat frame-ancestors answers is clickjacking: a *foreign* page
+ # framing this one and stealing clicks. `'self'` refuses every foreign
+ # origin exactly as `'none'` does — what it additionally allows is this
+ # origin framing itself, which is precisely how a streamed download works
+ # (a hidden iframe navigates to `/_mbdl/<id>` so the service worker is
+ # asked for the response it holds).
+ #
+ # Under `'none'` Firefox blocked that frame, the worker was never asked,
+ # and every large download waited out two 15-second timeouts and then fell
+ # through — on Firefox and Safari that is the only way to write a large
+ # file to disk. Chrome did not show it: its worker intercepts the
+ # navigation before the network response and its CSP are ever considered,
+ # which is why this looked like a Firefox-only problem for an afternoon.
+ "frame-ancestors 'self'",
"base-uri 'none'",
"form-action 'none'",
])
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 2daa55b..7b187df 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -154,7 +154,22 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
response.headers.setdefault("Content-Security-Policy", CSP)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
- response.headers.setdefault("X-Frame-Options", "DENY")
+ # SAMEORIGIN, matching `frame-ancestors 'self'` in the CSP above.
+ #
+ # The two say the same thing to different generations of browser, and
+ # they were saying different things: CSP allowed this origin to frame
+ # itself, this header forbade all framing. The spec says a browser must
+ # ignore X-Frame-Options when the CSP carries frame-ancestors — but
+ # relying on that while shipping a header that contradicts our own
+ # policy is asking to be surprised, and we were: the streamed download
+ # (a hidden iframe onto `/_mbdl/<id>`, the only way to write a large
+ # file to disk on Firefox and Safari) stayed blocked after the CSP was
+ # fixed, and this header was why it looked like the fix had not worked.
+ #
+ # No foreign origin may frame this page under either spelling. That is
+ # the property; DENY was one notch stricter than the property needed and
+ # broke a feature to get there.
+ response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
return response
# Routers (webapp last — catches / before API routes)
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")