Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion tests/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: /"
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
52 changes: 52 additions & 0 deletions x2s3/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down
Loading