Repository: falconry/falcon

Issue:
Add option for `Access-Control-Allow-Private-Network` to CORSMiddleware
Add option to enable [`Access-Control-Allow-Private-Network`](https://wicg.github.io/private-network-access/#http-headerdef-access-control-request-private-network) to CORSMiddleware.

We probably don't need to inject it unconditionally, but only in response to the `Access-Control-Request-Private-Network` request header (when set to to `true`).

The option **must be disabled** by default.


Discussion:
like this? 
middleware.py

```python
from __future__ import annotations

from typing import Any, Iterable, Optional, Union

from .request import Request
from .response import Response


class CORSMiddleware(object):
    """CORS Middleware.

    This middleware provides a simple out-of-the box CORS policy, including handling
    of preflighted requests from the browser.

    See also:

    * https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
    * https://www.w3.org/TR/cors/#resource-processing-model

    Keyword Arguments:
        allow_origins (Union[str, Iterable[str]]): List of origins to allow (case
            sensitive). The string ``'*'`` acts as a wildcard, matching every origin.
            (default ``'*'``).
        expose_headers (Optional[Union[str, Iterable[str]]]): List of additional
            response headers to expose via the ``Access-Control-Expose-Headers``
            header. These headers are in addition to the CORS-safelisted ones:
            ``Cache-Control``, ``Content-Language``, ``Content-Length``,
            ``Content-Type``, ``Expires``, ``Last-Modified``, ``Pragma``.
            (default ``None``).

            See also:
            https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers
        allow_credentials (Optional[Union[str, Iterable[str]]]): List of origins
            (case sensitive) for which to allow credentials via the
            ``Access-Control-Allow-Credentials`` header.
            The string ``'*'`` acts as a wildcard, matching every allowed origin,
            while ``None`` disallows all origins. This parameter takes effect only
            if the origin is allowed by the ``allow_origins`` argument.
            (Default ``None``).

    """

    def __init__(
        self,
        allow_origins: Union[str, Iterable[str]] = '*',
        expose_headers: Optional[Union[str, Iterable[str]]] = None,
        allow_credentials: Optional[Union[str, Iterable[str]]] = None,
        allow_private_network: bool = False,
    ):

        if allow_origins == '*':
            self.allow_origins = allow_origins
        else:
            if isinstance(allow_origins, str):
                allow_origins = [allow_origins]
            self.allow_origins = frozenset(allow_origins)
            if '*' in self.allow_origins:
                raise ValueError(
                    'The wildcard string "*" may only be passed to allow_origins as a '
                    'string literal, not inside an iterable.'
                )

        if expose_headers is not None and not isinstance(expose_headers, str):
            expose_headers = ', '.join(expose_headers)
        self.expose_headers = expose_headers

        if allow_credentials is None:
            allow_credentials = frozenset()
        elif allow_credentials != '*':
            if isinstance(allow_credentials, str):
                allow_credentials = [allow_credentials]
            allow_credentials = frozenset(allow_credentials)
            if '*' in allow_credentials:
                raise ValueError(
                    'The wildcard string "*" may only be passed to allow_credentials '
                    'as a string literal, not inside an iterable.'
                )
        self.allow_credentials = allow_credentials

        self.allow_private_network = allow_private_network

    def process_response(
        self, req: Request, resp: Response, resource: object, req_succeeded: bool
    ) -> None:
        """Implement the CORS policy for all routes.

        This middleware provides a simple out-of-the box CORS policy,
        including handling of preflighted requests from the browser.

        See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS

        See also: https://www.w3.org/TR/cors/#resource-processing-model
        """

        origin = req.get_header('Origin')
        if origin is None:
            return

        if self.allow_origins != '*' and origin not in self.allow_origins:
            return

        if resp.get_header('Access-Control-Allow-Origin') is None:
            set_origin = '*' if self.allow_origins == '*' else origin
            if self.allow_credentials == '*' or origin in self.allow_credentials:
                set_origin = origin
                resp.set_header('Access-Control-Allow-Credentials', 'true')
            resp.set_header('Access-Control-Allow-Origin', set_origin)

        if self.expose_headers:
            resp.set_header('Access-Control-Expose-Headers', self.expose_headers)

        if (
            req_succeeded
            and req.method == 'OPTIONS'
            and req.get_header('Access-Control-Request-Method')
        ):
            # NOTE(kgriffs): This is a CORS preflight request. Patch the
            #   response accordingly.

            allow = resp.get_header('Allow')
            resp.delete_header('Allow')

            allow_headers = req.get_header(
                'Access-Control-Request-Headers', default='*'
            )

            resp.set_header('Access-Control-Allow-Methods', allow)
            resp.set_header('Access-Control-Allow-Headers', allow_headers)
            resp.set_header('Access-Control-Max-Age', '86400')  # 24 hours

            if self.allow_private_network and req.get_header('Access-Control-Request-Private-Network') == 'true':
                resp.set_header('Access-Control-Allow-Private-Network', 'true')


    async def process_response_async(self, *args: Any) -> None:
        self.process_response(*args)
```
Hi @zodecky, I haven't checked all the details, but, yes I was thinking along these lines. Could you open a pull request?
ok
just opened, not sure if I did it the right way
Note: this issue is still looking for contributors.



Patch:
diff --git a/docs/_newsfragments/2381.newandimproved.rst b/docs/_newsfragments/2381.newandimproved.rst
new file mode 100644
index 000000000..76896ce24
--- /dev/null
+++ b/docs/_newsfragments/2381.newandimproved.rst
@@ -0,0 +1,4 @@
+Added support for private network requests in CORS middleware.
+This is off by default and can be enabled by passing the keyword argument 
+``allow_private_network=True`` to :class:`falcon.middleware.CORSMiddleware` 
+during initialization.
\ No newline at end of file
diff --git a/falcon/middleware.py b/falcon/middleware.py
index 2403919af..df1cbaf58 100644
--- a/falcon/middleware.py
+++ b/falcon/middleware.py
@@ -50,8 +50,17 @@ class CORSMiddleware(UniversalMiddlewareWithProcessResponse):
             The string ``'*'`` acts as a wildcard, matching every allowed origin,
             while ``None`` disallows all origins. This parameter takes effect only
             if the origin is allowed by the ``allow_origins`` argument.
-            (Default ``None``).
+            (default ``None``).
+        allow_private_network (bool):
+            If ``True``, the server includes the
+            ``Access-Control-Allow-Private-Network`` header in responses to
+            CORS preflight (OPTIONS) requests. This indicates that the resource is
+            willing to respond to requests from less-public IP address spaces
+            (e.g., from public site to private device).
+            (default ``False``).
 
+            See also:
+            https://wicg.github.io/private-network-access/#private-network-request-heading
     """
 
     def __init__(
@@ -59,6 +68,7 @@ def __init__(
         allow_origins: Union[str, Iterable[str]] = '*',
         expose_headers: Optional[Union[str, Iterable[str]]] = None,
         allow_credentials: Optional[Union[str, Iterable[str]]] = None,
+        allow_private_network: bool = False,
     ):
         if allow_origins == '*':
             self.allow_origins = allow_origins
@@ -88,6 +98,7 @@ def __init__(
                     'as a string literal, not inside an iterable.'
                 )
         self.allow_credentials = allow_credentials
+        self.allow_private_network = allow_private_network
 
     def process_response(
         self, req: Request, resp: Response, resource: object, req_succeeded: bool
@@ -146,6 +157,11 @@ def process_response(
                 resp.set_header('Access-Control-Allow-Headers', allow_headers)
                 resp.set_header('Access-Control-Max-Age', '86400')  # 24 hours
 
+            if self.allow_private_network and (
+                req.get_header('Access-Control-Request-Private-Network') == 'true'
+            ):
+                resp.set_header('Access-Control-Allow-Private-Network', 'true')
+
     async def process_response_async(
         self,
         req: AsgiRequest,


Test patch:
diff --git a/tests/test_cors_middleware.py b/tests/test_cors_middleware.py
index 9aff6abf6..df1a0d589 100644
--- a/tests/test_cors_middleware.py
+++ b/tests/test_cors_middleware.py
@@ -161,6 +161,32 @@ def my_sink(req, resp):
             assert 'Access-Control-Expose-Headers' not in result.headers
             assert 'Access-Control-Allow-Origin' not in result.headers
 
+    @pytest.mark.parametrize('include_request_private_network', (True, False))
+    def test_disabled_cors_private_network(
+        self, cors_client, include_request_private_network
+    ):
+        # default scenario for cors middleware, where
+        # allow private network is off by default
+
+        cors_client.app.add_route('/', CORSHeaderResource())
+
+        headers = (
+            ('Origin', 'localhost'),
+            ('Access-Control-Request-Method', 'GET'),
+        )
+
+        if include_request_private_network:
+            headers = (
+                *headers,
+                ('Access-Control-Request-Private-Network', 'true'),
+            )
+
+        result = cors_client.simulate_options('/', headers=headers)
+
+        h = result.headers
+
+        assert 'Access-Control-Allow-Private-Network' not in h
+
 
 @pytest.fixture(scope='function')
 def make_cors_client(asgi, util):
@@ -293,3 +319,19 @@ def test_expose_headers(self, make_cors_client, attr, exp):
         assert res.headers['Access-Control-Expose-Headers'] == exp
         h = dict(res.headers.lower_items()).keys()
         assert 'Access-Control-Allow-Credentials'.lower() not in h
+
+    def test_enabled_cors_private_network_headers(self, make_cors_client):
+        client = make_cors_client(falcon.CORSMiddleware(allow_private_network=True))
+
+        client.app.add_route('/', CORSHeaderResource())
+
+        res = client.simulate_options(
+            '/',
+            headers=(
+                ('Origin', 'localhost'),
+                ('Access-Control-Request-Method', 'GET'),
+                ('Access-Control-Request-Private-Network', 'true'),
+            ),
+        )
+
+        assert res.headers['Access-Control-Allow-Private-Network'] == 'true'