From 0eb68d8709c04d3103a6ed39b69c387858a6241f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:08:27 +0100 Subject: [PATCH 1/4] fix: add ROOT_PATH_SKIP_PREFIXES to exempt sibling-service links from root_path rewriting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On shared-host deployments (e.g. eoapi-k8s defaults: proxy at /stac, titiler at /raster, /vector, /browser on one hostname), STAC responses carry same-host links to sibling services. ProcessLinksMiddleware cannot distinguish these from its own links by netloc — with OVERRIDE_HOST=false the upstream generates its links with the request netloc too — so it prepends ROOT_PATH to them, producing broken links (/stac/raster/... 404). Add an opt-in ROOT_PATH_SKIP_PREFIXES setting: same-host links whose path matches a configured prefix (path-segment-aware) are left untouched. Unset (default) keeps behavior unchanged. Co-Authored-By: Claude Opus 4.8 --- docs/user-guide/configuration.md | 11 ++ docs/user-guide/tips.md | 2 + src/stac_auth_proxy/app.py | 1 + src/stac_auth_proxy/config.py | 18 +++ .../middleware/ProcessLinksMiddleware.py | 26 +++- tests/test_config.py | 33 +++++ tests/test_process_links.py | 128 ++++++++++++++++++ 7 files changed, 218 insertions(+), 1 deletion(-) diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index ae82c3c3..0a23bab7 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -92,6 +92,17 @@ The application is configurable via environment variables. > [!NOTE] > This is independent of the upstream API's path. The proxy will handle removing this prefix from incoming requests and adding it to outgoing links. +### `ROOT_PATH_SKIP_PREFIXES` + +: Path prefixes of sibling services on the proxy's hostname whose links must not be rewritten + + - **Type:** comma-separated list of path prefixes + - **Required:** No, defaults to `''` (rewrite all same-host links) + - **Example:** `/raster,/vector,/browser` + + > [!NOTE] + > When the proxy shares a hostname with other services (e.g. the proxy at `/stac` alongside a tiler at `/raster`), STAC responses may contain links to those sibling services on the same host. By default the proxy cannot distinguish these from its own links and would prepend `ROOT_PATH` to them, breaking them. List the sibling services' path prefixes here to leave such links untouched. Matching is path-segment-aware (`/raster` matches `/raster` and `/raster/...`, but not `/rasterfoo`). + ## Authentication ### `OIDC_DISCOVERY_URL` diff --git a/docs/user-guide/tips.md b/docs/user-guide/tips.md index 1f144405..bcae0f73 100644 --- a/docs/user-guide/tips.md +++ b/docs/user-guide/tips.md @@ -39,6 +39,8 @@ The proxy can be optionally served from a non-root path (e.g., `/api/v1`). Addit - Update the OpenAPI specification to include the `ROOT_PATH` in the servers field - Handle requests that don't match the `ROOT_PATH` with a 404 response +If the proxy shares its hostname with other services (e.g. the proxy at `/stac` alongside a tiler at `/raster`), links to those services would also receive the `ROOT_PATH` prefix. Use `ROOT_PATH_SKIP_PREFIXES` to exempt their path prefixes from link rewriting. + ## Non-OIDC Workaround If the upstream server utilizes RS256 JWTs but does not utilize a proper OIDC server, the proxy can be configured to work around this by setting the `OIDC_DISCOVERY_URL` to a statically-hosted OIDC discovery document that points to a valid JWKS endpoint. diff --git a/src/stac_auth_proxy/app.py b/src/stac_auth_proxy/app.py index 1c56d0ce..706a3ce0 100644 --- a/src/stac_auth_proxy/app.py +++ b/src/stac_auth_proxy/app.py @@ -175,6 +175,7 @@ def configure_app( ProcessLinksMiddleware, upstream_url=str(settings.upstream_url), root_path=settings.root_path, + root_path_skip_prefixes=settings.root_path_skip_prefixes, ) if settings.root_path: diff --git a/src/stac_auth_proxy/config.py b/src/stac_auth_proxy/config.py index 9c11ad97..7741f0c1 100644 --- a/src/stac_auth_proxy/config.py +++ b/src/stac_auth_proxy/config.py @@ -79,6 +79,7 @@ class Settings(BaseSettings): allowed_jwt_audiences: Optional[Sequence[str]] = None root_path: str = "" + root_path_skip_prefixes: Sequence[str] = () override_host: bool = True healthz_prefix: str = Field(pattern=_PREFIX_PATTERN, default="/healthz") upstream_timeout: float = 15.0 @@ -147,3 +148,20 @@ def _default_oidc_discovery_internal_url(cls, data: Any) -> Any: def parse_audience(cls, v) -> Sequence[str] | None: """Parse a comma separated string list of audiences into a list.""" return str2list(v) + + @field_validator("root_path_skip_prefixes", mode="before") + @classmethod + def parse_root_path_skip_prefixes(cls, v) -> Sequence[str] | None: + """Parse a comma separated string of path prefixes into a normalized list.""" + values = str2list(v) + if values is None: + return values + prefixes = [] + for value in values: + prefix = value.strip().rstrip("/") + if not prefix: + continue + if not prefix.startswith("/"): + raise ValueError(f"Path prefix {value!r} must start with '/'") + prefixes.append(prefix) + return prefixes diff --git a/src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py b/src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py index 112f6381..9b7967a5 100644 --- a/src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py +++ b/src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py @@ -3,7 +3,7 @@ import logging import re from dataclasses import dataclass -from typing import Any, Optional +from typing import Any, Optional, Sequence from urllib.parse import ParseResult, urlparse, urlunparse from starlette.datastructures import Headers @@ -27,9 +27,18 @@ class ProcessLinksMiddleware(JsonResponseMiddleware): app: ASGIApp upstream_url: str root_path: Optional[str] = None + root_path_skip_prefixes: Sequence[str] = () json_content_type_expr: str = r"application/(geo\+)?json" + def __post_init__(self) -> None: + """Normalize skip prefixes, dropping empty entries and trailing slashes.""" + self.root_path_skip_prefixes = tuple( + prefix.rstrip("/") + for prefix in self.root_path_skip_prefixes + if prefix.rstrip("/") + ) + def should_transform_response(self, request: Request, scope: Scope) -> bool: """Only transform responses with JSON content type.""" return bool( @@ -70,6 +79,21 @@ def _update_link( parsed_link = urlparse(link["href"]) + # Leave links to sibling services untouched. On a shared-host deployment + # (e.g. this proxy at /stac and a tiler at /raster on the same hostname), + # same-host links to other services must not be treated as local to the + # upstream API and must not have the root_path prepended. + if parsed_link.netloc == request_url.netloc and any( + parsed_link.path == prefix or parsed_link.path.startswith(f"{prefix}/") + for prefix in self.root_path_skip_prefixes + ): + logger.debug( + "Ignoring link %s because its path matches a configured skip prefix (%s)", + link["href"], + self.root_path_skip_prefixes, + ) + return + if parsed_link.netloc not in [ request_url.netloc, upstream_url.netloc, diff --git a/tests/test_config.py b/tests/test_config.py index a6dcce16..9aa950de 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,7 @@ """Test config classes.""" +import pytest + from stac_auth_proxy.config import CorsSettings, Settings @@ -53,6 +55,37 @@ def test_settings_model_config(): assert settings.allowed_jwt_audiences == [""] +def test_root_path_skip_prefixes(): + """Test parsing and normalization of root_path_skip_prefixes.""" + common_kwargs = { + "upstream_url": "https://example.com", + "oidc_discovery_url": "https://example.com/.well-known/openid-configuration", + } + + # Defaults to empty (feature disabled) + settings = Settings(**common_kwargs) + assert list(settings.root_path_skip_prefixes) == [] + + # Comma-separated string, with trailing slashes normalized + settings = Settings( + **common_kwargs, + root_path_skip_prefixes="/raster/,/vector,/browser", + ) + assert list(settings.root_path_skip_prefixes) == ["/raster", "/vector", "/browser"] + + # List input + settings = Settings(**common_kwargs, root_path_skip_prefixes=["/raster"]) + assert list(settings.root_path_skip_prefixes) == ["/raster"] + + # Empty entries are dropped + settings = Settings(**common_kwargs, root_path_skip_prefixes="/raster,,") + assert list(settings.root_path_skip_prefixes) == ["/raster"] + + # Prefixes must start with a slash + with pytest.raises(ValueError): + Settings(**common_kwargs, root_path_skip_prefixes="raster") + + def test_settings_model_config_with_environment_variables(monkeypatch): """Test that the model config is set correctly with environment variables.""" monkeypatch.setenv("UPSTREAM_URL", "https://example.com") diff --git a/tests/test_process_links.py b/tests/test_process_links.py index 2896f0fa..a1337dd0 100644 --- a/tests/test_process_links.py +++ b/tests/test_process_links.py @@ -510,6 +510,134 @@ def test_transform_upstream_links_nested_objects(): ) +@pytest.mark.parametrize( + "root_path_skip_prefixes,input_links,expected_links", + [ + # Same-host links to sibling services are skipped; STAC links still rewritten + ( + ["/raster", "/vector"], + [ + {"rel": "self", "href": "http://proxy.example.com/collections"}, + { + "rel": "xyz", + "href": "http://proxy.example.com/raster/collections/c/items/i/{z}/{x}/{y}", + }, + { + "rel": "tilejson", + "href": "http://proxy.example.com/vector/tilejson.json", + }, + ], + [ + "http://proxy.example.com/stac/collections", + "http://proxy.example.com/raster/collections/c/items/i/{z}/{x}/{y}", + "http://proxy.example.com/vector/tilejson.json", + ], + ), + # Default (no skip prefixes): same-host sibling link gets root_path (current behavior) + ( + (), + [ + {"rel": "xyz", "href": "http://proxy.example.com/raster/tiles"}, + ], + [ + "http://proxy.example.com/stac/raster/tiles", + ], + ), + # Matching is path-segment-aware: "/raster" must not match "/rasterfoo" + ( + ["/raster"], + [ + {"rel": "self", "href": "http://proxy.example.com/rasterfoo/tiles"}, + {"rel": "exact", "href": "http://proxy.example.com/raster"}, + {"rel": "child", "href": "http://proxy.example.com/raster/tiles"}, + ], + [ + "http://proxy.example.com/stac/rasterfoo/tiles", + "http://proxy.example.com/raster", + "http://proxy.example.com/raster/tiles", + ], + ), + # Trailing slashes on configured prefixes are normalized + ( + ["/raster/"], + [ + {"rel": "xyz", "href": "http://proxy.example.com/raster/tiles"}, + {"rel": "exact", "href": "http://proxy.example.com/raster"}, + ], + [ + "http://proxy.example.com/raster/tiles", + "http://proxy.example.com/raster", + ], + ), + # Skip prefixes only apply to the request host; other hosts are untouched anyway + ( + ["/raster"], + [ + {"rel": "xyz", "href": "http://other.example.com/raster/tiles"}, + ], + [ + "http://other.example.com/raster/tiles", + ], + ), + ], +) +def test_transform_with_root_path_skip_prefixes( + root_path_skip_prefixes, input_links, expected_links +): + """Test that same-host links matching a skip prefix are not rewritten.""" + middleware = ProcessLinksMiddleware( + app=None, + upstream_url="http://upstream.example.com", + root_path="/stac", + root_path_skip_prefixes=root_path_skip_prefixes, + ) + request_scope = { + "type": "http", + "path": "/test", + "headers": [ + (b"host", b"proxy.example.com"), + (b"content-type", b"application/json"), + ], + } + + data = {"links": input_links} + transformed = middleware.transform_json(data, Request(request_scope)) + + for i, expected in enumerate(expected_links): + assert transformed["links"][i]["href"] == expected + + +def test_transform_with_root_path_skip_prefixes_and_forwarded_headers(): + """Test that skip prefixes apply to the forwarded (client-facing) host.""" + middleware = ProcessLinksMiddleware( + app=None, + upstream_url="http://upstream.example.com", + root_path="/stac", + root_path_skip_prefixes=["/raster"], + ) + request_scope = { + "type": "http", + "path": "/test", + "headers": [ + (b"host", b"internal-proxy:8080"), + (b"content-type", b"application/json"), + (b"x-forwarded-host", b"api.example.com"), + (b"x-forwarded-proto", b"https"), + ], + } + + data = { + "links": [ + {"rel": "self", "href": "https://api.example.com/collections"}, + {"rel": "xyz", "href": "https://api.example.com/raster/tiles"}, + ] + } + transformed = middleware.transform_json(data, Request(request_scope)) + + assert transformed["links"][0]["href"] == "https://api.example.com/stac/collections" + assert transformed["links"][1]["href"] == "https://api.example.com/raster/tiles" + + @pytest.mark.parametrize( "headers,expected_base_url", [ From 286d8db02aa7b753e829eab073914c7a4d671874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Houpert?= <10154151+lhoupert@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:37:36 +0100 Subject: [PATCH 2/4] test: pin skip-prefix scope to client-facing links; reject bare-slash prefix - add a regression test asserting upstream-host links are still rewritten even when their path matches a skip prefix (the setting describes sibling services on the proxy's public hostname) - raise on a bare '/' prefix instead of silently dropping it (it would otherwise read as 'skip everything' but do nothing) Co-Authored-By: Claude Opus 4.8 --- src/stac_auth_proxy/config.py | 2 ++ tests/test_config.py | 4 ++++ tests/test_process_links.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/src/stac_auth_proxy/config.py b/src/stac_auth_proxy/config.py index 7741f0c1..10454218 100644 --- a/src/stac_auth_proxy/config.py +++ b/src/stac_auth_proxy/config.py @@ -160,6 +160,8 @@ def parse_root_path_skip_prefixes(cls, v) -> Sequence[str] | None: for value in values: prefix = value.strip().rstrip("/") if not prefix: + if value.strip(): + raise ValueError(f"Path prefix {value!r} would match every path") continue if not prefix.startswith("/"): raise ValueError(f"Path prefix {value!r} must start with '/'") diff --git a/tests/test_config.py b/tests/test_config.py index 9aa950de..273bc4bb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -85,6 +85,10 @@ def test_root_path_skip_prefixes(): with pytest.raises(ValueError): Settings(**common_kwargs, root_path_skip_prefixes="raster") + # A bare slash would skip every same-host link + with pytest.raises(ValueError): + Settings(**common_kwargs, root_path_skip_prefixes="/") + def test_settings_model_config_with_environment_variables(monkeypatch): """Test that the model config is set correctly with environment variables.""" diff --git a/tests/test_process_links.py b/tests/test_process_links.py index a1337dd0..7f160349 100644 --- a/tests/test_process_links.py +++ b/tests/test_process_links.py @@ -638,6 +638,36 @@ def test_transform_with_root_path_skip_prefixes_and_forwarded_headers(): assert transformed["links"][1]["href"] == "https://api.example.com/raster/tiles" +def test_transform_upstream_netloc_links_ignore_skip_prefixes(): + """Skip prefixes only apply to client-facing links, not upstream-host links.""" + middleware = ProcessLinksMiddleware( + app=None, + upstream_url="http://upstream.example.com", + root_path="/stac", + root_path_skip_prefixes=["/raster"], + ) + request_scope = { + "type": "http", + "path": "/test", + "headers": [ + (b"host", b"proxy.example.com"), + (b"content-type", b"application/json"), + ], + } + + # A link on the upstream host is rewritten to the proxy host with root_path, + # even when its path matches a skip prefix: skip prefixes describe sibling + # services on the proxy's public hostname, not paths behind the upstream. + data = { + "links": [{"rel": "xyz", "href": "http://upstream.example.com/raster/tiles"}] + } + transformed = middleware.transform_json(data, Request(request_scope)) + + assert ( + transformed["links"][0]["href"] == "http://proxy.example.com/stac/raster/tiles" + ) + + @pytest.mark.parametrize( "headers,expected_base_url", [ From 0a13430d6c6b53e7385ddf2de0ca0d762e246743 Mon Sep 17 00:00:00 2001 From: Felix Delattre Date: Thu, 23 Jul 2026 16:32:49 +0200 Subject: [PATCH 3/4] fix: skip ROOT_PATH rewrite for sibling-service link prefixes. --- docs/user-guide/configuration.md | 10 ++- docs/user-guide/tips.md | 2 +- helm/values.schema.json | 5 ++ helm/values.yaml | 1 + src/stac_auth_proxy/config.py | 50 +++++++----- .../middleware/ProcessLinksMiddleware.py | 64 +++++++-------- tests/test_config.py | 14 +++- tests/test_process_links.py | 77 +++---------------- 8 files changed, 97 insertions(+), 126 deletions(-) diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index 0a23bab7..fc6da1e3 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -94,14 +94,18 @@ The application is configurable via environment variables. ### `ROOT_PATH_SKIP_PREFIXES` -: Path prefixes of sibling services on the proxy's hostname whose links must not be rewritten +: Path prefixes that should **not** get `ROOT_PATH` added - **Type:** comma-separated list of path prefixes - - **Required:** No, defaults to `''` (rewrite all same-host links) + - **Required:** No, defaults to `''` (add `ROOT_PATH` to all same-host links) - **Example:** `/raster,/vector,/browser` > [!NOTE] - > When the proxy shares a hostname with other services (e.g. the proxy at `/stac` alongside a tiler at `/raster`), STAC responses may contain links to those sibling services on the same host. By default the proxy cannot distinguish these from its own links and would prepend `ROOT_PATH` to them, breaking them. List the sibling services' path prefixes here to leave such links untouched. Matching is path-segment-aware (`/raster` matches `/raster` and `/raster/...`, but not `/rasterfoo`). + > The proxy usually **adds** `ROOT_PATH` to same-host links that do not have it yet (e.g. `/collections` → `/stac/collections`). That is intentional: upstream often returns links without `ROOT_PATH`. + > + > The problem is when other apps live on the same host (e.g. a tiler at `/raster`). Their links look like same-host links too, so the proxy would turn `/raster/...` into `/stac/raster/...` and break them. List those other apps' path prefixes here so they are left alone. (If a link still uses the upstream hostname, the proxy can still rewrite the host to the public one; only the `ROOT_PATH` add is skipped.) + > + > Matching is by path segment: `/raster` matches `/raster` and `/raster/...`, but not `/rasterfoo`. ## Authentication diff --git a/docs/user-guide/tips.md b/docs/user-guide/tips.md index bcae0f73..67323f62 100644 --- a/docs/user-guide/tips.md +++ b/docs/user-guide/tips.md @@ -39,7 +39,7 @@ The proxy can be optionally served from a non-root path (e.g., `/api/v1`). Addit - Update the OpenAPI specification to include the `ROOT_PATH` in the servers field - Handle requests that don't match the `ROOT_PATH` with a 404 response -If the proxy shares its hostname with other services (e.g. the proxy at `/stac` alongside a tiler at `/raster`), links to those services would also receive the `ROOT_PATH` prefix. Use `ROOT_PATH_SKIP_PREFIXES` to exempt their path prefixes from link rewriting. +If other apps share the same host (e.g. the proxy at `/stac` and a tiler at `/raster`), their links would also get `ROOT_PATH` added by mistake. Set `ROOT_PATH_SKIP_PREFIXES` to those paths (e.g. `/raster`) so the proxy leaves them alone. ## Non-OIDC Workaround diff --git a/helm/values.schema.json b/helm/values.schema.json index 9ca36a22..df705d49 100644 --- a/helm/values.schema.json +++ b/helm/values.schema.json @@ -70,6 +70,11 @@ "description": "Path prefix for the proxy API. Also prefixes probe httpGet.path when those paths are unset.", "default": "" }, + "ROOT_PATH_SKIP_PREFIXES": { + "type": "string", + "description": "Comma-separated path prefixes that should not get ROOT_PATH added (e.g. /raster,/vector,/browser when those apps share the same host).", + "default": "" + }, "OIDC_DISCOVERY_URL": { "type": "string", "pattern": "^https?://.+", diff --git a/helm/values.yaml b/helm/values.yaml index 6b94f90c..ae1ece78 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -148,6 +148,7 @@ env: # Used with ROOT_PATH to derive probe httpGet.path when not set explicitly HEALTHZ_PREFIX: "/healthz" # ROOT_PATH: "/stac" # If set, probes default to {ROOT_PATH}{HEALTHZ_PREFIX} + # ROOT_PATH_SKIP_PREFIXES: "/raster,/vector,/browser" # Other apps on the same host; do not add ROOT_PATH OIDC_DISCOVERY_INTERNAL_URL: "http://localhost:8081/.well-known/openid-configuration" DEFAULT_PUBLIC: false PRIVATE_ENDPOINTS: | diff --git a/src/stac_auth_proxy/config.py b/src/stac_auth_proxy/config.py index 10454218..6166ab11 100644 --- a/src/stac_auth_proxy/config.py +++ b/src/stac_auth_proxy/config.py @@ -2,11 +2,11 @@ import importlib import json -from typing import Any, Literal, Optional, Sequence, TypeAlias, Union +from typing import Annotated, Any, Literal, Optional, Sequence, TypeAlias, Union from pydantic import BaseModel, Field, field_validator, model_validator from pydantic.networks import HttpUrl -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict METHODS = Literal["GET", "POST", "PUT", "DELETE", "PATCH"] EndpointMethods: TypeAlias = dict[str, Sequence[METHODS]] @@ -28,6 +28,31 @@ def str2list(x: str | Sequence[str] | None) -> Sequence[str] | None: return x +def normalize_root_path_skip_prefixes( + values: Sequence[str] | None, +) -> tuple[str, ...]: + """ + Clean up path prefixes that should not get ``ROOT_PATH`` added. + + Drops empty entries and trailing slashes. Each prefix must start with + ``/``. A bare ``/`` is rejected because it would match every path. + """ + if not values: + return () + + prefixes: list[str] = [] + for value in values: + prefix = value.strip().rstrip("/") + if not prefix: + if value.strip(): + raise ValueError(f"Path prefix {value!r} would match every path") + continue + if not prefix.startswith("/"): + raise ValueError(f"Path prefix {value!r} must start with '/'") + prefixes.append(prefix) + return tuple(prefixes) + + class _ClassInput(BaseModel): """Input model for dynamically loading a class or function.""" @@ -79,7 +104,9 @@ class Settings(BaseSettings): allowed_jwt_audiences: Optional[Sequence[str]] = None root_path: str = "" - root_path_skip_prefixes: Sequence[str] = () + # NoDecode: pydantic-settings JSON-decodes Sequence fields before validators, + # which rejects the documented comma-separated env form (/raster,/vector). + root_path_skip_prefixes: Annotated[Sequence[str], NoDecode] = () override_host: bool = True healthz_prefix: str = Field(pattern=_PREFIX_PATTERN, default="/healthz") upstream_timeout: float = 15.0 @@ -151,19 +178,6 @@ def parse_audience(cls, v) -> Sequence[str] | None: @field_validator("root_path_skip_prefixes", mode="before") @classmethod - def parse_root_path_skip_prefixes(cls, v) -> Sequence[str] | None: + def parse_root_path_skip_prefixes(cls, v) -> Sequence[str]: """Parse a comma separated string of path prefixes into a normalized list.""" - values = str2list(v) - if values is None: - return values - prefixes = [] - for value in values: - prefix = value.strip().rstrip("/") - if not prefix: - if value.strip(): - raise ValueError(f"Path prefix {value!r} would match every path") - continue - if not prefix.startswith("/"): - raise ValueError(f"Path prefix {value!r} must start with '/'") - prefixes.append(prefix) - return prefixes + return normalize_root_path_skip_prefixes(str2list(v)) diff --git a/src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py b/src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py index 9b7967a5..7776740b 100644 --- a/src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py +++ b/src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py @@ -27,17 +27,9 @@ class ProcessLinksMiddleware(JsonResponseMiddleware): app: ASGIApp upstream_url: str root_path: Optional[str] = None - root_path_skip_prefixes: Sequence[str] = () json_content_type_expr: str = r"application/(geo\+)?json" - - def __post_init__(self) -> None: - """Normalize skip prefixes, dropping empty entries and trailing slashes.""" - self.root_path_skip_prefixes = tuple( - prefix.rstrip("/") - for prefix in self.root_path_skip_prefixes - if prefix.rstrip("/") - ) + root_path_skip_prefixes: Sequence[str] = () def should_transform_response(self, request: Request, scope: Scope) -> bool: """Only transform responses with JSON content type.""" @@ -79,21 +71,6 @@ def _update_link( parsed_link = urlparse(link["href"]) - # Leave links to sibling services untouched. On a shared-host deployment - # (e.g. this proxy at /stac and a tiler at /raster on the same hostname), - # same-host links to other services must not be treated as local to the - # upstream API and must not have the root_path prepended. - if parsed_link.netloc == request_url.netloc and any( - parsed_link.path == prefix or parsed_link.path.startswith(f"{prefix}/") - for prefix in self.root_path_skip_prefixes - ): - logger.debug( - "Ignoring link %s because its path matches a configured skip prefix (%s)", - link["href"], - self.root_path_skip_prefixes, - ) - return - if parsed_link.netloc not in [ request_url.netloc, upstream_url.netloc, @@ -106,16 +83,22 @@ def _update_link( ) return - # If the link path is not a descendant of the upstream path, don't transform it + # Skip links outside the upstream path, unless they match a skip prefix + # (those still need host rewrite, just not root_path). if upstream_url.path != "/" and not parsed_link.path.startswith( upstream_url.path ): - logger.debug( - "Ignoring link %s because it is not descendant of upstream path (%s)", - link["href"], - upstream_url.path, - ) - return + path = parsed_link.path + if not any( + path == prefix or path.startswith(f"{prefix}/") + for prefix in self.root_path_skip_prefixes + ): + logger.debug( + "Ignoring link %s because it is not descendant of upstream path (%s)", + link["href"], + upstream_url.path, + ) + return # Replace the upstream host with the client's host if parsed_link.netloc == upstream_url.netloc: @@ -129,14 +112,19 @@ def _update_link( path=parsed_link.path[len(upstream_url.path) :] ) - # Add the root_path to the link if it exists - if self.root_path and not ( - parsed_link.path.startswith(f"{self.root_path}/") - or parsed_link.path == self.root_path + # Add root_path unless this link is for another app on the same host + # (listed in root_path_skip_prefixes), or it already has root_path. + path = parsed_link.path + skip_root_path = any( + path == prefix or path.startswith(f"{prefix}/") + for prefix in self.root_path_skip_prefixes + ) + if ( + self.root_path + and not skip_root_path + and not (path.startswith(f"{self.root_path}/") or path == self.root_path) ): - parsed_link = parsed_link._replace( - path=f"{self.root_path}{parsed_link.path}" - ) + parsed_link = parsed_link._replace(path=f"{self.root_path}{path}") updated_href = urlunparse(parsed_link) if updated_href == link["href"]: diff --git a/tests/test_config.py b/tests/test_config.py index 273bc4bb..f0ac5334 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -85,11 +85,23 @@ def test_root_path_skip_prefixes(): with pytest.raises(ValueError): Settings(**common_kwargs, root_path_skip_prefixes="raster") - # A bare slash would skip every same-host link + # A bare "/" would mean "skip everything" — reject it with pytest.raises(ValueError): Settings(**common_kwargs, root_path_skip_prefixes="/") +def test_root_path_skip_prefixes_from_environment(monkeypatch): + """Comma-separated env value (e.g. /raster,/vector) must load as a list.""" + monkeypatch.setenv("UPSTREAM_URL", "https://example.com") + monkeypatch.setenv( + "OIDC_DISCOVERY_URL", "https://example.com/.well-known/openid-configuration" + ) + monkeypatch.setenv("ROOT_PATH_SKIP_PREFIXES", "/raster,/vector,/browser") + + settings = Settings() + assert list(settings.root_path_skip_prefixes) == ["/raster", "/vector", "/browser"] + + def test_settings_model_config_with_environment_variables(monkeypatch): """Test that the model config is set correctly with environment variables.""" monkeypatch.setenv("UPSTREAM_URL", "https://example.com") diff --git a/tests/test_process_links.py b/tests/test_process_links.py index 7f160349..f422377d 100644 --- a/tests/test_process_links.py +++ b/tests/test_process_links.py @@ -513,7 +513,7 @@ def test_transform_upstream_links_nested_objects(): @pytest.mark.parametrize( "root_path_skip_prefixes,input_links,expected_links", [ - # Same-host links to sibling services are skipped; STAC links still rewritten + # Skip-listed paths stay put; normal STAC paths still get /stac ( ["/raster", "/vector"], [ @@ -533,7 +533,7 @@ def test_transform_upstream_links_nested_objects(): "http://proxy.example.com/vector/tilejson.json", ], ), - # Default (no skip prefixes): same-host sibling link gets root_path (current behavior) + # No skip list: even /raster gets /stac added (the bug without this setting) ( (), [ @@ -543,7 +543,7 @@ def test_transform_upstream_links_nested_objects(): "http://proxy.example.com/stac/raster/tiles", ], ), - # Matching is path-segment-aware: "/raster" must not match "/rasterfoo" + # "/raster" matches /raster and /raster/..., but not /rasterfoo ( ["/raster"], [ @@ -557,34 +557,12 @@ def test_transform_upstream_links_nested_objects(): "http://proxy.example.com/raster/tiles", ], ), - # Trailing slashes on configured prefixes are normalized - ( - ["/raster/"], - [ - {"rel": "xyz", "href": "http://proxy.example.com/raster/tiles"}, - {"rel": "exact", "href": "http://proxy.example.com/raster"}, - ], - [ - "http://proxy.example.com/raster/tiles", - "http://proxy.example.com/raster", - ], - ), - # Skip prefixes only apply to the request host; other hosts are untouched anyway - ( - ["/raster"], - [ - {"rel": "xyz", "href": "http://other.example.com/raster/tiles"}, - ], - [ - "http://other.example.com/raster/tiles", - ], - ), ], ) def test_transform_with_root_path_skip_prefixes( root_path_skip_prefixes, input_links, expected_links ): - """Test that same-host links matching a skip prefix are not rewritten.""" + """Skip-listed paths keep their path; STAC links still get root_path.""" middleware = ProcessLinksMiddleware( app=None, upstream_url="http://upstream.example.com", @@ -607,42 +585,16 @@ def test_transform_with_root_path_skip_prefixes( assert transformed["links"][i]["href"] == expected -def test_transform_with_root_path_skip_prefixes_and_forwarded_headers(): - """Test that skip prefixes apply to the forwarded (client-facing) host.""" - middleware = ProcessLinksMiddleware( - app=None, - upstream_url="http://upstream.example.com", - root_path="/stac", - root_path_skip_prefixes=["/raster"], - ) - request_scope = { - "type": "http", - "path": "/test", - "headers": [ - (b"host", b"internal-proxy:8080"), - (b"content-type", b"application/json"), - (b"x-forwarded-host", b"api.example.com"), - (b"x-forwarded-proto", b"https"), - ], - } - - data = { - "links": [ - {"rel": "self", "href": "https://api.example.com/collections"}, - {"rel": "xyz", "href": "https://api.example.com/raster/tiles"}, - ] - } - transformed = middleware.transform_json(data, Request(request_scope)) - - assert transformed["links"][0]["href"] == "https://api.example.com/stac/collections" - assert transformed["links"][1]["href"] == "https://api.example.com/raster/tiles" +def test_transform_upstream_netloc_links_honor_skip_prefixes(): + """ + Skip-listed links on the upstream host: rewrite host, do not add root_path. - -def test_transform_upstream_netloc_links_ignore_skip_prefixes(): - """Skip prefixes only apply to client-facing links, not upstream-host links.""" + The link is outside the upstream path (/api), so without the skip list it + would be ignored entirely. With the skip list, we still swap the host. + """ middleware = ProcessLinksMiddleware( app=None, - upstream_url="http://upstream.example.com", + upstream_url="http://upstream.example.com/api", root_path="/stac", root_path_skip_prefixes=["/raster"], ) @@ -655,17 +607,12 @@ def test_transform_upstream_netloc_links_ignore_skip_prefixes(): ], } - # A link on the upstream host is rewritten to the proxy host with root_path, - # even when its path matches a skip prefix: skip prefixes describe sibling - # services on the proxy's public hostname, not paths behind the upstream. data = { "links": [{"rel": "xyz", "href": "http://upstream.example.com/raster/tiles"}] } transformed = middleware.transform_json(data, Request(request_scope)) - assert ( - transformed["links"][0]["href"] == "http://proxy.example.com/stac/raster/tiles" - ) + assert transformed["links"][0]["href"] == "http://proxy.example.com/raster/tiles" @pytest.mark.parametrize( From 9b0361c284fbd5eb2eed989dec881e454513d79b Mon Sep 17 00:00:00 2001 From: Felix Delattre Date: Thu, 23 Jul 2026 17:30:24 +0200 Subject: [PATCH 4/4] Love you, too, ponytail. --- pyproject.toml | 2 +- src/stac_auth_proxy/config.py | 49 +++++++++---------- .../middleware/ProcessLinksMiddleware.py | 19 ++++--- uv.lock | 2 +- 4 files changed, 33 insertions(+), 39 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d06a16be..36d6a1d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "fastapi>=0.115.5", "httpx[http2]>=0.28.0", "jinja2>=3.1.6", - "pydantic-settings>=2.6.1", + "pydantic-settings>=2.7.0", "pyjwt>=2.10.1", "starlette>=1.0.1", "starlette-cramjam>=0.4.0", diff --git a/src/stac_auth_proxy/config.py b/src/stac_auth_proxy/config.py index 6166ab11..ea140560 100644 --- a/src/stac_auth_proxy/config.py +++ b/src/stac_auth_proxy/config.py @@ -28,31 +28,6 @@ def str2list(x: str | Sequence[str] | None) -> Sequence[str] | None: return x -def normalize_root_path_skip_prefixes( - values: Sequence[str] | None, -) -> tuple[str, ...]: - """ - Clean up path prefixes that should not get ``ROOT_PATH`` added. - - Drops empty entries and trailing slashes. Each prefix must start with - ``/``. A bare ``/`` is rejected because it would match every path. - """ - if not values: - return () - - prefixes: list[str] = [] - for value in values: - prefix = value.strip().rstrip("/") - if not prefix: - if value.strip(): - raise ValueError(f"Path prefix {value!r} would match every path") - continue - if not prefix.startswith("/"): - raise ValueError(f"Path prefix {value!r} must start with '/'") - prefixes.append(prefix) - return tuple(prefixes) - - class _ClassInput(BaseModel): """Input model for dynamically loading a class or function.""" @@ -179,5 +154,25 @@ def parse_audience(cls, v) -> Sequence[str] | None: @field_validator("root_path_skip_prefixes", mode="before") @classmethod def parse_root_path_skip_prefixes(cls, v) -> Sequence[str]: - """Parse a comma separated string of path prefixes into a normalized list.""" - return normalize_root_path_skip_prefixes(str2list(v)) + """ + Parse and normalize path prefixes that should not get ``ROOT_PATH`` added. + + Accepts a comma-separated string or sequence. Drops empty entries and + trailing slashes. Each prefix must start with ``/``. A bare ``/`` is + rejected because it would match every path. + """ + values = str2list(v) + if not values: + return () + + prefixes: list[str] = [] + for value in values: + prefix = value.strip().rstrip("/") + if not prefix: + if value.strip(): + raise ValueError(f"Path prefix {value!r} would match every path") + continue + if not prefix.startswith("/"): + raise ValueError(f"Path prefix {value!r} must start with '/'") + prefixes.append(prefix) + return tuple(prefixes) diff --git a/src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py b/src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py index 7776740b..385c5ec4 100644 --- a/src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py +++ b/src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py @@ -58,6 +58,13 @@ def transform_json(self, data: dict[str, Any], request: Request) -> dict[str, An ) return data + def _matches_skip_prefix(self, path: str) -> bool: + """Return whether path is exactly a skip prefix or under one of them.""" + return any( + path == prefix or path.startswith(f"{prefix}/") + for prefix in self.root_path_skip_prefixes + ) + def _update_link( self, link: dict[str, Any], request_url: ParseResult, upstream_url: ParseResult ) -> None: @@ -88,11 +95,7 @@ def _update_link( if upstream_url.path != "/" and not parsed_link.path.startswith( upstream_url.path ): - path = parsed_link.path - if not any( - path == prefix or path.startswith(f"{prefix}/") - for prefix in self.root_path_skip_prefixes - ): + if not self._matches_skip_prefix(parsed_link.path): logger.debug( "Ignoring link %s because it is not descendant of upstream path (%s)", link["href"], @@ -115,13 +118,9 @@ def _update_link( # Add root_path unless this link is for another app on the same host # (listed in root_path_skip_prefixes), or it already has root_path. path = parsed_link.path - skip_root_path = any( - path == prefix or path.startswith(f"{prefix}/") - for prefix in self.root_path_skip_prefixes - ) if ( self.root_path - and not skip_root_path + and not self._matches_skip_prefix(path) and not (path.startswith(f"{self.root_path}/") or path == self.root_path) ): parsed_link = parsed_link._replace(path=f"{self.root_path}{path}") diff --git a/uv.lock b/uv.lock index dee2a6bb..e946742e 100644 --- a/uv.lock +++ b/uv.lock @@ -2075,7 +2075,7 @@ requires-dist = [ { name = "mkdocs-material", extras = ["imaging"], marker = "extra == 'docs'", specifier = ">=9.6.16" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.30.0" }, { name = "prometheus-fastapi-instrumentator", marker = "extra == 'metrics'", specifier = ">=8.0.2" }, - { name = "pydantic-settings", specifier = ">=2.6.1" }, + { name = "pydantic-settings", specifier = ">=2.7.0" }, { name = "pyjwt", specifier = ">=2.10.1" }, { name = "starlette", specifier = ">=1.0.1" }, { name = "starlette-cramjam", specifier = ">=0.4.0" },