mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 23:31:31 +02:00
A write fuzz over all 113 unguarded write operations (6,465 requests)
produced 36 unhandled 500s. Every one was the same shape: a value
pydantic accepted that Postgres then refused while binding or writing,
surfacing as an unhandled DBAPIError.
Three kinds appeared — a NUL byte in any text field (project, org, tag
and engine names, MCP token names, interest rule queries, the `q` of
all six search grammars, endpoint verify host and path), a string
longer than its column (project label against varchar(50)), and an
integer past bigint (a body-supplied page number, interest rule
weight).
All three are SQLSTATE class 22, the SQL standard's data exception
class, which by definition means the supplied value cannot be
represented in the target column. That is always a client error, so a
handler now maps class 22 to 400 with a message naming the kind of
problem, and re-raises everything else. Class 23 (integrity) is
deliberately left alone: it can indicate a server fault.
The one class 23 failure was a genuine defect rather than bad input.
InterestService.update wrote an explicit null straight through to a
NOT NULL column, so PATCH {"enabled": null} was a 500. It now stores a
null only where the column accepts one, taking that from the column
itself, which keeps `weight: null` clearing the weight while `enabled:
null` means unchanged — the convention every other update service here
already follows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6LcH8uUddjJbKrahf34ys
45 lines
1.5 KiB
Python
45 lines
1.5 KiB
Python
"""Turn a value the database refuses into a 400 rather than an unhandled 500."""
|
|
|
|
from fastapi import Request
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy.exc import DBAPIError
|
|
from starlette.status import HTTP_400_BAD_REQUEST
|
|
|
|
from shared.logging import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# SQLSTATE class 22 is the SQL standard's data exception class: the value supplied
|
|
# cannot be represented in the column it was sent to. It is never a server fault.
|
|
_DATA_EXCEPTION = "22"
|
|
|
|
_MESSAGES = {
|
|
"22001": "A value in the request is longer than the field allows.",
|
|
"22003": "A number in the request is outside the range the field allows.",
|
|
"22021": "A value in the request contains a NUL byte, which cannot be stored.",
|
|
}
|
|
_FALLBACK = "A value in the request is not valid for the field it was sent to."
|
|
|
|
|
|
def _sqlstate(exc: DBAPIError) -> str | None:
|
|
orig = exc.orig
|
|
inner = getattr(orig, "__cause__", None) or orig
|
|
state = getattr(inner, "sqlstate", None)
|
|
return state if isinstance(state, str) else None
|
|
|
|
|
|
async def data_exception_handler(request: Request, exc: DBAPIError) -> JSONResponse:
|
|
state = _sqlstate(exc)
|
|
if not state or not state.startswith(_DATA_EXCEPTION):
|
|
raise exc
|
|
logger.info(
|
|
"rejected unstorable value",
|
|
path=request.url.path,
|
|
method=request.method,
|
|
sqlstate=state,
|
|
)
|
|
return JSONResponse(
|
|
status_code=HTTP_400_BAD_REQUEST,
|
|
content={"detail": _MESSAGES.get(state, _FALLBACK)},
|
|
)
|