Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,10 @@ ANTSEED_MAX_OUTPUT=1000
# to deposit/pay gas with). Defaults to a public Base endpoint; set ""/off to
# disable the on-chain read (dashboard then shows escrow only).
ANTSEED_WALLET_RPC_URL=

# Optional external control plane ("bring your own control plane"): a separate
# service owning consumer keys / tenants (e.g. unhardcoded-cloud). Both must be
# set to enable; otherwise the router runs operator-only. The secret gates the
# ingress /internal/usage surface AND authenticates outbound resolve calls.
CONTROL_PLANE_URL=
CONTROL_PLANE_INTERNAL_SECRET=
78 changes: 67 additions & 11 deletions auth_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse

import control_plane_client
import host_store
import internal_api
from env_secrets import load_env_secrets
from shim import _CACHE_READ_FACTOR # the billing cache-read discount — one source

Expand Down Expand Up @@ -63,6 +65,10 @@
SYNTHETIC_PROBE_INITIAL_DELAY_S = float(os.getenv("DASHBOARD_SYNTHETIC_PROBE_INITIAL_DELAY_S", "45"))
SYNTHETIC_PROBE_TIMEOUT_S = float(os.getenv("DASHBOARD_SYNTHETIC_PROBE_TIMEOUT_S", "45"))
SYNTHETIC_PROBE_CALLER = os.getenv("DASHBOARD_SYNTHETIC_PROBE_CALLER", "dashboard-probe")
# Optional namespace for control-plane-resolved callers (e.g. "cp:") so tenant
# slugs can't merge attribution with operator-minted consumer names. Default off:
# calls.caller == tenant slug, which is what the control plane's usage reads key on.
CONTROL_PLANE_CALLER_PREFIX = os.getenv("CONTROL_PLANE_CALLER_PREFIX", "")



Expand All @@ -84,6 +90,10 @@ def _load_caller_map(raw: str, name: str) -> Dict[str, str]:
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"), format="%(message)s")
log = logging.getLogger("llm-router-auth-proxy")
app = FastAPI(title="llm-router auth proxy", docs_url=None, redoc_url=None)
# Control-plane metering surface (/internal/usage[/recent]). Registered before
# any route definition below so Starlette matches it ahead of the catch-all
# proxy; hidden (404) unless CONTROL_PLANE_INTERNAL_SECRET is configured.
app.include_router(internal_api.router)
_client: httpx.AsyncClient | None = None
_probe_task: asyncio.Task[None] | None = None
_windows: dict[str, deque[float]] = defaultdict(deque)
Expand Down Expand Up @@ -477,8 +487,44 @@ def _caller_auth(token: str | None) -> dict[str, Any]:
return {"ok": True, "caller": caller, "digest": digest, "storage": storage, "meta": meta}


def _rate_ok(caller: str) -> bool:
async def _caller_auth_async(token: str | None) -> dict[str, Any]:
"""_caller_auth plus the external-control-plane fallback.

Local key stores stay authoritative: a locally known key — including a
revoked/inactive one — never falls through to the control plane; only a
pure local miss consults it (and only when the feature is configured, see
control_plane_client.enabled()). On a resolve hit the caller becomes the
control-plane consumer name (tenant slug) and the plan's rate limits ride
the returned meta; an explicit local record for that same name keeps
precedence (operator kill-switch: mark the slug inactive locally)."""
auth = _caller_auth(token)
if auth.get("ok") or auth.get("caller") or not token or not control_plane_client.enabled():
return auth
digest = hashlib.sha256(token.encode()).hexdigest()
resolved = await control_plane_client.resolve_key(digest)
if resolved is None or not resolved.active or not resolved.consumer:
return auth
caller = f"{CONTROL_PLANE_CALLER_PREFIX}{resolved.consumer}"
meta = _consumer_meta(caller)
if meta.get("keys"):
# A local consumer already answers to this name — attribution merges.
control_plane_client.log_collision_once(caller)
if meta.get("status") != "active":
return {"ok": False, "caller": caller, "digest": digest,
"storage": "control_plane", "error_code": "caller_inactive"}
if meta.get("rate_per_min") is None:
meta["rate_per_min"] = resolved.rate_per_min
if meta.get("burst") is None:
meta["burst"] = resolved.burst
out = {"ok": True, "caller": caller, "digest": digest,
"storage": "control_plane", "meta": meta}
if resolved.tenant_id is not None:
out["tenant_id"] = resolved.tenant_id
return out


