diff --git a/drb-c2-core/Dockerfile b/drb-c2-core/Dockerfile index 1575b31..664ad7c 100644 --- a/drb-c2-core/Dockerfile +++ b/drb-c2-core/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.14-slim +FROM python:3.12-slim WORKDIR /app diff --git a/drb-c2-core/app/config.py b/drb-c2-core/app/config.py index 2720be1..5284058 100644 --- a/drb-c2-core/app/config.py +++ b/drb-c2-core/app/config.py @@ -51,7 +51,11 @@ class Settings(BaseSettings): # Internal service key — allows server-side services (discord bot) to call C2 without Firebase service_key: Optional[str] = None - # CORS — comma-separated list of allowed origins, or "*" for all + # Upload size limit — reject audio files larger than this (bytes). Default 100 MB. + upload_max_bytes: int = 100 * 1024 * 1024 + + # CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"] + # Defaults to "*" for local development only. cors_origins: list[str] = ["*"] class Config: diff --git a/drb-c2-core/app/internal/auth.py b/drb-c2-core/app/internal/auth.py index cfc53b4..10d0cdb 100644 --- a/drb-c2-core/app/internal/auth.py +++ b/drb-c2-core/app/internal/auth.py @@ -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) diff --git a/drb-c2-core/app/internal/storage.py b/drb-c2-core/app/internal/storage.py index bafd5b2..384b1ee 100644 --- a/drb-c2-core/app/internal/storage.py +++ b/drb-c2-core/app/internal/storage.py @@ -5,7 +5,21 @@ from app.config import settings from app.internal.logger import logger -async def upload_audio(data: bytes, filename: str) -> Optional[str]: +def _safe_audio_filename(filename: str, call_id: str) -> str: + """Return a safe GCS object name derived from the call_id. + + We ignore the client-supplied filename entirely and derive the name from the + call_id (which we control) to prevent path traversal via crafted filenames. + The original extension is preserved only if it's a known audio type. + """ + import os + ext = os.path.splitext(filename)[-1].lower() if filename else "" + if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"): + ext = ".mp3" + return f"{call_id}{ext}" + + +async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]: """Upload audio bytes to GCS and return a signed URL, or None if disabled.""" if not settings.gcs_bucket: logger.info("GCS_BUCKET not configured — skipping audio upload.") @@ -21,7 +35,8 @@ async def upload_audio(data: bytes, filename: str) -> Optional[str]: client = storage.Client() signing_creds = None bucket = client.bucket(settings.gcs_bucket) - blob = bucket.blob(f"calls/{filename}") + safe_name = _safe_audio_filename(filename, call_id) + blob = bucket.blob(f"calls/{safe_name}") blob.upload_from_string(data, content_type="audio/mpeg") if signing_creds: return blob.generate_signed_url( diff --git a/drb-c2-core/app/routers/incidents.py b/drb-c2-core/app/routers/incidents.py index 726d463..77959b8 100644 --- a/drb-c2-core/app/routers/incidents.py +++ b/drb-c2-core/app/routers/incidents.py @@ -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} diff --git a/drb-c2-core/app/routers/nodes.py b/drb-c2-core/app/routers/nodes.py index 9d443aa..a4936a7 100644 --- a/drb-c2-core/app/routers/nodes.py +++ b/drb-c2-core/app/routers/nodes.py @@ -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 diff --git a/drb-c2-core/app/routers/systems.py b/drb-c2-core/app/routers/systems.py index 4231316..eaccd08 100644 --- a/drb-c2-core/app/routers/systems.py +++ b/drb-c2-core/app/routers/systems.py @@ -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: diff --git a/drb-c2-core/app/routers/tokens.py b/drb-c2-core/app/routers/tokens.py index 1fea1b9..99a62bf 100644 --- a/drb-c2-core/app/routers/tokens.py +++ b/drb-c2-core/app/routers/tokens.py @@ -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.") diff --git a/drb-c2-core/app/routers/trips.py b/drb-c2-core/app/routers/trips.py index 584c8f5..0b3e821 100644 --- a/drb-c2-core/app/routers/trips.py +++ b/drb-c2-core/app/routers/trips.py @@ -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, diff --git a/drb-c2-core/app/routers/upload.py b/drb-c2-core/app/routers/upload.py index e874e75..ad44f51 100644 --- a/drb-c2-core/app/routers/upload.py +++ b/drb-c2-core/app/routers/upload.py @@ -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: diff --git a/drb-server-discord-bot/Dockerfile b/drb-server-discord-bot/Dockerfile index 413629d..c5095dc 100644 --- a/drb-server-discord-bot/Dockerfile +++ b/drb-server-discord-bot/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.14-slim +FROM python:3.12-slim WORKDIR /app