Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion services/api/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,32 @@
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")

SERVICE_VERSION = "0.7.0"

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
Expand Down
31 changes: 31 additions & 0 deletions services/api/tests/test_validation_no_echo.py
Original file line number Diff line number Diff line change
@@ -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"
Loading