From 90d6966d93882c2a256737128873fd48c57bb405 Mon Sep 17 00:00:00 2001 From: Kristian Evers Date: Thu, 1 Feb 2024 12:42:46 +0100 Subject: [PATCH 1/6] Add middleware for handling x-forwarded-host --- webproj/api.py | 3 ++ webproj/middleware.py | 72 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 webproj/middleware.py diff --git a/webproj/api.py b/webproj/api.py index 1bd062a..ade7b23 100644 --- a/webproj/api.py +++ b/webproj/api.py @@ -16,6 +16,8 @@ import pyproj from pyproj.transformer import Transformer, AreaOfInterest +from webproj.middleware import ProxyHeadersMiddleware + __VERSION__ = "1.2.3" if "WEBPROJ_LIB" in os.environ: @@ -79,6 +81,7 @@ def token_query_param( ) origins = ["*"] app.add_middleware(CORSMiddleware, allow_origins=origins) +app.add_middleware(ProxyHeadersMiddleware) _DATA = Path(__file__).parent / Path("data.json") diff --git a/webproj/middleware.py b/webproj/middleware.py new file mode 100644 index 0000000..2ac08be --- /dev/null +++ b/webproj/middleware.py @@ -0,0 +1,72 @@ +""" +This middleware can be used when a known proxy is fronting the application, +and is trusted to be properly setting the `X-Forwarded-Proto`, +`X-Forwarded-Host` and `x-forwarded-prefix` headers with. + +Modifies the `host`, 'root_path' and `scheme` information. + +https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers#Proxies + +Original source: https://github.com/encode/uvicorn/blob/master/uvicorn/middleware/proxy_headers.py +Altered to accomodate x-forwarded-host instead of x-forwarded-for +Altered: 27-01-2022 +""" +from typing import List, Optional, Tuple, Union + +from starlette.types import ASGIApp, Receive, Scope, Send + +Headers = List[Tuple[bytes, bytes]] + + +class ProxyHeadersMiddleware: + def __init__(self, app, trusted_hosts: Union[List[str], str] = "127.0.0.1") -> None: + self.app = app + if isinstance(trusted_hosts, str): + self.trusted_hosts = {item.strip() for item in trusted_hosts.split(",")} + else: + self.trusted_hosts = set(trusted_hosts) + self.always_trust = "*" in self.trusted_hosts + + def remap_headers(self, src: Headers, before: bytes, after: bytes) -> Headers: + remapped = [] + before_value = None + after_value = None + for header in src: + k, v = header + if k == before: + before_value = v + continue + elif k == after: + after_value = v + continue + remapped.append(header) + if after_value: + remapped.append((before, after_value)) + elif before_value: + remapped.append((before, before_value)) + return remapped + + async def __call__(self, scope, receive, send) -> None: + if scope["type"] in ("http", "websocket"): + + client_addr: Optional[Tuple[str, int]] = scope.get("client") + client_host = client_addr[0] if client_addr else None + + if self.always_trust or client_host in self.trusted_hosts: + headers = dict(scope["headers"]) + if b"x-forwarded-proto" in headers: + # Determine if the incoming request was http or https based on + # the X-Forwarded-Proto header. + x_forwarded_proto = headers[b"x-forwarded-proto"].decode("latin1") + scope["scheme"] = x_forwarded_proto.strip() # type: ignore[index] + + if b"x-forwarded-host" in headers: + # Setting scope["server"] is not enough because of https://github.com/encode/starlette/issues/604#issuecomment-543945716 + scope["headers"] = self.remap_headers( + scope["headers"], b"host", b"x-forwarded-host" + ) + if b"x-forwarded-prefix" in headers: + x_forwarded_prefix = headers[b"x-forwarded-prefix"].decode("latin1") + scope["root_path"] = x_forwarded_prefix + + return await self.app(scope, receive, send) \ No newline at end of file From ce66512b3f53074382f93380f3b7f2ca7afdd342 Mon Sep 17 00:00:00 2001 From: Kristian Evers Date: Thu, 1 Feb 2024 12:52:39 +0100 Subject: [PATCH 2/6] Explicitly set 127.0.0.1 as trusted host --- webproj/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webproj/api.py b/webproj/api.py index ade7b23..53efca9 100644 --- a/webproj/api.py +++ b/webproj/api.py @@ -81,7 +81,7 @@ def token_query_param( ) origins = ["*"] app.add_middleware(CORSMiddleware, allow_origins=origins) -app.add_middleware(ProxyHeadersMiddleware) +app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="127.0.0.1") _DATA = Path(__file__).parent / Path("data.json") From 3a5060cbc49f1aebfdc04919f86209b063b2f114 Mon Sep 17 00:00:00 2001 From: Kristian Evers Date: Mon, 12 Feb 2024 15:43:39 +0100 Subject: [PATCH 3/6] Remove CORSMiddleware --- webproj/api.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/webproj/api.py b/webproj/api.py index 53efca9..415cd90 100644 --- a/webproj/api.py +++ b/webproj/api.py @@ -79,8 +79,6 @@ def token_query_param( docs_url="/documentation", dependencies=[Depends(token_header_param), Depends(token_query_param)], ) -origins = ["*"] -app.add_middleware(CORSMiddleware, allow_origins=origins) app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="127.0.0.1") _DATA = Path(__file__).parent / Path("data.json") From 93d2d4a8389d13e101e35abec461e42bd0a0b7ad Mon Sep 17 00:00:00 2001 From: Elvis Date: Mon, 4 Nov 2024 13:37:33 +0100 Subject: [PATCH 4/6] Copying x-forwarded-functions from !gh/sdfidk/stac-fastapi. --- Dockerfile | 2 +- webproj/middleware.py | 131 +++++++++++++++++++++++++++--------------- 2 files changed, 85 insertions(+), 48 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3a7f43a..35fb82f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,6 @@ RUN conda env create -f environment.yaml RUN conda run -n webproj pyproj sync --source-id dk_sdfe --target-dir $WEBPROJ_LIB RUN conda run -n webproj pyproj sync --source-id dk_sdfi --target-dir $WEBPROJ_LIB -CMD ["conda", "run", "-n", "webproj", "uvicorn", "--proxy-headers", "app.main:app", "--host", "0.0.0.0", "--port", "80"] +CMD ["conda", "run", "-n", "webproj", "uvicorn", "app.main:app", "--proxy-headers", "--host", "0.0.0.0", "--port", "80"] EXPOSE 80 diff --git a/webproj/middleware.py b/webproj/middleware.py index 2ac08be..3cbc617 100644 --- a/webproj/middleware.py +++ b/webproj/middleware.py @@ -11,62 +11,99 @@ Altered to accomodate x-forwarded-host instead of x-forwarded-for Altered: 27-01-2022 """ +import re from typing import List, Optional, Tuple, Union - +from http.client import HTTP_PORT, HTTPS_PORT from starlette.types import ASGIApp, Receive, Scope, Send Headers = List[Tuple[bytes, bytes]] -class ProxyHeadersMiddleware: - def __init__(self, app, trusted_hosts: Union[List[str], str] = "127.0.0.1") -> None: - self.app = app - if isinstance(trusted_hosts, str): - self.trusted_hosts = {item.strip() for item in trusted_hosts.split(",")} - else: - self.trusted_hosts = set(trusted_hosts) - self.always_trust = "*" in self.trusted_hosts +class ProxyHeaderMiddleware: + """Account for forwarding headers when deriving base URL. - def remap_headers(self, src: Headers, before: bytes, after: bytes) -> Headers: - remapped = [] - before_value = None - after_value = None - for header in src: - k, v = header - if k == before: - before_value = v - continue - elif k == after: - after_value = v - continue - remapped.append(header) - if after_value: - remapped.append((before, after_value)) - elif before_value: - remapped.append((before, before_value)) - return remapped + Prioritise standard Forwarded header, look for non-standard X-Forwarded-* if missing. + Default to what can be derived from the URL if no headers provided. Middleware updates + the host header that is interpreted by starlette when deriving Request.base_url. + """ - async def __call__(self, scope, receive, send) -> None: - if scope["type"] in ("http", "websocket"): + def __init__(self, app: ASGIApp): + """Create proxy header middleware.""" + self.app = app - client_addr: Optional[Tuple[str, int]] = scope.get("client") - client_host = client_addr[0] if client_addr else None + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Call from stac-fastapi framework.""" + if scope["type"] == "http": + proto, domain, port = self._get_forwarded_url_parts(scope) + scope["scheme"] = proto + if domain is not None: + port_suffix = "" + if port is not None: + if (proto == "http" and port != HTTP_PORT) or ( + proto == "https" and port != HTTPS_PORT + ): + port_suffix = f":{port}" + scope["headers"] = self._replace_header_value_by_name( + scope, + "host", + f"{domain}{port_suffix}", + ) + await self.app(scope, receive, send) + + def _get_forwarded_url_parts(self, scope: Scope) -> Tuple[str]: + proto = scope.get("scheme", "http") + header_host = self._get_header_value_by_name(scope, "host") + if header_host is None: + domain, port = scope.get("server") + else: + header_host_parts = header_host.split(":") + if len(header_host_parts) == 2: + domain, port = header_host_parts + else: + domain = header_host_parts[0] + port = None + forwarded = self._get_header_value_by_name(scope, "forwarded") + if forwarded is not None: + parts = forwarded.split(";") + for part in parts: + if len(part) > 0 and re.search("=", part): + key, value = part.split("=") + if key == "proto": + proto = value + elif key == "host": + host_parts = value.split(":") + domain = host_parts[0] + try: + port = int(host_parts[1]) if len(host_parts) == 2 else None + except ValueError: + # ignore ports that are not valid integers + pass + else: + proto = self._get_header_value_by_name(scope, "x-forwarded-proto", proto) + port_str = self._get_header_value_by_name(scope, "x-forwarded-port", port) + try: + port = int(port_str) if port_str is not None else None + except ValueError: + # ignore ports that are not valid integers + pass - if self.always_trust or client_host in self.trusted_hosts: - headers = dict(scope["headers"]) - if b"x-forwarded-proto" in headers: - # Determine if the incoming request was http or https based on - # the X-Forwarded-Proto header. - x_forwarded_proto = headers[b"x-forwarded-proto"].decode("latin1") - scope["scheme"] = x_forwarded_proto.strip() # type: ignore[index] + return (proto, domain, port) - if b"x-forwarded-host" in headers: - # Setting scope["server"] is not enough because of https://github.com/encode/starlette/issues/604#issuecomment-543945716 - scope["headers"] = self.remap_headers( - scope["headers"], b"host", b"x-forwarded-host" - ) - if b"x-forwarded-prefix" in headers: - x_forwarded_prefix = headers[b"x-forwarded-prefix"].decode("latin1") - scope["root_path"] = x_forwarded_prefix + def _get_header_value_by_name( + self, scope: Scope, header_name: str, default_value: str = None + ) -> str: + headers = scope["headers"] + candidates = [ + value.decode() for key, value in headers if key.decode() == header_name + ] + return candidates[0] if len(candidates) == 1 else default_value - return await self.app(scope, receive, send) \ No newline at end of file + @staticmethod + def _replace_header_value_by_name( + scope: Scope, header_name: str, new_value: str + ) -> List[Tuple[str]]: + return [ + (name, value) + for name, value in scope["headers"] + if name.decode() != header_name + ] + [(str.encode(header_name), str.encode(new_value))] From 6b3cc90ac188f48a6d6d7dcca3727441ad46d1ac Mon Sep 17 00:00:00 2001 From: Kristian Evers Date: Wed, 6 Nov 2024 09:13:27 +0100 Subject: [PATCH 5/6] Resolve ImportError by removing an 's' from `ProxyHeaderMiddleware` --- webproj/api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/webproj/api.py b/webproj/api.py index 415cd90..fcbada6 100644 --- a/webproj/api.py +++ b/webproj/api.py @@ -16,7 +16,7 @@ import pyproj from pyproj.transformer import Transformer, AreaOfInterest -from webproj.middleware import ProxyHeadersMiddleware +from webproj.middleware import ProxyHeaderMiddleware __VERSION__ = "1.2.3" @@ -79,7 +79,7 @@ def token_query_param( docs_url="/documentation", dependencies=[Depends(token_header_param), Depends(token_query_param)], ) -app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="127.0.0.1") +app.add_middleware(ProxyHeaderMiddleware, trusted_hosts="127.0.0.1") _DATA = Path(__file__).parent / Path("data.json") @@ -132,7 +132,7 @@ def __init__(self, src, dst): if dst not in CRS_LIST.keys(): raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unknown destination CRS identifier: '{dst}'", ) @@ -140,7 +140,7 @@ def __init__(self, src, dst): dst_region = CRS_LIST[dst]["country"] if src_region != dst_region and "Global" not in (src_region, dst_region): raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=status.HTTP_400_BAD_REQUEST, detail="CRS's are not compatible across countries", ) From 2e816314095587a1272ae251ec182b292ba6d568 Mon Sep 17 00:00:00 2001 From: Kristian Evers Date: Wed, 6 Nov 2024 09:15:25 +0100 Subject: [PATCH 6/6] Remove `trusted_hosts` argument from middleware addition --- webproj/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webproj/api.py b/webproj/api.py index fcbada6..cb6aaaf 100644 --- a/webproj/api.py +++ b/webproj/api.py @@ -79,7 +79,7 @@ def token_query_param( docs_url="/documentation", dependencies=[Depends(token_header_param), Depends(token_query_param)], ) -app.add_middleware(ProxyHeaderMiddleware, trusted_hosts="127.0.0.1") +app.add_middleware(ProxyHeaderMiddleware) _DATA = Path(__file__).parent / Path("data.json")