diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index ae82c3c..0a23bab 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 1f14440..bcae0f7 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 1c56d0c..706a3ce 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 9c11ad9..1045421 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,22 @@ 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: + 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 diff --git a/src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py b/src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py index 112f638..9b7967a 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 a6dcce1..273bc4b 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,41 @@ 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") + + # 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.""" monkeypatch.setenv("UPSTREAM_URL", "https://example.com") diff --git a/tests/test_process_links.py b/tests/test_process_links.py index 2896f0f..7f16034 100644 --- a/tests/test_process_links.py +++ b/tests/test_process_links.py @@ -510,6 +510,164 @@ 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" + + +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", [