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:
Logan
2026-06-21 13:40:08 -04:00
parent f0a0ea508a
commit 18d96193ab
11 changed files with 235 additions and 41 deletions
+12 -3
View File
@@ -4,7 +4,7 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
from app.models import IncidentCreate, IncidentUpdate
from app.internal import firestore as fstore
from app.internal.auth import require_admin_token
from app.internal.auth import require_admin_token, require_service_or_firebase_token, summarize_limiter
router = APIRouter(prefix="/incidents", tags=["incidents"])
@@ -20,7 +20,10 @@ async def list_incidents(status: Optional[str] = None, type: Optional[str] = Non
@router.post("/summarize")
async def summarize_all_stale(background_tasks: BackgroundTasks):
async def summarize_all_stale(
background_tasks: BackgroundTasks,
_: dict = Depends(require_admin_token),
):
"""Immediately run the summarizer pass on all stale incidents (don't wait for the next interval)."""
from app.internal.summarizer import _run_summary_pass
background_tasks.add_task(_run_summary_pass)
@@ -76,12 +79,18 @@ async def delete_incident(incident_id: str, _: dict = Depends(require_admin_toke
@router.post("/{incident_id}/summarize")
async def summarize_incident(incident_id: str, background_tasks: BackgroundTasks):
async def summarize_incident(
incident_id: str,
background_tasks: BackgroundTasks,
decoded: dict = Depends(require_service_or_firebase_token),
):
"""Immediately run the summarizer for a specific incident."""
from app.internal.summarizer import _summarize_incident
inc = await fstore.doc_get("incidents", incident_id)
if not inc:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
# Rate limit by incident ID to prevent repeated expensive LLM calls
summarize_limiter.check(incident_id)
background_tasks.add_task(_summarize_incident, inc)
return {"ok": True, "incident_id": incident_id}
+7 -2
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query
from app.models import CommandPayload
from app.internal import firestore as fstore
from app.internal.mqtt_handler import mqtt_handler
from app.internal.auth import require_admin_token
from app.internal.auth import require_admin_token, require_service_key_or_admin
from app.routers.tokens import assign_token, release_token
router = APIRouter(prefix="/nodes", tags=["nodes"])
@@ -55,7 +55,11 @@ async def reject_node(node_id: str, _: dict = Depends(require_admin_token)):
@router.post("/{node_id}/command")
async def send_command(node_id: str, cmd: CommandPayload):
async def send_command(
node_id: str,
cmd: CommandPayload,
_: dict = Depends(require_service_key_or_admin),
):
node = await fstore.doc_get("nodes", node_id)
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
@@ -108,6 +112,7 @@ async def assign_system(
system_id: str,
hardware_preset: str = Query("rtl-sdr-v3"),
ppm_override: Optional[float] = Query(None),
_: dict = Depends(require_service_key_or_admin),
):
"""
Assign a system to a node. Fetches the system config from Firestore
+40 -11
View File
@@ -1,9 +1,10 @@
import uuid
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Dict, Optional
from app.models import SystemCreate, SystemRecord
from app.internal import firestore as fstore
from app.internal.auth import require_admin_token, bootstrap_limiter
router = APIRouter(prefix="/systems", tags=["systems"])
@@ -35,7 +36,7 @@ async def get_system(system_id: str):
@router.post("", status_code=201)
async def create_system(body: SystemCreate):
async def create_system(body: SystemCreate, _: dict = Depends(require_admin_token)):
system_id = str(uuid.uuid4())
doc = SystemRecord(system_id=system_id, **body.model_dump())
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
@@ -43,7 +44,7 @@ async def create_system(body: SystemCreate):
@router.put("/{system_id}")
async def update_system(system_id: str, body: SystemCreate):
async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
@@ -52,7 +53,7 @@ async def update_system(system_id: str, body: SystemCreate):
@router.delete("/{system_id}", status_code=204)
async def delete_system(system_id: str):
async def delete_system(system_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
@@ -62,7 +63,11 @@ async def delete_system(system_id: str):
# ── Per-system AI flag overrides ──────────────────────────────────────────────
@router.put("/{system_id}/ai-flags")
async def update_system_ai_flags(system_id: str, body: AiFlagsBody):
async def update_system_ai_flags(
system_id: str,
body: AiFlagsBody,
_: dict = Depends(require_admin_token),
):
"""
Set per-system AI flag overrides. Only fields included in the body are
written; omitted fields remain unchanged (or absent, meaning inherit global).
@@ -95,7 +100,11 @@ async def get_ten_codes(system_id: str):
@router.put("/{system_id}/ten-codes")
async def update_ten_codes(system_id: str, body: TenCodesBody):
async def update_ten_codes(
system_id: str,
body: TenCodesBody,
_: dict = Depends(require_admin_token),
):
"""Replace the ten-code dictionary for a system."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
@@ -117,18 +126,26 @@ async def get_vocabulary(system_id: str):
@router.post("/{system_id}/vocabulary/bootstrap", status_code=202)
async def bootstrap_vocabulary(system_id: str):
async def bootstrap_vocabulary(
system_id: str,
decoded: dict = Depends(require_admin_token),
):
"""Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
bootstrap_limiter.check(system_id)
from app.internal.vocabulary_learner import bootstrap_system_vocabulary
terms = await bootstrap_system_vocabulary(system_id)
return {"added": len(terms), "terms": terms}
@router.post("/{system_id}/vocabulary/terms")
async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
async def add_vocabulary_term(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Manually add a term to the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
@@ -139,7 +156,11 @@ async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
@router.delete("/{system_id}/vocabulary/terms")
async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
async def remove_vocabulary_term(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Remove a term from the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
@@ -150,7 +171,11 @@ async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
@router.post("/{system_id}/vocabulary/pending/approve")
async def approve_pending(system_id: str, body: VocabularyTermBody):
async def approve_pending(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Move a pending induction suggestion into the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
@@ -161,7 +186,11 @@ async def approve_pending(system_id: str, body: VocabularyTermBody):
@router.post("/{system_id}/vocabulary/pending/dismiss")
async def dismiss_pending(system_id: str, body: VocabularyTermBody):
async def dismiss_pending(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Dismiss a pending induction suggestion without adding it."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
+11 -6
View File
@@ -1,9 +1,10 @@
import uuid
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from datetime import datetime, timezone
from app.internal import firestore as fstore
from app.internal.auth import require_admin_token
router = APIRouter(prefix="/tokens", tags=["tokens"])
@@ -22,13 +23,13 @@ async def list_tokens():
"""List all tokens. The actual token string is masked for safety."""
tokens = await fstore.collection_list("bot_tokens")
return [
{**t, "token": t["token"][:10] + "" + t["token"][-4:]}
{**t, "token": "•••" + t["token"][-4:]}
for t in tokens
]
@router.post("", status_code=201)
async def add_token(body: TokenCreate):
async def add_token(body: TokenCreate, _: dict = Depends(require_admin_token)):
token_id = str(uuid.uuid4())
doc = {
"token_id": token_id,
@@ -43,7 +44,7 @@ async def add_token(body: TokenCreate):
@router.post("/flush", status_code=200)
async def flush_tokens():
async def flush_tokens(_: dict = Depends(require_admin_token)):
"""Force-release all in-use tokens (admin utility — use when tokens get orphaned)."""
def _find():
from app.internal.firestore import db
@@ -61,7 +62,11 @@ async def flush_tokens():
@router.put("/{token_id}/prefer/{system_id}", status_code=200)
async def set_preferred_system(token_id: str, system_id: str):
async def set_preferred_system(
token_id: str,
system_id: str,
_: dict = Depends(require_admin_token),
):
"""
Mark this token as the preferred bot for a system.
When a discord_join is issued for any node in that system, this token
@@ -89,7 +94,7 @@ async def set_preferred_system(token_id: str, system_id: str):
@router.delete("/{token_id}", status_code=204)
async def delete_token(token_id: str):
async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("bot_tokens", token_id)
if not existing:
raise HTTPException(404, "Token not found.")
+64 -11
View File
@@ -3,12 +3,18 @@ import json
import httpx
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from app.models import TripCreate, TripEventCreate, AttendeeAction
from app.internal import firestore as fstore
from app.config import settings
from app.internal.logger import logger
from app.internal.auth import (
require_service_or_firebase_token,
require_service_key,
require_service_key_or_admin,
trip_chat_limiter,
)
router = APIRouter(prefix="/trips", tags=["trips"])
@@ -180,7 +186,7 @@ async def get_trip(trip_id: str):
@router.delete("/{trip_id}")
async def delete_trip(trip_id: str):
async def delete_trip(trip_id: str, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -192,7 +198,12 @@ async def delete_trip(trip_id: str):
@router.post("/{trip_id}/join")
async def join_trip(trip_id: str, body: AttendeeAction):
async def join_trip(
trip_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Join a trip as an attendee. Only the Discord bot (service key) may call this."""
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -203,7 +214,12 @@ async def join_trip(trip_id: str, body: AttendeeAction):
@router.post("/{trip_id}/leave")
async def leave_trip(trip_id: str, body: AttendeeAction):
async def leave_trip(
trip_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Leave a trip. Only the Discord bot (service key) may call this."""
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -253,7 +269,11 @@ async def create_event(trip_id: str, body: TripEventCreate):
@router.delete("/{trip_id}/events/{event_id}")
async def delete_event(trip_id: str, event_id: str):
async def delete_event(
trip_id: str,
event_id: str,
_: dict = Depends(require_service_key_or_admin),
):
event = await fstore.doc_get("trip_events", event_id)
if not event or event.get("trip_id") != trip_id:
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
@@ -262,7 +282,13 @@ async def delete_event(trip_id: str, event_id: str):
@router.post("/{trip_id}/events/{event_id}/join")
async def join_event(trip_id: str, event_id: str, body: AttendeeAction):
async def join_event(
trip_id: str,
event_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Join an event. Only the Discord bot (service key) may call this."""
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -278,7 +304,13 @@ async def join_event(trip_id: str, event_id: str, body: AttendeeAction):
@router.post("/{trip_id}/events/{event_id}/leave")
async def leave_event(trip_id: str, event_id: str, body: AttendeeAction):
async def leave_event(
trip_id: str,
event_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Leave an event. Only the Discord bot (service key) may call this."""
event = await fstore.doc_get("trip_events", event_id)
if not event or event.get("trip_id") != trip_id:
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
@@ -293,10 +325,18 @@ async def leave_event(trip_id: str, event_id: str, body: AttendeeAction):
# ---------------------------------------------------------------------------
@router.post("/{trip_id}/chat")
async def trip_chat(trip_id: str, body: ChatRequest):
async def trip_chat(
trip_id: str,
body: ChatRequest,
decoded: dict = Depends(require_service_or_firebase_token),
):
if not settings.openai_api_key:
raise HTTPException(503, "OpenAI not configured.")
# Rate limit by caller identity
caller_key = decoded.get("uid") or ("service" if decoded.get("service") else "unknown")
trip_chat_limiter.check(f"{caller_key}:{trip_id}")
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -306,10 +346,20 @@ async def trip_chat(trip_id: str, body: ChatRequest):
from openai import AsyncOpenAI
oai = AsyncOpenAI(api_key=settings.openai_api_key)
# Strip history to only user/assistant roles to prevent prompt injection
safe_history = [
{"role": m.role, "content": m.content}
for m in body.history[-20:]
if m.role in ("user", "assistant")
]
# Truncate message to prevent oversized single requests
user_message = body.message[:2000]
messages: list[dict] = [
{"role": "system", "content": _build_system_prompt(trip, events)},
*[{"role": m.role, "content": m.content} for m in body.history[-20:]],
{"role": "user", "content": body.message},
*safe_history,
{"role": "user", "content": user_message},
]
suggestions: list[dict] = []
@@ -340,7 +390,10 @@ async def trip_chat(trip_id: str, body: ChatRequest):
args = json.loads(tc.function.arguments)
if tc.function.name == "search_places":
results = await _places_search(args.get("query", ""), args.get("near", ""))
# Limit query string lengths before hitting the Maps API
query = str(args.get("query", ""))[:200]
near = str(args.get("near", ""))[:200]
results = await _places_search(query, near)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
+4 -2
View File
@@ -4,6 +4,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.internal.storage import upload_audio
from app.internal import firestore as fstore
from app.internal.logger import logger
from app.config import settings
router = APIRouter(tags=["upload"])
@@ -43,9 +44,10 @@ async def upload_call_audio(
data = await file.read()
if not data:
raise HTTPException(400, "Empty file.")
if len(data) > settings.upload_max_bytes:
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
filename = file.filename
audio_url = await upload_audio(data, filename)
audio_url = await upload_audio(data, file.filename or "", call_id=call_id)
if audio_url:
try: