From 4591c13d9a0ff0498cd3e7771442e131c1037baf Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Mon, 31 Aug 2026 20:27:03 -0500 Subject: [PATCH] fix: gate traceback disclosure behind DEBUG_TRACEBACKS flag, run container as non-root Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012RttwcxBgsnXH2TwQGQsrp --- Dockerfile | 29 ++++++++++++++++++++++++++++- api/routes/rag.py | 21 ++++++++++++++------- tests/test_rag_routes.py | 21 ++++++++++++++++++--- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index c28bbde..08bc620 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,6 @@ COPY processing/ ./processing/ COPY retrieval/ ./retrieval/ COPY rag/ ./rag/ COPY scripts/ ./scripts/ -COPY utils/ ./utils/ COPY configs/ ./configs/ # Copy built UI from builder stage @@ -48,11 +47,39 @@ COPY --from=ui-builder /ui/dist /usr/share/nginx/html RUN rm -f /etc/nginx/sites-enabled/default \ /etc/nginx/sites-available/default +# ── Non-root runtime user ────────────────────────────────────────────────── +# Runtime writes this container actually performs, and what each needs: +# /app/data/faiss_index/ -- VectorStore.save() (index.faiss, metadata.pkl), +# written by scripts/build_index.py on first boot +# /app/logs/, /app/cache/ -- reserved for app use (currently unwritten, but +# pre-created + owned so a future write doesn't +# silently hit a permission error) +# /var/log/nginx/ -- access.log / error.log (nginx opens these at +# startup, not lazily -- must be writable then) +# /var/lib/nginx/ -- client_body/proxy/fastcgi/... temp dirs, created +# by nginx workers on first request +# /etc/nginx/conf.d/ -- devhub.conf is generated here by the CMD script +# at container start (embeds the runtime JWT_SECRET) +# The stock nginx.conf also sets `user www-data;` and `pid /run/nginx.pid;`, +# both of which assume a root master process (setuid to www-data, write to +# /run which is root:root here) -- since the master now runs as appuser, +# the `user` directive is dropped (nginx just runs workers as whoever +# started it) and the pid file is redirected to /tmp (world-writable). +RUN groupadd --gid 10001 appuser \ + && useradd --uid 10001 --gid appuser --no-create-home --shell /usr/sbin/nologin appuser \ + && mkdir -p /app/data/faiss_index /app/logs /app/cache \ + && chown -R appuser:appuser /app \ + && chown -R appuser:appuser /var/log/nginx /var/lib/nginx /etc/nginx/conf.d \ + && sed -i '/^user www-data;$/d' /etc/nginx/nginx.conf \ + && sed -i 's#pid /run/nginx.pid;#pid /tmp/nginx.pid;#' /etc/nginx/nginx.conf + ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PYTHONPATH=/app \ REPO_BASE=/repos +USER appuser + EXPOSE 8082 5173 # Startup sequence: diff --git a/api/routes/rag.py b/api/routes/rag.py index 72d542b..46a9e0e 100644 --- a/api/routes/rag.py +++ b/api/routes/rag.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from typing import Optional import json +import os import traceback from rag.control_plane import CONTROL_PLANE @@ -11,6 +12,12 @@ router = APIRouter() +def _debug_tracebacks_enabled() -> bool: + # Same shared-secret-style convention as api/auth.py's _auth_enabled(): + # a no-op (tracebacks hidden) unless explicitly turned on. + return os.getenv("DEBUG_TRACEBACKS", "").strip().lower() == "true" + + # ========================================================= # REQUEST MODEL # ========================================================= @@ -61,13 +68,13 @@ def query(req: QueryRequest, actor: str = Depends(require_auth)): } except Exception as e: - raise HTTPException( - status_code=500, - detail={ - "error": str(e), - "trace": traceback.format_exc() - } - ) + detail = {"error": str(e)} + # Stack traces can leak file paths, internals, and other sensitive + # detail into the HTTP response -- only include one when a deployer + # has explicitly opted in via DEBUG_TRACEBACKS=true. + if _debug_tracebacks_enabled(): + detail["trace"] = traceback.format_exc() + raise HTTPException(status_code=500, detail=detail) # ========================================================= diff --git a/tests/test_rag_routes.py b/tests/test_rag_routes.py index be199ef..2d32490 100644 --- a/tests/test_rag_routes.py +++ b/tests/test_rag_routes.py @@ -66,19 +66,34 @@ def test_query_endpoint_success(client, mock_control_plane): assert data["answer"] == "test answer" assert data["api_version"] == "v6" -def test_query_endpoint_failure(client, mock_control_plane): +def test_query_endpoint_failure_traceback_enabled(client, mock_control_plane, monkeypatch): + monkeypatch.setenv("DEBUG_TRACEBACKS", "true") mock_engine = MagicMock() mock_engine.query.side_effect = Exception("Query Failed") mock_control_plane.get_engine.return_value = mock_engine - + response = client.post("/query", json={"query": "hello"}) - + assert response.status_code == 500 data = response.json() assert "detail" in data assert data["detail"]["error"] == "Query Failed" assert "trace" in data["detail"] +def test_query_endpoint_failure_traceback_disabled(client, mock_control_plane, monkeypatch): + monkeypatch.delenv("DEBUG_TRACEBACKS", raising=False) + mock_engine = MagicMock() + mock_engine.query.side_effect = Exception("Query Failed") + mock_control_plane.get_engine.return_value = mock_engine + + response = client.post("/query", json={"query": "hello"}) + + assert response.status_code == 500 + data = response.json() + assert "detail" in data + assert data["detail"]["error"] == "Query Failed" + assert "trace" not in data["detail"] + # ========================================================= # ENDPOINT TESTS: /stream