def _rate_ok(caller: str, meta: dict[str, Any] | None = None) -> bool:
meta = meta if meta is not None else _consumer_meta(caller)
rate_per_min = int(meta.get("rate_per_min") or RATE_PER_MIN)
burst = int(meta.get("burst") or BURST)
now = time.monotonic()
Expand Down Expand Up @@ -517,8 +563,10 @@ def _route_matches(pattern: str, route: str) -> bool:
return False


def _route_allowed(caller: str, route: str | None) -> bool:
allowed_routes = _consumer_meta(caller).get("allowed_routes") or []
def _route_allowed(caller: str, route: str | None,
meta: dict[str, Any] | None = None) -> bool:
meta = meta if meta is not None else _consumer_meta(caller)
allowed_routes = meta.get("allowed_routes") or []
if not allowed_routes:
return True
if not route:
Expand Down Expand Up @@ -973,6 +1021,7 @@ async def shutdown() -> None:
_probe_task = None
if _client:
await _client.aclose()
await control_plane_client.close()


@app.get("/healthz")
Expand Down Expand Up @@ -1730,7 +1779,7 @@ async def consumer_skill(request: Request) -> Response:
and the same key auth (revoked/expired/inactive keys are rejected)."""
auth = request.headers.get("authorization") or ""
token = auth[7:].strip() if auth.lower().startswith("bearer ") else auth.strip()
if not _caller_auth(token).get("ok"):
if not (await _caller_auth_async(token)).get("ok"):
return JSONResponse(status_code=401, content={"error": {
"message": "invalid or inactive API key",
"type": "auth_error", "code": "consumer_auth"}})
Expand All @@ -1749,7 +1798,7 @@ async def consumer_skill(request: Request) -> Response:
# or mutate state: they admit/normalize a term, preview the ranking, or list the
# field vocabulary — all derived from the live catalog the skill already shows.
async def _consumer_x_proxy(request: Request, method: str, path: str) -> Response:
if not _caller_auth(_extract_token(request)).get("ok"):
if not (await _caller_auth_async(_extract_token(request))).get("ok"):
return JSONResponse(status_code=401, content={"error": {
"message": "invalid or inactive API key",
"type": "auth_error", "code": "consumer_auth"}})
Expand Down Expand Up @@ -2376,7 +2425,7 @@ async def dashboard_key_usage(request: Request) -> Response:
@app.get("/api/usage")
async def key_usage(request: Request) -> Response:
token = _extract_token(request)
auth = _caller_auth(token)
auth = await _caller_auth_async(token)
if not auth.get("ok"):
code = auth.get("error_code") or "caller_auth"
status_code = 403 if code in {"caller_inactive", "caller_key_revoked", "caller_key_expired"} else 401
Expand Down Expand Up @@ -2404,7 +2453,7 @@ async def session_view(sid: str, request: Request) -> Response:
itself sends as X-Unhardcoded-Session. Lets a harness (e.g. the opencode
plugin) show live router economics without operator access to /x/*."""
token = _extract_token(request)
auth = _caller_auth(token)
auth = await _caller_auth_async(token)
if not auth.get("ok"):
code = auth.get("error_code") or "caller_auth"
status_code = 403 if code in {"caller_inactive", "caller_key_revoked", "caller_key_expired"} else 401
Expand Down Expand Up @@ -3207,7 +3256,7 @@ async def proxy(path: str, request: Request) -> Response:
return JSONResponse(status_code=404, content={"error": {"message": "not found", "type": "invalid_request_error", "code": None}})
started = time.perf_counter()
token = _extract_token(request)
auth = _caller_auth(token)
auth = await _caller_auth_async(token)
caller = auth.get("caller")
if not auth.get("ok"):
code = auth.get("error_code") or "caller_auth"
Expand All @@ -3223,13 +3272,14 @@ async def proxy(path: str, request: Request) -> Response:
return JSONResponse(status_code=status_code, content={"error": {"message": messages.get(code, "caller not authorized"), "type": "auth_error", "code": code}})

