diff --git a/services/api/app/main.py b/services/api/app/main.py index 9ed60e2..ceafab6 100644 --- a/services/api/app/main.py +++ b/services/api/app/main.py @@ -40,8 +40,10 @@ ensure_qdrant_collection, qdrant_health, ) -from fastapi import Depends, FastAPI +from fastapi import Depends, FastAPI, Request +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse log = logging.getLogger("memorygate") @@ -49,6 +51,21 @@ app = FastAPI(title="MemoryGate", version=SERVICE_VERSION) + +@app.exception_handler(RequestValidationError) +async def validation_error(_request: Request, error: RequestValidationError): + # FastAPI's default reply repeats the refused input (for a missing field, the whole body), + # and key changes carry the admin key itself: say where and why, never what was sent. + return JSONResponse( + { + "detail": [ + {"loc": list(item["loc"]), "msg": item["msg"], "type": item["type"]} + for item in error.errors() + ] + }, + status_code=422, + ) + # Only the bundled dashboard's own origin by default. `*` is available as an # explicit development override via MEMORYGATE_CORS_ORIGINS and is logged when # used, because a wildcard puts every route in reach of any page the owner has diff --git a/services/api/tests/test_validation_no_echo.py b/services/api/tests/test_validation_no_echo.py new file mode 100644 index 0000000..e801a7c --- /dev/null +++ b/services/api/tests/test_validation_no_echo.py @@ -0,0 +1,31 @@ +"""A refused request body is never sent back: a key in a bad request stays out of the reply.""" + +import pytest +from memory_stack import stack as stack + +SECRET = "never-echo-current-key-0123456789" +PROVIDER_KEY = "sk-never-echo-provider-0123456789" + + +@pytest.mark.parametrize( + ("method", "path", "body"), + [ + # A missing field: pydantic's error input is the whole body, the current key included. + ("POST", "/auth/change-key", {"current_key": SECRET}), + ("POST", "/auth/change-key", {"current_key": SECRET, "new_key": "short"}), + ("PUT", "/system/ai-runtime", {"current_key": SECRET, "api_key": PROVIDER_KEY}), + # A wrong type: the error input is the value itself. + ("POST", "/auth/rotate-key", {"current_key": [SECRET]}), + ("POST", "/system/memory-reset", {"current_key": {"key": SECRET}}), + ], +) +def test_refused_body_is_not_echoed(stack, method, path, body): + response = stack.http.request(method, path, json=body, headers=stack.admin_headers()) + + assert response.status_code == 422 + assert SECRET not in response.text + assert PROVIDER_KEY not in response.text + # Callers still learn which field was wrong and why. + for error in response.json()["detail"]: + assert set(error) == {"loc", "msg", "type"} + assert error["loc"][0] == "body"