From 812aa3dda955e447b2033c762a2ee4dc74c487d8 Mon Sep 17 00:00:00 2001 From: Normann Date: Wed, 14 Oct 2026 15:22:22 +0200 Subject: [PATCH] fix: prevent exception diagnostics from leaking in API problem responses (#1405) * Potential fix for code scanning alert no. 11: Information exposure through an exception traceback should not be in client side code Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix: keep unexpected API error details private and test problem responses --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/akkudoktoreos/server/rest/error.py | 8 +-- tests/test_rest_error.py | 95 ++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 tests/test_rest_error.py diff --git a/src/akkudoktoreos/server/rest/error.py b/src/akkudoktoreos/server/rest/error.py index 8969d0b1..0b2927fd 100644 --- a/src/akkudoktoreos/server/rest/error.py +++ b/src/akkudoktoreos/server/rest/error.py @@ -1,5 +1,4 @@ import html -import traceback from dataclasses import dataclass from typing import Optional, cast @@ -8,8 +7,6 @@ from fastapi.exceptions import HTTPException, RequestValidationError from fastapi.responses import JSONResponse from loguru import logger -from akkudoktoreos.core.coreabc import get_config - @dataclass(slots=True) class EOSProblem(Exception): @@ -45,9 +42,6 @@ def _problem_response( if cause is not None: logger.exception(cause) - if get_config().logging.api_level in ("TRACE", "DEBUG"): - body["traceback"] = traceback.format_exception(cause) - return JSONResponse( status_code=status, media_type="application/problem+json", @@ -86,7 +80,7 @@ async def unexpected_exception_handler(request: Request, exc: Exception) -> JSON request=request, status=500, title="Internal Server Error", - detail=str(exc), + detail="An unexpected error occurred.", cause=exc, type="/problems/internal-server-error", ) diff --git a/tests/test_rest_error.py b/tests/test_rest_error.py new file mode 100644 index 00000000..a98800ca --- /dev/null +++ b/tests/test_rest_error.py @@ -0,0 +1,95 @@ +"""Problem responses keep server diagnostics private and preserve public errors.""" + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from loguru import logger + +from akkudoktoreos.server.rest.error import EOSProblem, register_problem_handlers + + +@pytest.mark.parametrize("api_level", ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]) +@pytest.mark.parametrize("unexpected", [False, True]) +def test_problem_responses_keep_diagnostics_in_logs(config_eos, api_level, unexpected): + config_eos.logging.api_level = api_level + app = FastAPI() + register_problem_handlers(app) + private_detail = "Database failed at /private/server.db with password=secret" + + @app.get("/failure") + def failure(): + try: + raise RuntimeError(private_detail) + except RuntimeError as exc: + if unexpected: + raise + raise EOSProblem( + status=503, + title="Service Unavailable", + detail="The service is temporarily unavailable.", + type="/problems/service-unavailable", + cause=exc, + ) from exc + + logs: list[str] = [] + sink_id = logger.add(logs.append, level="ERROR", backtrace=False, diagnose=False) + try: + with TestClient(app, raise_server_exceptions=False) as client: + response = client.get("/failure?token=private-query-token") + finally: + logger.remove(sink_id) + + status = 500 if unexpected else 503 + assert response.status_code == status + assert response.headers["content-type"] == "application/problem+json" + assert response.json() == { + "type": "/problems/internal-server-error" if unexpected else "/problems/service-unavailable", + "title": "Internal Server Error" if unexpected else "Service Unavailable", + "status": status, + "detail": ( + "An unexpected error occurred." + if unexpected + else "The service is temporarily unavailable." + ), + "instance": "/failure", + } + assert private_detail not in response.text + assert "private-query-token" not in response.text + assert any(private_detail in message and "Traceback" in message for message in logs) + + +def test_http_and_validation_errors_preserve_public_details(): + app = FastAPI() + register_problem_handlers(app) + + @app.get("/missing") + def missing(): + raise HTTPException(status_code=404, detail="The requested item does not exist.") + + @app.get("/validate") + def validate(value: int): + return {"value": value} + + with TestClient(app) as client: + missing_response = client.get("/missing") + validation_response = client.get("/validate?value=private-invalid-input") + + assert missing_response.status_code == 404 + assert missing_response.headers["content-type"] == "application/problem+json" + assert missing_response.json() == { + "type": "about:blank", + "title": "HTTP Error", + "status": 404, + "detail": "The requested item does not exist.", + "instance": "/missing", + } + assert validation_response.status_code == 422 + assert validation_response.headers["content-type"] == "application/problem+json" + assert validation_response.json() == { + "type": "/problems/validation", + "title": "Validation Error", + "status": 422, + "detail": "Request validation failed.", + "instance": "/validate", + } + assert "private-invalid-input" not in validation_response.text