diff --git a/tests/test_base.py b/tests/test_base.py index ef353fa..6a099de 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -38,4 +38,26 @@ def test_get_robotstxt(client): response = client.get("/robots.txt") assert response.status_code == 200 assert response.headers['content-type'].startswith("text/plain") - assert response.text == "User-agent: *\nDisallow: /" \ No newline at end of file + assert response.text == "User-agent: *\nDisallow: /" + + +def test_pna_preflight_grants_private_network(client): + """A CORS preflight carrying Access-Control-Request-Private-Network must be + answered with Access-Control-Allow-Private-Network: true so Chromium permits + public-origin pages to load data from an internal-network host.""" + response = client.options( + "/some-target/some.zarr/.zattrs", + headers={ + "Origin": "https://example.com", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Private-Network": "true", + }, + ) + assert response.headers.get("access-control-allow-private-network") == "true" + + +def test_pna_header_absent_without_request(client): + """The PNA grant header must not leak onto responses that did not ask for it.""" + response = client.get("/robots.txt") + assert response.status_code == 200 + assert "access-control-allow-private-network" not in response.headers \ No newline at end of file diff --git a/x2s3/app.py b/x2s3/app.py index 77d2c93..4ea1450 100644 --- a/x2s3/app.py +++ b/x2s3/app.py @@ -62,6 +62,55 @@ async def send_with_request_id(message): await self.app(scope, receive, send_with_request_id) +class PrivateNetworkAccessMiddleware: + """Pure ASGI middleware that grants browser Private Network Access (PNA) + preflights. + + Chromium browsers (Chrome/Edge) send a CORS preflight before any request from + a public-origin page to a private-network address (e.g. an internal host + serving objects to a browser-based viewer). The preflight carries + `Access-Control-Request-Private-Network: true`, and the request only proceeds + if the response echoes `Access-Control-Allow-Private-Network: true`. Starlette's + CORSMiddleware does not emit this header, so without it Chromium blocks + public-origin pages from loading data hosted on an internal network. + + (Firefox uses a separate user-permission model -- Local Network Access -- rather + than this header, so this neither helps nor harms Firefox.) + + Registered outside CORSMiddleware so it can append the header to the preflight + response that CORSMiddleware generates. Implemented as pure ASGI so it only + touches response headers without re-wrapping the body. The header is added only + when the PNA request header is present, which the browser sends solely on + preflights, so it never appears on normal data responses. + """ + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + # ASGI lowercases header names; the request header value is the ASCII "true". + requested = any( + name == b"access-control-request-private-network" + and value.strip().lower() == b"true" + for name, value in scope.get("headers", []) + ) + if not requested: + await self.app(scope, receive, send) + return + + async def send_with_pna(message): + if message["type"] == "http.response.start": + headers = message.setdefault("headers", []) + headers.append((b"access-control-allow-private-network", b"true")) + await send(message) + + await self.app(scope, receive, send_with_pna) + + def create_app(settings): @asynccontextmanager @@ -141,6 +190,9 @@ async def lifespan(app: FastAPI): allow_headers=["*"], expose_headers=["Range", "Content-Range", "x-amz-request-id"], ) + # Echo Access-Control-Allow-Private-Network on PNA preflights. Added after + # (i.e. outside) CORSMiddleware so it wraps the preflight response CORS emits. + app.add_middleware(PrivateNetworkAccessMiddleware) app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates")