From f94770a7fb2b0127399d890519ad45286e38fd08 Mon Sep 17 00:00:00 2001 From: Naman Singh Date: Tue, 14 Jul 2026 11:17:11 +0530 Subject: [PATCH 1/2] fix(security): disable debug by default, escape traceback XSS, gate OpenAPI docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related security fixes for information disclosure and XSS: 1. Changed debug default from True to False — production deployments no longer accidentally run with debug enabled (config.py:22) 2. HTML-escaped traceback.format_exc() before embedding in 500 response to prevent Reflected XSS via exception messages containing attacker-controlled content (main.py:293) 3. Gated /docs, /redoc, /openapi.json endpoints behind debug mode — API schema is no longer publicly exposed in production (main.py:171-173) Resolves: #2022 --- backend/secuscan/config.py | 2 +- backend/secuscan/main.py | 23 +++++++++++++++----- testing/backend/unit/test_config_settings.py | 4 ++-- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/backend/secuscan/config.py b/backend/secuscan/config.py index e37be8543..757ca3ac9 100644 --- a/backend/secuscan/config.py +++ b/backend/secuscan/config.py @@ -19,7 +19,7 @@ class Settings(BaseSettings): # Server Configuration bind_address: str = "127.0.0.1" bind_port: int = 8000 - debug: bool = True + debug: bool = False # Primary data store database_path: str = str(PROJECT_ROOT / "data" / "secuscan.db") diff --git a/backend/secuscan/main.py b/backend/secuscan/main.py index 66282c415..b2c041932 100644 --- a/backend/secuscan/main.py +++ b/backend/secuscan/main.py @@ -2,6 +2,7 @@ SecuScan Backend - Main application entry point """ +import html as html_mod import logging import sys import shutil @@ -162,30 +163,39 @@ async def lifespan(app: FastAPI): await scheduler.stop() logger.info("✓ Shutdown complete") -# Create FastAPI application +# Create FastAPI application — docs/redoc/openapi only exposed when debug=True app = FastAPI( title="SecuScan API", description="Backend for SecuScan Pentesting Toolkit", version="1.0.0", - docs_url="/docs", - redoc_url="/redoc", - openapi_url="/openapi.json", + docs_url="/docs" if settings.debug else None, + redoc_url="/redoc" if settings.debug else None, + openapi_url="/openapi.json" if settings.debug else None, lifespan=lifespan ) @app.get("/api/docs", include_in_schema=False) async def redirect_api_docs(): from fastapi.responses import RedirectResponse + if not settings.debug: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Not found") return RedirectResponse(url="/docs") @app.get("/api/redoc", include_in_schema=False) async def redirect_api_redoc(): from fastapi.responses import RedirectResponse + if not settings.debug: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Not found") return RedirectResponse(url="/redoc") @app.get("/api/openapi.json", include_in_schema=False) async def redirect_api_openapi(): from fastapi.responses import RedirectResponse + if not settings.debug: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Not found") return RedirectResponse(url="/openapi.json") # CORS middleware @@ -280,8 +290,9 @@ async def custom_unhandled_exception_handler(request: Request, exc: Exception): if settings.debug: import traceback - html = f"

500 Internal Server Error

{traceback.format_exc()}
" - response = HTMLResponse(html, status_code=500) + safe_traceback = html_mod.escape(traceback.format_exc()) + html_content = f"

500 Internal Server Error

