Files
interactive-story/backend/app/routers/settings.py
T
parththakkar106andClaude Opus 4.8 c500203270 Harden auth against a forwarded-header rate-limit bypass, and guard BYOK SSRF
The per-IP rate limits could be bypassed entirely: uvicorn ran with
--forwarded-allow-ips "*", which trusts the leftmost X-Forwarded-For value
(client-controlled), and Render forwards the inbound header rather than
stripping it. Rotating the header handed out a fresh rate-limit bucket per
request, so the login/register limit (10/5min) and guest-minting limit
(30/5min) were no throttle at all — unbounded password guessing and guest-row
creation. Confirmed live: fixed IP -> 429 after 10; rotating spoofed header ->
no 429 across 14 attempts.

Two-layer fix:
- limits._client_ip now derives the client IP from the hop the trusted edge
  appends (rightmost of X-Forwarded-For), which a client can't spoof past;
  tunable via AIDND_TRUSTED_PROXY_HOPS. Dropped --forwarded-allow-ips "*".
- New per-account login throttle (email-keyed, 8 fails / 15 min, cleared on
  success): stops distributed guessing against one account that a per-IP limit
  can't, since it can't be diluted across many source addresses.

Also close an SSRF on the BYOK endpoint_url (hosted mode only): the connection
test and turn/chat streams now refuse a URL that resolves to a non-public
address (private/loopback/link-local metadata/reserved), checked at request
time so it resists a DNS record flipping to a private IP. No-op locally, where
reaching localhost Ollama is intended.

Tests: test_ratelimit_hardening.py (8), test_netguard.py (13). 172 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CYEJKobJ2Re4Dv7qUoSA7
2026-08-15 13:47:14 +05:30

106 lines
4.0 KiB
Python

import httpx
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
from .. import auth, limits, models, netguard, schemas, security
from ..database import get_db
router = APIRouter(prefix="/api/settings", tags=["settings"])
def get_settings(db: Session, user: models.User) -> models.Settings:
"""Per-user settings row, created on first access (Phase 8: settings —
endpoint, key, models, memory config — are per user, not global)."""
settings = (
db.query(models.Settings).filter(models.Settings.user_id == user.id).first()
)
if settings is None:
settings = models.Settings(user_id=user.id)
db.add(settings)
db.commit()
return settings
@router.get("", response_model=schemas.SettingsOut)
def read_settings(
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
return get_settings(db, user)
@router.put("", response_model=schemas.SettingsOut)
def update_settings(
payload: schemas.SettingsUpdate,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
settings = get_settings(db, user)
fields = payload.model_dump(exclude_unset=True)
# Write-only API key: absent = unchanged, "" = cleared, else encrypted.
if "api_key" in fields:
fields["api_key"] = security.encrypt_secret(fields["api_key"].strip())
embedding_model_changed = (
"embedding_model" in fields
and fields["embedding_model"] != settings.embedding_model
)
for field, value in fields.items():
setattr(settings, field, value)
if embedding_model_changed:
# Vectors from the old model have a different dimensionality/space;
# clear them so the post-turn task re-embeds with the new model.
# (This user's adventures only — settings are per-user now.)
owned = (
db.query(models.Adventure.id)
.filter(models.Adventure.user_id == user.id)
.scalar_subquery()
)
db.query(models.Memory).filter(models.Memory.adventure_id.in_(owned)).update(
{"embedding": None}, synchronize_session=False
)
db.commit()
return settings
async def list_endpoint_models(cfg: auth.ProviderConfig) -> dict:
"""GET the endpoint's /models listing. Doubles as a connectivity check, so
failures come back as {"ok": False, "detail": ...} rather than raising."""
# SSRF guard: never probe a non-public address the user pointed us at.
reason = await run_in_threadpool(netguard.endpoint_block_reason, cfg.endpoint_url)
if reason:
return {"ok": False, "detail": f"Can't reach that endpoint — {reason}."}
url = cfg.endpoint_url.rstrip("/") + "/models"
headers = {}
if cfg.api_key:
headers["Authorization"] = f"Bearer {cfg.api_key}"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url, headers=headers)
except httpx.HTTPError as exc:
return {"ok": False, "detail": f"Connection failed: {exc}"}
if resp.status_code != 200:
return {"ok": False, "detail": f"HTTP {resp.status_code}: {resp.text[:300]}"}
models_available: list[str] = []
try:
data = resp.json()
models_available = [m.get("id", "?") for m in data.get("data", [])]
except (ValueError, AttributeError, TypeError):
pass # non-JSON or unexpected shape — connectivity is still confirmed
return {"ok": True, "models": models_available}
@router.post("/test")
async def test_connection(
request: Request,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""Cheap connectivity check against whatever the turn engine would actually
use — including the shared demo endpoint when the user has no key."""
limits.rate_limit("connection-test", request, user)
settings = get_settings(db, user)
return await list_endpoint_models(auth.resolve_provider_config(settings))