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
106 lines
4.0 KiB
Python
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))
|