Security fixes
auth.py
secrets.compare_digest replaces == for service key comparison (timing-safe)
Added require_service_key — bot-only endpoints (trip/event join/leave)
Added require_service_key_or_admin — node commands/config (bot via service key OR dashboard admin via Firebase)
Added _RateLimiter with three shared instances: trip_chat_limiter (20/5min per user), summarize_limiter (5/10min per incident), bootstrap_limiter (2/hr per system)
nodes.py
send_command and assign_system now require require_service_key_or_admin — the Discord bot can still call them via service key, but regular Firebase users are blocked
tokens.py
add_token, flush_tokens, set_preferred_system, delete_token all require require_admin_token
Token masking changed from token[:10] + "…" + token[-4:] to "•••" + token[-4:]
systems.py
All write endpoints (create, update, delete, ai-flags, ten-codes, vocabulary writes, bootstrap) now require require_admin_token
bootstrap_vocabulary also calls bootstrap_limiter.check(system_id)
incidents.py
POST /incidents/summarize (bulk) now requires require_admin_token
POST /incidents/{id}/summarize now calls summarize_limiter.check(incident_id)
trips.py
join_trip, leave_trip, join_event, leave_event require require_service_key — only the Discord bot can set Discord attendee identity
delete_trip, delete_event require require_service_key_or_admin
trip_chat rate-limited per caller UID, history stripped to user/assistant roles only, user message truncated to 2000 chars, Maps query strings capped at 200 chars
upload.py
Rejects files larger than settings.upload_max_bytes (default 100MB) with 413
storage.py
_safe_audio_filename() derives GCS object name from call_id + allowlisted extension, completely ignoring the client-supplied filename
config.py
Added upload_max_bytes: int = 100 * 1024 * 1024
Both Dockerfiles — python:3.14-slim → python:3.12-slim
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from typing import Optional
|
||||
from fastapi import HTTPException, Security
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
@@ -26,7 +29,7 @@ async def require_service_or_firebase_token(
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
token = credentials.credentials
|
||||
if settings.service_key and token == settings.service_key:
|
||||
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
||||
return {"service": True}
|
||||
try:
|
||||
return firebase_auth.verify_id_token(token)
|
||||
@@ -42,3 +45,72 @@ async def require_admin_token(
|
||||
if not decoded.get("admin"):
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return decoded
|
||||
|
||||
|
||||
async def require_service_key(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||
) -> dict:
|
||||
"""Accept only the internal service key — used for bot-only endpoints."""
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
if not settings.service_key:
|
||||
raise HTTPException(status_code=503, detail="Service key not configured")
|
||||
if not secrets.compare_digest(credentials.credentials, settings.service_key):
|
||||
raise HTTPException(status_code=403, detail="Service key required")
|
||||
return {"service": True}
|
||||
|
||||
|
||||
async def require_service_key_or_admin(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||
) -> dict:
|
||||
"""Accept either the internal service key or a Firebase admin token.
|
||||
|
||||
Used for endpoints that the Discord bot (service key) and dashboard admins
|
||||
(Firebase + admin claim) both need to call, but regular Firebase users must not.
|
||||
"""
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
token = credentials.credentials
|
||||
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
||||
return {"service": True}
|
||||
try:
|
||||
decoded = firebase_auth.verify_id_token(token)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
if not decoded.get("admin"):
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return decoded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple in-memory sliding-window rate limiter
|
||||
# ---------------------------------------------------------------------------
|
||||
# Not persistent across restarts; good enough for a single-instance deployment.
|
||||
# Key format is caller-defined (e.g. "{uid}:{endpoint}").
|
||||
|
||||
class _RateLimiter:
|
||||
def __init__(self, max_calls: int, window_seconds: int):
|
||||
self.max_calls = max_calls
|
||||
self.window = window_seconds
|
||||
self._log: dict[str, deque] = defaultdict(deque)
|
||||
|
||||
def check(self, key: str) -> None:
|
||||
now = time.monotonic()
|
||||
q = self._log[key]
|
||||
while q and now - q[0] > self.window:
|
||||
q.popleft()
|
||||
if len(q) >= self.max_calls:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Rate limit exceeded. Please wait before trying again.",
|
||||
)
|
||||
q.append(now)
|
||||
|
||||
|
||||
# Shared limiter instances
|
||||
# trip chat: 20 requests per user per 5 minutes
|
||||
trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
|
||||
# per-incident summarize: 5 per incident per 10 minutes
|
||||
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
||||
# vocabulary bootstrap: 2 per system per hour
|
||||
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
|
||||
|
||||
Reference in New Issue
Block a user