caller = str(caller)
auth_meta = auth.get("meta")
body = await request.body()
requested_route = _requested_route_from(path, body)
if not _route_allowed(caller, requested_route):
if not _route_allowed(caller, requested_route, meta=auth_meta):
_record_reject(reason="route_not_allowed", path="/" + path, caller=caller, status=403, route=requested_route)
_log({"event": "reject", "reason": "route_not_allowed", "caller": caller, "path": "/" + path, "route": requested_route})
return JSONResponse(status_code=403, content={"error": {"message": "caller is not allowed to use this route", "type": "auth_error", "code": "caller_route_not_allowed"}})
if not _rate_ok(caller):
if not _rate_ok(caller, meta=auth_meta):
_record_reject(reason="rate_limit", path="/" + path, caller=caller, status=429)
_log({"event": "reject", "reason": "rate_limit", "caller": caller, "path": "/" + path})
return JSONResponse(status_code=429, content={"error": {"message": "caller rate limit exceeded", "type": "rate_limit_error", "code": "caller_rate_limit"}})
Expand All @@ -3255,9 +3305,15 @@ async def proxy(path: str, request: Request) -> Response:

headers = {
k: v for k, v in request.headers.items()
if k.lower() not in {"authorization", "host", "connection", "content-length"}
if k.lower() not in {"authorization", "host", "connection", "content-length",
"x-llm-router-tenant", "x-internal-secret"}
}
headers["x-llm-router-caller"] = caller
# Tenant identity for per-tenant provider credentials (BYO keys): set ONLY
# from the authenticated resolve — the client-sent header is stripped above,
# so it can never be smuggled past auth.
if auth.get("tenant_id") is not None:
headers["x-llm-router-tenant"] = str(auth["tenant_id"])
Comment on lines 3306 to +3316

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm upstream trusts x-llm-router-caller and whether it's already stripped elsewhere.
rg -nP --type=py -C3 'x-llm-router-caller'

Repository: genlayerlabs/unhardcoded

Length of output: 162


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== file locations ==\n'
git ls-files | rg 'auth_proxy\.py$|README|pyproject|requirements|setup\.py|package\.json|\.py$' | head -n 50

printf '\n== header usages ==\n'
rg -n --hidden --glob '!**/.git/**' 'x-llm-router-(caller|tenant)|x-internal-secret' .

printf '\n== auth_proxy.py around target lines ==\n'
python3 - <<'PY'
from pathlib import Path
p = Path('auth_proxy.py')
lines = p.read_text().splitlines()
for start in (3296, 3306, 3316, 3326):
    end = min(start+20, len(lines))
    print(f'\n-- {start}-{end} --')
    for i in range(start-1, end):
        print(f'{i+1}: {lines[i]}')
PY

Repository: genlayerlabs/unhardcoded

Length of output: 11616


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== shim.py caller/tenant sections ==\n'
python3 - <<'PY'
from pathlib import Path
p = Path('shim.py')
lines = p.read_text().splitlines()
for start in (80, 270, 836, 882, 890):
    end = min(start+30, len(lines))
    print(f'\n-- {start}-{end} --')
    for i in range(start-1, end):
        print(f'{i+1}: {lines[i]}')
PY

