From: Marcelo Trylesinski Date: Tue, 4 Nov 2025 07:29:23 +0000 (+0100) Subject: Add `allow_private_network` in `CORSMiddleware` (#3065) X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=refs%2Fheads%2Fmain;p=thirdparty%2Fstarlette.git Add `allow_private_network` in `CORSMiddleware` (#3065) --- diff --git a/docs/middleware.md b/docs/middleware.md index 68bcc596..aa9d119f 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -76,6 +76,7 @@ The following arguments are supported: * `allow_methods` - A list of HTTP methods that should be allowed for cross-origin requests. Defaults to `['GET']`. You can use `['*']` to allow all standard methods. * `allow_headers` - A list of HTTP request headers that should be supported for cross-origin requests. Defaults to `[]`. You can use `['*']` to allow all headers. The `Accept`, `Accept-Language`, `Content-Language` and `Content-Type` headers are always allowed for CORS requests. * `allow_credentials` - Indicate that cookies should be supported for cross-origin requests. Defaults to `False`. Also, `allow_origins`, `allow_methods` and `allow_headers` cannot be set to `['*']` for credentials to be allowed, all of them must be explicitly specified. +* `allow_private_network` - Indicates whether to accept cross-origin requests over a private network. Defaults to `False`. * `expose_headers` - Indicate any response headers that should be made accessible to the browser. Defaults to `[]`. * `max_age` - Sets a maximum time in seconds for browsers to cache CORS responses. Defaults to `600`. @@ -92,6 +93,14 @@ appropriate CORS headers, and either a 200 or 400 response for informational pur Any request with an `Origin` header. In this case the middleware will pass the request through as normal, but will include appropriate CORS headers on the response. +#### Private Network Access (PNA) + +Private Network Access is a browser security feature that restricts websites from public networks from accessing servers on private networks. + +When a website attempts to make such a cross-network request, the browser will send a `Access-Control-Request-Private-Network: true` header in the +pre-flight request. If the `allow_private_network` flag is set to `True`, the middleware will include the `Access-Control-Allow-Private-Network: true` +header in the response, allowing the request. If set to `False`, the middleware will return a 400 response, blocking the request. + ### CORSMiddleware Global Enforcement When using CORSMiddleware with your Starlette application, it's important to ensure that CORS headers are applied even to error responses generated by unhandled exceptions. The recommended solution is to wrap the entire Starlette application with CORSMiddleware. This approach guarantees that even if an exception is caught by ServerErrorMiddleware (or other outer error-handling middleware), the response will still include the proper `Access-Control-Allow-Origin` header. diff --git a/starlette/middleware/cors.py b/starlette/middleware/cors.py index ffd8aefc..36ba9f93 100644 --- a/starlette/middleware/cors.py +++ b/starlette/middleware/cors.py @@ -21,6 +21,7 @@ class CORSMiddleware: allow_headers: Sequence[str] = (), allow_credentials: bool = False, allow_origin_regex: str | None = None, + allow_private_network: bool = False, expose_headers: Sequence[str] = (), max_age: int = 600, ) -> None: @@ -35,7 +36,7 @@ class CORSMiddleware: allow_all_headers = "*" in allow_headers preflight_explicit_allow_origin = not allow_all_origins or allow_credentials - simple_headers = {} + simple_headers: dict[str, str] = {} if allow_all_origins: simple_headers["Access-Control-Allow-Origin"] = "*" if allow_credentials: @@ -43,7 +44,7 @@ class CORSMiddleware: if expose_headers: simple_headers["Access-Control-Expose-Headers"] = ", ".join(expose_headers) - preflight_headers = {} + preflight_headers: dict[str, str] = {} if preflight_explicit_allow_origin: # The origin value will be set in preflight_response() if it is allowed. preflight_headers["Vary"] = "Origin" @@ -69,6 +70,7 @@ class CORSMiddleware: self.allow_all_headers = allow_all_headers self.preflight_explicit_allow_origin = preflight_explicit_allow_origin self.allow_origin_regex = compiled_allow_origin_regex + self.allow_private_network = allow_private_network self.simple_headers = simple_headers self.preflight_headers = preflight_headers @@ -105,9 +107,10 @@ class CORSMiddleware: requested_origin = request_headers["origin"] requested_method = request_headers["access-control-request-method"] requested_headers = request_headers.get("access-control-request-headers") + requested_private_network = request_headers.get("access-control-request-private-network") headers = dict(self.preflight_headers) - failures = [] + failures: list[str] = [] if self.is_allowed_origin(origin=requested_origin): if self.preflight_explicit_allow_origin: @@ -130,6 +133,12 @@ class CORSMiddleware: failures.append("headers") break + if requested_private_network is not None: + if self.allow_private_network: + headers["Access-Control-Allow-Private-Network"] = "true" + else: + failures.append("private-network") + # We don't strictly need to use 400 responses here, since its up to # the browser to enforce the CORS policy, but its more informative # if we do. diff --git a/tests/middleware/test_cors.py b/tests/middleware/test_cors.py index 45bab3e1..cbee7d6e 100644 --- a/tests/middleware/test_cors.py +++ b/tests/middleware/test_cors.py @@ -396,7 +396,7 @@ def test_cors_allow_origin_regex_fullmatch( assert response.headers["access-control-allow-origin"] == "https://subdomain.example.org" assert "access-control-allow-credentials" not in response.headers - # Test diallowed standard response + # Test disallowed standard response headers = {"Origin": "https://subdomain.example.org.hacker.com"} response = client.get("/", headers=headers) assert response.status_code == 200 @@ -530,3 +530,78 @@ def test_cors_allowed_origin_does_not_leak_between_credentialed_requests( response = client.get("/", headers={"Origin": "https://someplace.org"}) assert response.headers["access-control-allow-origin"] == "*" assert "access-control-allow-credentials" not in response.headers + + +def test_cors_private_network_access_allowed(test_client_factory: TestClientFactory) -> None: + def homepage(request: Request) -> PlainTextResponse: + return PlainTextResponse("Homepage", status_code=200) + + app = Starlette( + routes=[Route("/", endpoint=homepage)], + middleware=[ + Middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_private_network=True, + ) + ], + ) + + client = test_client_factory(app) + + headers_without_pna = {"Origin": "https://example.org", "Access-Control-Request-Method": "GET"} + headers_with_pna = {**headers_without_pna, "Access-Control-Request-Private-Network": "true"} + + # Test preflight with Private Network Access request + response = client.options("/", headers=headers_with_pna) + assert response.status_code == 200 + assert response.text == "OK" + assert response.headers["access-control-allow-private-network"] == "true" + + # Test preflight without Private Network Access request + response = client.options("/", headers=headers_without_pna) + assert response.status_code == 200 + assert response.text == "OK" + assert "access-control-allow-private-network" not in response.headers + + # The access-control-allow-private-network header is not set for non-preflight requests + response = client.get("/", headers=headers_with_pna) + assert response.status_code == 200 + assert response.text == "Homepage" + assert "access-control-allow-private-network" not in response.headers + assert "access-control-allow-origin" in response.headers + + +def test_cors_private_network_access_disallowed(test_client_factory: TestClientFactory) -> None: + def homepage(request: Request) -> None: ... # pragma: no cover + + app = Starlette( + routes=[Route("/", endpoint=homepage)], + middleware=[ + Middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_private_network=False, + ) + ], + ) + + client = test_client_factory(app) + + # Test preflight with Private Network Access request when not allowed + headers_without_pna = {"Origin": "https://example.org", "Access-Control-Request-Method": "GET"} + headers_with_pna = {**headers_without_pna, "Access-Control-Request-Private-Network": "true"} + + response = client.options("/", headers=headers_without_pna) + assert response.status_code == 200 + assert response.text == "OK" + assert "access-control-allow-private-network" not in response.headers + + # If the request includes a Private Network Access header, but the middleware is configured to disallow it, the + # request should be denied with a 400 response. + response = client.options("/", headers=headers_with_pna) + assert response.status_code == 400 + assert response.text == "Disallowed CORS private-network" + assert "access-control-allow-private-network" not in response.headers