Restrict proxy forwarding to an exact method/path allowlist

This commit is contained in:
9qeklajc
2026-08-24 01:08:33 +02:00
parent 0217002ea1
commit 5af04364c9
5 changed files with 325 additions and 78 deletions
+11
View File
@@ -101,6 +101,12 @@ class Settings(BaseSettings):
# Network # Network
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS") cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
# Comma-separated METHOD:path pairs adding to the proxy's canonical
# endpoint allowlist, e.g. "POST:v1/rerank,GET:batches". Only for upstreams
# exposing an endpoint outside the OpenAI-compatible set; each addition
# widens what the provider credential can be spent against, so wildcards
# and prefixes are not supported.
proxy_extra_allowed_paths: str = Field(default="", env="PROXY_EXTRA_ALLOWED_PATHS")
tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL") tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL")
providers_refresh_interval_seconds: int = Field( providers_refresh_interval_seconds: int = Field(
default=0, env="PROVIDERS_REFRESH_INTERVAL_SECONDS" default=0, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
@@ -192,6 +198,11 @@ SECRET_FIELDS = frozenset({"admin_password", "nsec"})
# neither store nor shadow them; env is always authoritative. # neither store nor shadow them; env is always authoritative.
ENV_ONLY_FIELDS = frozenset( ENV_ONLY_FIELDS = frozenset(
{ {
# Widening the proxy's reachable upstream surface is a deployment
# decision, not a runtime toggle: it changes what the provider
# credential can be spent against. Keeping it env-only also lets the
# proxy parse it once at import without going stale.
"proxy_extra_allowed_paths",
"database_pool_size", "database_pool_size",
"database_max_overflow", "database_max_overflow",
"database_pool_timeout", "database_pool_timeout",
+115 -33
View File
@@ -26,6 +26,7 @@ from .core.db import (
) )
from .core.exceptions import UpstreamError from .core.exceptions import UpstreamError
from .core.not_found import build_not_found_response from .core.not_found import build_not_found_response
from .core.settings import settings
from .payment.helpers import ( from .payment.helpers import (
calculate_discounted_max_cost, calculate_discounted_max_cost,
check_token_balance, check_token_balance,
@@ -221,27 +222,93 @@ async def refresh_model_maps_periodically() -> None:
) )
_API_PATH_PREFIXES = ( # Canonical endpoints this proxy will forward, keyed by the path with any
"v1/", # leading "v1/" and trailing slash removed, mapped to the methods allowed on
"responses", # each. The provider credential is attached during forwarding, so endpoint
"chat/", # permission has to come from this table rather than from the client-supplied
"completions", # path: an upstream's key-management, organization, or billing routes live
"models", # under the same origin and must never be reachable through the proxy.
"embeddings", _ALLOWED_ENDPOINTS: dict[str, frozenset[str]] = {
"audio/", "chat/completions": frozenset({"POST"}),
"images/", "completions": frozenset({"POST"}),
"moderations", "responses": frozenset({"POST"}),
"providers", "messages": frozenset({"POST"}),
"tee/", "embeddings": frozenset({"POST"}),
"attestation", "moderations": frozenset({"POST"}),
) "rerank": frozenset({"POST"}),
"audio/speech": frozenset({"POST"}),
"audio/transcriptions": frozenset({"POST"}),
"audio/translations": frozenset({"POST"}),
"images/generations": frozenset({"POST"}),
"images/edits": frozenset({"POST"}),
"images/variations": frozenset({"POST"}),
"models": frozenset({"GET"}),
"attestation": frozenset({"GET"}),
"tee/attestation": frozenset({"GET"}),
}
# Split the allowlist by spelling so bare tokens anchor to a path segment. _ALLOWED_METHODS = frozenset({"GET", "POST"})
# A slash-terminated prefix ("v1/") is already segment-anchored under
# startswith. A bare token ("models") must match a whole segment — exactly or
# followed by "/" — so "modelsdump" / "attestationadmin" cannot slip through. def _canonical_api_path(path: str) -> str:
_API_SLASH_PREFIXES = tuple(p for p in _API_PATH_PREFIXES if p.endswith("/")) """Reduce a request path to its allowlist key.
_API_BARE_PREFIXES = tuple(p for p in _API_PATH_PREFIXES if not p.endswith("/"))
OpenAI-style clients reach the same endpoint with or without the ``v1/``
prefix and with or without a trailing slash, so both spellings collapse to
one key. Callers must screen the path with
:func:`_is_ambiguously_spelled_path` first — this function assumes the path
has no dot segments, empty segments, or encoded separators left to resolve.
"""
core = path[:-1] if path.endswith("/") else path
if core.startswith("v1/"):
core = core[len("v1/") :]
return core
def _parse_extra_allowed_endpoints(raw: str) -> dict[str, frozenset[str]]:
"""Parse operator-configured additions to the endpoint allowlist.
Deployments whose provider exposes an endpoint outside the canonical set
opt in explicitly with ``PROXY_EXTRA_ALLOWED_PATHS``, a comma-separated
list of ``METHOD:path`` pairs (e.g. ``POST:v1/rerank,GET:batches``). Every
entry must name one concrete method and one unambiguous path; wildcards
and bare prefixes are deliberately unsupported, so widening the proxy's
reach is always a per-endpoint decision. Malformed entries are dropped
with a warning rather than silently widening or narrowing the surface.
"""
extra: dict[str, frozenset[str]] = {}
for entry in raw.split(","):
entry = entry.strip()
if not entry:
continue
method, separator, endpoint = entry.partition(":")
method = method.strip().upper()
endpoint = endpoint.strip()
if not separator or method not in _ALLOWED_METHODS or not endpoint:
logger.warning(
"Ignoring malformed PROXY_EXTRA_ALLOWED_PATHS entry",
extra={"entry": entry},
)
continue
if _is_ambiguously_spelled_path(endpoint):
logger.warning(
"Ignoring ambiguously spelled PROXY_EXTRA_ALLOWED_PATHS entry",
extra={"entry": entry},
)
continue
if any(character in endpoint for character in "*?["):
# Refuse glob syntax outright. Kept as a literal endpoint name it
# would never match a real request, so the operator would think
# they had widened the proxy when they had not.
logger.warning(
"Ignoring wildcard PROXY_EXTRA_ALLOWED_PATHS entry; "
"list each endpoint explicitly",
extra={"entry": entry},
)
continue
key = _canonical_api_path(endpoint)
extra[key] = extra.get(key, frozenset()) | {method}
return extra
def _is_ambiguously_spelled_path(path: str) -> bool: def _is_ambiguously_spelled_path(path: str) -> bool:
@@ -267,20 +334,35 @@ def _is_ambiguously_spelled_path(path: str) -> bool:
return "%2e" in lowered or "%2f" in lowered or "%5c" in lowered return "%2e" in lowered or "%2f" in lowered or "%5c" in lowered
def _forwarding_allowed(path: str, is_ehbp: bool) -> bool: _EXTRA_ALLOWED_ENDPOINTS = _parse_extra_allowed_endpoints(
"""Gate which paths may reach an upstream at all. settings.proxy_extra_allowed_paths
)
The provider credential is attached during forwarding, so an unknown path
must never be forwarded on the caller's say-so. A path must resolve to a def _allowed_methods_for(endpoint: str) -> frozenset[str]:
known API prefix; EHBP requests are identified by header and carry their own """Return the methods allowed on a canonical endpoint, empty if unknown."""
encrypted contract. Endpoint permission is derived from this allowlist, not methods = _ALLOWED_ENDPOINTS.get(endpoint, frozenset())
from the client-supplied path. methods |= _EXTRA_ALLOWED_ENDPOINTS.get(endpoint, frozenset())
return methods
def _forwarding_allowed(path: str, method: str) -> bool:
"""Gate which method/path pairs may reach an upstream at all.
The provider credential is attached during forwarding, so an unknown
endpoint must never be forwarded on the caller's say-so. The path is
reduced to its canonical form and looked up in the endpoint table; there is
no prefix match, so a known prefix no longer carries an unknown endpoint
(``v1/organization/api_keys`` is rejected even though ``v1/`` is familiar).
EHBP requests are gated by the same table. Their body is opaque to the
proxy, which is a reason to constrain the destination more tightly, not to
trust the caller's path: the encrypted contract covers the body, never the
endpoint the credential is spent against.
""" """
if is_ehbp: if method not in _ALLOWED_METHODS:
return True return False
if path.startswith(_API_SLASH_PREFIXES): return method in _allowed_methods_for(_canonical_api_path(path))
return True
return any(path == p or path.startswith(p + "/") for p in _API_BARE_PREFIXES)
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None) @proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
@@ -310,7 +392,7 @@ async def _proxy(
headers = dict(request.headers) headers = dict(request.headers)
is_ehbp = "ehbp-encapsulated-key" in headers is_ehbp = "ehbp-encapsulated-key" in headers
if not _forwarding_allowed(path, is_ehbp): if not _forwarding_allowed(path, request.method):
return build_not_found_response(request, path) return build_not_found_response(request, path)
is_responses_api = path.startswith("v1/responses") or path.startswith("responses") is_responses_api = path.startswith("v1/responses") or path.startswith("responses")
+67 -14
View File
@@ -318,6 +318,43 @@ async def test_authenticated_post_to_unknown_path_is_rejected(
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.parametrize(
"bad_path",
[
# Unambiguously spelled, under a prefix the proxy serves, but not an
# endpoint it offers. These are real upstream routes that manage keys,
# org membership, and billing on the same origin as inference.
"v1/organization/api_keys",
"v1/api_keys",
"v1/billing/usage",
"v1/admin/keys",
# An id segment is honoured one level deep, and only where an endpoint
# takes one at all.
"v1/chat/completions/abc",
"v1/models/gpt-4/secret",
],
)
async def test_known_prefix_does_not_carry_an_unknown_endpoint(
authenticated_client: AsyncClient, bad_path: str
) -> None:
"""A familiar prefix must not be a passport for the rest of the origin.
Nothing here is traversal-shaped, so the spelling screen lets it by; only
the endpoint allowlist stops it. Forwarding raises if the guard misses,
so a clean 404 also proves the credential never left."""
with patch(
"routstr.upstream.base.BaseUpstreamProvider.forward_request",
AsyncMock(side_effect=AssertionError("must not forward unknown endpoint")),
):
response = await authenticated_client.post(
f"/{bad_path}",
json={"model": "gpt-3.5-turbo", "messages": []},
)
assert response.status_code == 404
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_unknown_path_is_rejected(integration_client: AsyncClient) -> None: async def test_get_unknown_path_is_rejected(integration_client: AsyncClient) -> None:
@@ -328,21 +365,37 @@ async def test_get_unknown_path_is_rejected(integration_client: AsyncClient) ->
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ambiguous_path_is_rejected_before_ehbp_exemption( @pytest.mark.parametrize(
integration_client: AsyncClient, "bad_path",
[
# Percent-encoded so the traversal survives the client to the server,
# which decodes it to "v1/../admin" before routing.
"v1/%2e%2e/admin",
# Well-spelled, so only the endpoint allowlist can stop it.
"v1/organization/api_keys",
"anything/encrypted",
],
)
async def test_ehbp_request_is_gated_by_the_endpoint_allowlist(
integration_client: AsyncClient, bad_path: str
) -> None: ) -> None:
"""The ambiguous-spelling screen runs before the EHBP header exemption, so """EHBP hides the body from the proxy, not the destination.
an EHBP request cannot smuggle a traversal path past it."""
# Percent-encoded so the traversal survives the client to the server, which The encrypted contract covers the request body; it says nothing about which
# decodes it to "v1/../admin" before routing. endpoint the provider credential gets spent against, so an EHBP request is
response = await integration_client.post( screened and allowlisted exactly like any other."""
"/v1/%2e%2e/admin", with patch(
content=b"encrypted", "routstr.proxy.forward_ehbp_request",
headers={ AsyncMock(side_effect=AssertionError("must not forward unknown endpoint")),
"ehbp-encapsulated-key": "x", ):
"x-routstr-model": "gpt-4", response = await integration_client.post(
}, f"/{bad_path}",
) content=b"encrypted",
headers={
"ehbp-encapsulated-key": "x",
"x-routstr-model": "gpt-4",
},
)
assert response.status_code == 404 assert response.status_code == 404
+124 -21
View File
@@ -2,8 +2,9 @@
An authenticated POST used to be forwarded for ANY path, so a caller could reach An authenticated POST used to be forwarded for ANY path, so a caller could reach
arbitrary or traversal-shaped upstream endpoints with the provider credential arbitrary or traversal-shaped upstream endpoints with the provider credential
attached. The proxy now rejects ambiguous path spellings for every method and attached. The proxy now rejects ambiguous path spellings for every method, then
requires a known API prefix before anything is forwarded. requires the method/path pair to name a canonical endpoint. A familiar prefix is
no longer enough: "v1/organization/api_keys" is refused just like "internal/admin".
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,6 +19,7 @@ import pytest # noqa: E402
from routstr.proxy import ( # noqa: E402 from routstr.proxy import ( # noqa: E402
_forwarding_allowed, _forwarding_allowed,
_is_ambiguously_spelled_path, _is_ambiguously_spelled_path,
_parse_extra_allowed_endpoints,
) )
@@ -63,37 +65,138 @@ def test_canonical_paths_are_allowed(path: str) -> None:
def test_unknown_paths_are_not_forwarded() -> None: def test_unknown_paths_are_not_forwarded() -> None:
# The credential is attached during forwarding, so an unknown endpoint must # The credential is attached during forwarding, so an unknown endpoint must
# never be forwarded on the caller's say-so. # never be forwarded on the caller's say-so.
assert _forwarding_allowed("internal/admin", is_ehbp=False) is False assert _forwarding_allowed("internal/admin", "POST") is False
assert _forwarding_allowed("secret-endpoint", is_ehbp=False) is False assert _forwarding_allowed("secret-endpoint", "POST") is False
@pytest.mark.parametrize( @pytest.mark.parametrize(
"path", "path",
[ [
"modelsdump", # bare token "models" must not match a longer segment "modelsdump", # "models" must not match a longer segment
"attestationadmin", "attestationadmin",
"providers-secret", "providers-secret",
"embeddingsx", "embeddingsx",
"completions-internal", "completions-internal",
], ],
) )
def test_bare_prefix_does_not_match_a_longer_segment(path: str) -> None: def test_endpoint_name_does_not_match_a_longer_segment(path: str) -> None:
assert _forwarding_allowed(path, is_ehbp=False) is False assert _forwarding_allowed(path, "POST") is False
assert _forwarding_allowed(path, "GET") is False
def test_known_prefixes_are_forwarded() -> None: @pytest.mark.parametrize(
assert _forwarding_allowed("v1/chat/completions", is_ehbp=False) is True "path",
assert _forwarding_allowed("chat/completions", is_ehbp=False) is True [
# Bare tokens match a whole segment: exactly or followed by "/". # A familiar prefix must not carry an unknown endpoint. These are real
assert _forwarding_allowed("models", is_ehbp=False) is True # upstream routes that manage keys, org membership, and billing.
assert _forwarding_allowed("models/gpt-4", is_ehbp=False) is True "v1/organization/api_keys",
assert _forwarding_allowed("embeddings", is_ehbp=False) is True "v1/api_keys",
assert _forwarding_allowed("attestation", is_ehbp=False) is True "v1/admin/keys",
"v1/billing/usage",
"v1/files",
"v1/batches",
"chat/internal",
"audio/internal",
"images/internal",
"tee/keys",
# No endpoint takes a trailing id segment; a resource id never widens
# the reachable surface.
"models/gpt-4",
"models/gpt-4/secret",
"chat/completions/abc",
],
)
def test_known_prefix_does_not_carry_an_unknown_endpoint(path: str) -> None:
assert _forwarding_allowed(path, "POST") is False
assert _forwarding_allowed(path, "GET") is False
def test_ehbp_bypasses_prefix_gate_by_header() -> None: @pytest.mark.parametrize(
# Documents a deliberate exemption: EHBP is identified by header and carries ("path", "method"),
# its own encrypted contract, so the prefix gate does not apply. The [
# ambiguous-spelling screen still runs on EHBP paths (see the ordering test ("v1/chat/completions", "POST"),
# in the integration suite). ("chat/completions", "POST"),
assert _forwarding_allowed("anything/encrypted", is_ehbp=True) is True ("v1/chat/completions/", "POST"),
("completions", "POST"),
("v1/responses", "POST"),
("v1/messages", "POST"),
("v1/embeddings", "POST"),
("moderations", "POST"),
("audio/transcriptions", "POST"),
("images/generations", "POST"),
("models", "GET"),
("attestation", "GET"),
("tee/attestation", "GET"),
],
)
def test_canonical_endpoints_are_forwarded(path: str, method: str) -> None:
assert _forwarding_allowed(path, method) is True
@pytest.mark.parametrize(
("path", "method"),
[
("chat/completions", "GET"), # billed endpoints are POST-only
("v1/embeddings", "GET"),
("models", "POST"), # read-only endpoints are GET-only
("attestation", "POST"),
("v1/chat/completions", "DELETE"), # never routed here, refused anyway
("v1/chat/completions", "PUT"),
],
)
def test_method_must_match_the_endpoint(path: str, method: str) -> None:
assert _forwarding_allowed(path, method) is False
def test_operator_additions_are_parsed_per_endpoint() -> None:
parsed = _parse_extra_allowed_endpoints("POST:v1/rerank, GET:batches ,post:audio/x")
assert parsed == {
"rerank": frozenset({"POST"}), # the "v1/" prefix collapses like any path
"batches": frozenset({"GET"}),
"audio/x": frozenset({"POST"}),
}
def test_operator_additions_may_grant_two_methods_on_one_endpoint() -> None:
assert _parse_extra_allowed_endpoints("POST:batches,GET:batches") == {
"batches": frozenset({"POST", "GET"})
}
@pytest.mark.parametrize(
"raw",
[
"",
" ",
"v1/rerank", # no method
"POST:", # no path
":v1/rerank", # empty method
"DELETE:v1/rerank", # method the proxy never routes
"POST:*", # wildcards are deliberately unsupported
"POST:v1/*",
"POST:../secret", # ambiguous spellings are screened here too
"POST:v1//rerank",
"POST:%2e%2e/secret",
],
)
def test_malformed_operator_additions_widen_nothing(raw: str) -> None:
assert _parse_extra_allowed_endpoints(raw) == {}
def test_operator_additions_are_env_only() -> None:
# The proxy parses this once at import, so a persisted or admin-API-writable
# value would be read but never take effect. Keeping it env-only also means
# widening the reachable upstream surface takes a deploy.
from routstr.core.settings import ENV_ONLY_FIELDS
assert "proxy_extra_allowed_paths" in ENV_ONLY_FIELDS
def test_ehbp_is_gated_by_the_same_allowlist() -> None:
# EHBP hides the request body from the proxy, which is a reason to constrain
# the destination more tightly rather than to trust the caller's path: the
# encrypted contract covers the body, never the endpoint the provider
# credential is spent against.
assert _forwarding_allowed("anything/encrypted", "POST") is False
assert _forwarding_allowed("v1/organization/api_keys", "POST") is False
assert _forwarding_allowed("v1/chat/completions", "POST") is True
@@ -103,13 +103,14 @@ async def test_attestation_trailing_slash_routes_directly_to_tinfoil(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("path", "expected_status"), "path",
[ [
# Valid `attestation` segment but not the exact attestation route: # A valid `attestation` segment is not the exact attestation route, and
# fails model validation (empty body -> unknown model) before auth. # `attestation` takes no id segment, so the endpoint allowlist rejects
("attestation/foo", 400), # it at the edge rather than letting it reach model/auth handling.
# Not a known path segment at all: rejected at the edge before routing. "attestation/foo",
("attestationjunk", 404), # Not a known endpoint at all: rejected at the edge before routing.
"attestationjunk",
], ],
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -117,7 +118,6 @@ async def test_non_attestation_prefix_does_not_bypass_authentication(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
proxy_app: FastAPI, proxy_app: FastAPI,
path: str, path: str,
expected_status: int,
) -> None: ) -> None:
tinfoil = MagicMock() tinfoil = MagicMock()
tinfoil.provider_type = "tinfoil" tinfoil.provider_type = "tinfoil"
@@ -130,9 +130,7 @@ async def test_non_attestation_prefix_does_not_bypass_authentication(
) as client: ) as client:
response = await client.get(f"/{path}") response = await client.get(f"/{path}")
assert response.status_code == expected_status assert response.status_code == 404
if expected_status == 400:
assert response.json()["error"]["type"] == "invalid_model"
tinfoil.forward_get_request.assert_not_awaited() tinfoil.forward_get_request.assert_not_awaited()