printf '\n== auth_proxy control-plane tests around header forwarding ==\n'
python3 - <<'PY'
from pathlib import Path
p = Path('tests/test_auth_proxy_control_plane.py')
lines = p.read_text().splitlines()
for start in (110, 120, 126):
    end = min(start+25, len(lines))
    print(f'\n-- {start}-{end} --')
    for i in range(start-1, end):
        print(f'{i+1}: {lines[i]}')
PY

Repository: genlayerlabs/unhardcoded

Length of output: 13243


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
def try_probe():
    probes = []
    try:
        import httpx
        h = httpx.Headers({"X-Llm-Router-Caller": "client", "x-llm-router-caller": "server"})
        probes.append(("httpx", {
            "type": type(h).__name__,
            "items": list(h.items()),
            "raw": getattr(h, "raw", None),
            "get": h.get("x-llm-router-caller"),
        }))
    except Exception as e:
        probes.append(("httpx_error", repr(e)))

    try:
        from starlette.datastructures import Headers
        h = Headers(raw=[
            (b"X-Llm-Router-Caller", b"client"),
            (b"x-llm-router-caller", b"server"),
        ])
        probes.append(("starlette", {
            "type": type(h).__name__,
            "items": list(h.items()),
            "raw": getattr(h, "raw", None),
            "get": h.get("x-llm-router-caller"),
        }))
    except Exception as e:
        probes.append(("starlette_error", repr(e)))

    for name, data in probes:
        print(name, data)

try_probe()
PY

Repository: genlayerlabs/unhardcoded

Length of output: 561


Strip x-llm-router-caller from inbound headers too

x-llm-router-caller is consumed downstream for session ownership and request attribution, so a client-supplied mixed-case variant can survive the filter and get forwarded alongside the injected value. Exclude it before re-adding the trusted caller header.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@auth_proxy.py` around lines 3306 - 3316, The header sanitization in
auth_proxy.py is missing x-llm-router-caller, so a client can smuggle a
mixed-case version through and have it forwarded alongside the trusted value.
Update the inbound header filter in the request header-building block to strip
x-llm-router-caller before setting headers["x-llm-router-caller"] from the
authenticated caller value, keeping the trust boundary in the same place as the
existing x-llm-router-tenant handling.


status = 502
provider = None
Expand Down
10 changes: 10 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ services:
ANTSEED_CONTROL_TOKEN: ${ANTSEED_CONTROL_TOKEN:-}
# Operational store (router reads operator config from it). Prod = RDS.
DATABASE_URL: ${DATABASE_URL:-postgresql://hoststore:hoststore@postgres:5432/hoststore}
# Optional external control plane: per-tenant BYO provider credentials
# (fetched by tenant id from the trusted x-llm-router-tenant header the
# ingress stamps). Both unset -> feature off, platform keys only.
CONTROL_PLANE_URL: ${CONTROL_PLANE_URL:-}
CONTROL_PLANE_INTERNAL_SECRET: ${CONTROL_PLANE_INTERNAL_SECRET:-}
depends_on:
postgres:
condition: service_healthy
Expand Down Expand Up @@ -84,6 +89,11 @@ services:
DATABASE_URL: ${DATABASE_URL:-postgresql://hoststore:hoststore@postgres:5432/hoststore}
RATE_PER_MIN: ${RATE_PER_MIN:-600}
BURST: ${BURST:-200}
# Optional external control plane: consumer-key resolve fallback plus the
# /internal/usage metering surface. Both unset -> feature off (/internal/*
# answers 404, unknown keys just 401).
CONTROL_PLANE_URL: ${CONTROL_PLANE_URL:-}
CONTROL_PLANE_INTERNAL_SECRET: ${CONTROL_PLANE_INTERNAL_SECRET:-}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
DASHBOARD_KEY_ENV_PATH: /run/llm-router/.env.secrets
# env_file carries the HOST path; inside the container the file is the
Expand Down
Loading