Found by the Schemathesis API fuzzing spike (#923). Verified by hand against the real app on real PostgreSQL.
What happens
The plugin-version routes declare their version path parameter as a bare int, so FastAPI accepts any Python integer. PostgreSQL's plugin_version.version column is an INTEGER (32-bit), and asyncpg refuses to encode anything outside -2^31 .. 2^31-1. The result is a 500 where a 404 belongs.
GET /api/v1/evaluators/41813831-a5f0-4833-8447-4a69fbff83e9/versions/681812836839
Authorization: Bearer <token>
→ 500 Internal Server Error.
Captured exception:
sqlalchemy.exc.DBAPIError: (sqlalchemy.dialects.postgresql.asyncpg.Error)
<class 'asyncpg.exceptions.DataError'>: invalid input for query argument $2:
681812836839 (value out of int32 range)
[SQL: SELECT plugin_version.created, ... FROM plugin_version
WHERE plugin_version.plugin_id = $1::UUID AND plugin_version.version = $2::INTEGER]
Control: /versions/7 → 404 with a proper JSON detail, which is what an out-of-range version should also produce. Negative overflow (/versions/-3000000000) fails identically.
Affected operations
Four, all with the same shape:
GET /api/v1/evaluators/{evaluator_id}/versions/{version} — src/kitaru/server/adapters/rest/routers/evaluators.py:220
PATCH /api/v1/evaluators/{evaluator_id}/versions/{version} — evaluators.py:245
GET /api/v1/importers/{importer_id}/versions/{version} — src/kitaru/server/adapters/rest/routers/importers.py:220
PATCH /api/v1/importers/{importer_id}/versions/{version} — importers.py:246
Path through the code
The handler passes the raw int through PluginService into PluginRepository.get_version (src/kitaru/server/adapters/db/repositories/plugin_repository.py:277-279), which binds it against the INTEGER column. asyncpg raises DataError, SQLAlchemy wraps it as DBAPIError, and the handler at src/kitaru/server/api/app.py:175-189 only converts deadlocks and lost connections — everything else it re-raises, so the request 500s.
Impact
Low. It needs a valid bearer token under the LOCAL auth scheme, so it is an authenticated-user crash vector rather than an anonymous one. One handled 500 per request, no process crash, no data exposure, no resource exhaustion. Safe to file and fix publicly.
Suggested fix
Constrain the parameter at the API boundary in all four handlers:
version: Annotated[int, Path(ge=1, le=2_147_483_647)]
FastAPI then rejects out-of-range values with a 422 before any database call, and the bounds land in openapi/openapi.json automatically — which also closes the secondary schema gap, since the spec currently declares a bare type: integer with no bounds and so tells clients the wrong contract. Regenerate with scripts/generate_openapi.py and rerun pnpm run generate for the TypeScript types.
If a 404 is preferred over a 422 for symmetry with "version not found", clamp in the service instead — but the Path constraint is smaller and self-documenting.
Regression test
Assert 422 (not 500) for 2**31 and for -2**31 - 1 on one of the four routes. Needs PostgreSQL, since SQLite would accept the value.
Related
Shares a root cause with the NUL-byte class: the database's own constraints are the only validator, and its complaints arrive as server errors rather than client errors. The backstop proposed there — teaching the DBAPIError handler to recognize asyncpg's DataError family and answer 422 — would also catch this one.
Found by the Schemathesis API fuzzing spike (#923). Verified by hand against the real app on real PostgreSQL.
What happens
The plugin-version routes declare their
versionpath parameter as a bareint, so FastAPI accepts any Python integer. PostgreSQL'splugin_version.versioncolumn is anINTEGER(32-bit), and asyncpg refuses to encode anything outside-2^31 .. 2^31-1. The result is a 500 where a 404 belongs.→
500 Internal Server Error.Captured exception:
Control:
/versions/7→404with a proper JSON detail, which is what an out-of-range version should also produce. Negative overflow (/versions/-3000000000) fails identically.Affected operations
Four, all with the same shape:
GET /api/v1/evaluators/{evaluator_id}/versions/{version}—src/kitaru/server/adapters/rest/routers/evaluators.py:220PATCH /api/v1/evaluators/{evaluator_id}/versions/{version}—evaluators.py:245GET /api/v1/importers/{importer_id}/versions/{version}—src/kitaru/server/adapters/rest/routers/importers.py:220PATCH /api/v1/importers/{importer_id}/versions/{version}—importers.py:246Path through the code
The handler passes the raw
intthroughPluginServiceintoPluginRepository.get_version(src/kitaru/server/adapters/db/repositories/plugin_repository.py:277-279), which binds it against theINTEGERcolumn. asyncpg raisesDataError, SQLAlchemy wraps it asDBAPIError, and the handler atsrc/kitaru/server/api/app.py:175-189only converts deadlocks and lost connections — everything else it re-raises, so the request 500s.Impact
Low. It needs a valid bearer token under the LOCAL auth scheme, so it is an authenticated-user crash vector rather than an anonymous one. One handled 500 per request, no process crash, no data exposure, no resource exhaustion. Safe to file and fix publicly.
Suggested fix
Constrain the parameter at the API boundary in all four handlers:
FastAPI then rejects out-of-range values with a 422 before any database call, and the bounds land in
openapi/openapi.jsonautomatically — which also closes the secondary schema gap, since the spec currently declares a baretype: integerwith no bounds and so tells clients the wrong contract. Regenerate withscripts/generate_openapi.pyand rerunpnpm run generatefor the TypeScript types.If a 404 is preferred over a 422 for symmetry with "version not found", clamp in the service instead — but the
Pathconstraint is smaller and self-documenting.Regression test
Assert 422 (not 500) for
2**31and for-2**31 - 1on one of the four routes. Needs PostgreSQL, since SQLite would accept the value.Related
Shares a root cause with the NUL-byte class: the database's own constraints are the only validator, and its complaints arrive as server errors rather than client errors. The backstop proposed there — teaching the
DBAPIErrorhandler to recognize asyncpg'sDataErrorfamily and answer 422 — would also catch this one.