{safe_traceback}
" + response = HTMLResponse(html_content, status_code=500) else: response = PlainTextResponse("Internal Server Error", status_code=500) diff --git a/testing/backend/unit/test_config_settings.py b/testing/backend/unit/test_config_settings.py index e167664f1..614d9dd43 100644 --- a/testing/backend/unit/test_config_settings.py +++ b/testing/backend/unit/test_config_settings.py @@ -54,9 +54,9 @@ def test_settings_default_bind_address(): def test_settings_default_debug(): - """Debug defaults to True.""" + """Debug defaults to False (secure-by-default).""" s = Settings() - assert s.debug is True + assert s.debug is False def test_settings_default_sandbox_disabled(): From 929c87065e89e43f5fba91401f446356a4d4f9c5 Mon Sep 17 00:00:00 2001 From: Naman Singh Date: Wed, 15 Jul 2026 14:00:28 +0530 Subject: [PATCH 2/2] fix(security): enable debug mode in start.sh, add focused docs/traceback tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address admin review feedback on PR #2024: 1. start.sh: Export SECUSCAN_DEBUG=true so the dev server exposes /docs, /redoc, and /openapi.json — fixing the fresh-clone smoke check that was timing out polling /openapi.json. 2. test_config_settings.py: Add focused tests for debug-on/debug-off behavior: - test_docs_disabled_when_debug_false: /docs returns 404 in prod - test_openapi_disabled_when_debug_false: /openapi.json returns 404 - test_docs_enabled_when_debug_true: /docs works when debug on - test_traceback_not_leaked_in_production: exception messages don't leak in plain-text error responses --- start.sh | 3 + testing/backend/unit/test_config_settings.py | 86 ++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/start.sh b/start.sh index f582f9516..638c1f2b7 100755 --- a/start.sh +++ b/start.sh @@ -86,6 +86,9 @@ pip install -q -r backend/requirements.txt mkdir -p "$ROOT_DIR/data" "$ROOT_DIR/logs" echo "🚀 Starting backend on http://127.0.0.1:8000" +# Enable debug mode for development — exposes /docs, /redoc, /openapi.json +# and serves detailed error pages. Production defaults to debug=False. +export SECUSCAN_DEBUG=true python -m uvicorn backend.secuscan.main:app \ --host 127.0.0.1 \ --port 8000 \ diff --git a/testing/backend/unit/test_config_settings.py b/testing/backend/unit/test_config_settings.py index 614d9dd43..5b60cd1ad 100644 --- a/testing/backend/unit/test_config_settings.py +++ b/testing/backend/unit/test_config_settings.py @@ -286,3 +286,89 @@ def test_sandbox_settings_env_override(): assert s.sandbox_timeout == 30 assert s.sandbox_memory_mb == 128 assert s.sandbox_allow_network is False + + +# ── debug mode docs gating (Issue #2022) ────────────────────────────────────── + + +def test_debug_mode_true_enables_docs(): + """When debug=True, docs_url/redoc_url/openapi_url are set.""" + s = Settings(debug=True) + assert s.debug is True + + +def test_debug_mode_false_disables_docs(): + """When debug=False (default), docs should not be exposed.""" + s = Settings(debug=False) + assert s.debug is False + + +# ── FastAPI docs gating via TestClient ──────────────────────────────────────── + + +def test_docs_disabled_when_debug_false(tmp_path, monkeypatch): + """/docs returns 404 when the app was created with debug=False.""" + from backend.secuscan.main import app as _app + from fastapi.testclient import TestClient + + # The app is created with debug=False by default, so docs_url=None + assert _app.docs_url is None + + with TestClient(_app, raise_server_exceptions=False) as client: + resp = client.get("/docs") + assert resp.status_code == 404 + + +def test_openapi_disabled_when_debug_false(tmp_path, monkeypatch): + """/openapi.json returns 404 when the app was created with debug=False.""" + from backend.secuscan.main import app as _app + from fastapi.testclient import TestClient + + # The app is created with debug=False by default, so openapi_url=None + assert _app.openapi_url is None + + with TestClient(_app, raise_server_exceptions=False) as client: + resp = client.get("/openapi.json") + assert resp.status_code == 404 + + +def test_docs_enabled_when_debug_true(tmp_path): + """/docs returns 200 when a fresh app is created with debug=True.""" + from backend.secuscan.config import Settings + from fastapi import FastAPI + from fastapi.testclient import TestClient + + app_debug = FastAPI( + title="Test", + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", + ) + + with TestClient(app_debug, raise_server_exceptions=False) as client: + resp = client.get("/docs") + assert resp.status_code == 200 + resp = client.get("/openapi.json") + assert resp.status_code == 200 + data = resp.json() + assert "openapi" in data + + +def test_traceback_not_leaked_in_production(tmp_path, monkeypatch): + """In production (debug=False), unhandled exceptions return plain text, not HTML tracebacks.""" + from backend.secuscan.config import settings as real_settings + from backend.secuscan.main import app as _app + from fastapi.testclient import TestClient + + monkeypatch.setattr(real_settings, "debug", False) + + @_app.get("/test-exception-prod") + async def raise_exception_prod(): + raise RuntimeError("secret-sauce-error-12345") + + with TestClient(_app, raise_server_exceptions=False) as client: + resp = client.get("/test-exception-prod") + assert resp.status_code == 500 + body = resp.text + assert "secret-sauce-error-12345" not in body + assert "
" not in body