Skip to content

fix: gate traceback disclosure behind DEBUG_TRACEBACKS flag, run container as non-root - #11

Merged
man4ish merged 1 commit into
mainfrom
fix/security-hardening
Sep 1, 2026
Merged

fix: gate traceback disclosure behind DEBUG_TRACEBACKS flag, run container as non-root#11
man4ish merged 1 commit into
mainfrom
fix/security-hardening

Conversation

@man4ish

@man4ish man4ish commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

What

  1. Gates traceback disclosure in /rag/query's error response behind DEBUG_TRACEBACKS (default off).
  2. Splits the traceback assertion test into two explicit states.
  3. Runs the container as a non-root user, with ownership fixed on every path the app/nginx actually writes to.
  4. Checked whether ghcr.io/omnibioai/omnibioai-base:latest has a pinnable version — it doesn't (see below).
  5. Also fixed a build-blocking bug found along the way: Dockerfile still had COPY utils/ ./utils/, but utils/ was deleted in a prior cleanup PR (chore: remove zero-byte stub modules with no references (duplicated inline in rag/engine.py) #8) — this broke docker build outright. Removed the dead COPY line so the image builds again.

1. /rag/query traceback gating (api/routes/rag.py)

New _debug_tracebacks_enabled() (same on/off convention as api/auth.py's _auth_enabled(): os.getenv("DEBUG_TRACEBACKS", "").strip().lower() == "true"). The except block in /query now only adds "trace" to the response detail when that's true.

Before/after (captured via TestClient against a mocked engine raising Exception("Query Failed")):

DEBUG_TRACEBACKS unset (new default):

{
  "detail": {
    "error": "Query Failed"
  }
}

DEBUG_TRACEBACKS=true (preserves prior behavior exactly):

{
  "detail": {
    "error": "Query Failed",
    "trace": "Traceback (most recent call last):\n  File \".../api/routes/rag.py\", line 63, in query\n    result = engine.query(...)\n  ..."
  }
}

/rag/stream (api/routes/rag.py event_stream error handler) already only ever returned {"type": "error", "message": str(e)} — no traceback, no change needed. This PR makes that the consistent behavior everywhere: neither endpoint leaks a stack trace by default now.

2. Test split (tests/test_rag_routes.py)

test_query_endpoint_failure (which asserted "trace" in data["detail"] unconditionally) is now two tests:

  • test_query_endpoint_failure_traceback_enabled — sets DEBUG_TRACEBACKS=true via monkeypatch.setenv, asserts "trace" in data["detail"].
  • test_query_endpoint_failure_traceback_disabled — clears the env var via monkeypatch.delenv, asserts "trace" not in data["detail"].

Both states are explicitly tested — nothing was deleted or weakened.

3. Non-root container (Dockerfile)

Checked every path the app/nginx actually write to at runtime before touching permissions:

Path Written by Fix
/app/data/faiss_index/ VectorStore.save() via scripts/build_index.py on first boot pre-created, chown -R appuser:appuser /app
/app/logs/, /app/cache/ reserved for future app use (currently unwritten by any code) pre-created + owned, so a future write doesn't silently hit a permission error
/var/log/nginx/{access,error}.log nginx, opened at startup (not lazily) chown -R appuser:appuser /var/log/nginx
/var/lib/nginx/{body,proxy,fastcgi,scgi,uwsgi} nginx worker temp dirs, created on first request chown -R appuser:appuser /var/lib/nginx
/etc/nginx/conf.d/devhub.conf CMD script, generated at container start (embeds runtime JWT_SECRET) chown -R appuser:appuser /etc/nginx/conf.d

Also: stock nginx.conf sets user www-data; (assumes a root master process able to setuid) and pid /run/nginx.pid; (/run is root:root here) — both adjusted: the user directive is dropped (master already runs as appuser, so no setuid needed) and the pid file is redirected to /tmp (world-writable).

USER appuser (uid/gid 10001, no home, nologin shell) is the last setup instruction before CMD.

4. Base image pinning

Checked the GHCR registry directly (gh api /orgs/OmniBioAI/packages/container/omnibioai-base/versions): one version exists, tagged only "latest" — no semver/release tags, no dedicated base-image repo with GitHub releases. There's nothing to pin to that wouldn't be guessing. Left as ghcr.io/omnibioai/omnibioai-base:latest, unchanged — this remains a follow-up blocked on the base image publishing versioned tags. (Pinning to the current image digest was considered and rejected: that's a materially different decision — freezing to today's exact build rather than tracking intentional base-image updates — and the task asked for a pinnable version, not a digest.)


Verification

pytest: pytest -q175 passed (174-test baseline + 1 net-new test from the traceback split). Both new states individually confirmed:

tests/test_rag_routes.py::test_query_endpoint_failure_traceback_enabled PASSED
tests/test_rag_routes.py::test_query_endpoint_failure_traceback_disabled PASSED

Docker build + non-root run (fresh build, no cache reused beyond unrelated earlier layers):

$ docker build --platform linux/arm64 -t omnibioai-dev-hub:nonroot-test .
...
#28 [backend 17/17] RUN groupadd --gid 10001 appuser && useradd ... && chown -R appuser:appuser /app ...
#28 DONE 0.7s
...
naming to docker.io/library/omnibioai-dev-hub:nonroot-test done

Ran it (Ollama reachable via --add-host=ollama:host-gateway, pre-built FAISS index bind-mounted read-only so startup skips a full 19-repo rebuild):

$ docker run -d --add-host=ollama:host-gateway -p 18082:8082 -p 15173:5173 \
    -v "$(pwd)/data/faiss_index:/app/data/faiss_index:ro" omnibioai-dev-hub:nonroot-test

$ docker exec <container> id
uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)

$ docker logs <container>
⏳ Waiting for Ollama...
✅ Ollama is ready
✅ Index already exists, skipping build
🌐 nginx started on port 5173
INFO:     Started server process [1]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8082 (Press CTRL+C to quit)
INFO:     172.17.0.1:46372 - "POST /rag/query HTTP/1.1" 200 OK
INFO:     127.0.0.1:43804 - "POST /rag/query HTTP/1.0" 200 OK

Confirmed all three checks:

  • Container starts without permission errors — clean startup log above, nginx.conf rewrite and devhub.conf generation both succeeded.
  • /rag/query responds successfully from inside the running container — tested both directly against uvicorn (port 8082, HTTP 200 with a full grounded answer) and through nginx's reverse proxy (port 5173 → /rag/, also HTTP 200).
  • Logs and cache writes succeed without permission errors:
    • docker exec <container> ps auxuvicorn, nginx master, and all nginx workers run as appuser, not root.
    • /var/log/nginx/error.log — empty (no errors logged).
    • /var/lib/nginx/{body,fastcgi,proxy,scgi,uwsgi} — auto-created by nginx workers on first request, all owned appuser:appuser.
    • /etc/nginx/conf.d/devhub.conf — written successfully by the CMD script as appuser.
    • /app/logs, /app/cache — confirmed owned by appuser:appuser (no code path currently writes there during a query, so this is "ready and correctly owned" rather than "observed under load" — noted for transparency).

Scope

3 files: Dockerfile, api/routes/rag.py, tests/test_rag_routes.py.

⚠️ Do not auto-merge

Per instructions, this PR is not to be merged automatically — same as PR #9 and #10. Results reported above for review; merge only after explicit confirmation.

…ainer as non-root

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RttwcxBgsnXH2TwQGQsrp
@man4ish
man4ish merged commit 0644a8e into main Sep 1, 2026
1 of 2 checks passed
@man4ish
man4ish deleted the fix/security-hardening branch September 1, 2026 01:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant