]> git.ipfire.org Git - thirdparty/starlette.git/commitdiff
Add `allow_private_network` in `CORSMiddleware` (#3065) main
authorMarcelo Trylesinski <marcelotryle@gmail.com>
Tue, 4 Nov 2025 07:29:23 +0000 (08:29 +0100)
committerGitHub <noreply@github.com>
Tue, 4 Nov 2025 07:29:23 +0000 (08:29 +0100)
docs/middleware.md
starlette/middleware/cors.py
tests/middleware/test_cors.py

index 68bcc596695eaee113d6635b58a8aa7b1da275d1..aa9d119fed2cbc8cbd5220528ca5840beff49b87 100644 (file)
@@ -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.
index ffd8aefc60cedf17b87748cb11daeeb284c6685b..36ba9f93fb189af11df668118ad4f1a4f2204cf5 100644 (file)
@@ -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.
index 45bab3e1d629a7a411ed442884a3c99768bae3f2..cbee7d6e7c512ead36d94b184a58ad59052b88b8 100644 (file)
@@ -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