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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user