Found by the Schemathesis API fuzzing spike (#923). Verified by hand against the real app on real PostgreSQL, not just in the fuzz log.
What happens
A NUL byte (\x00) inside any user-supplied string travels from the HTTP boundary all the way to PostgreSQL, which refuses to store it, and the request answers 500 Internal Server Error instead of a 4xx.
The chain:
- A caller sends a string containing a single zero byte. It travels as an ordinary JSON
\u0000 escape or as %00 in a urlencoded form field, so no exotic client, raw socket, or proxy trick is needed.
- FastAPI accepts it. The field is typed
str | None with no character rules, and a zero byte is a legal Python string character.
- Nothing between the route handler and the database looks at the string.
- asyncpg cannot encode the byte into a PostgreSQL text parameter and raises
CharacterNotInRepertoireError: invalid byte sequence for encoding "UTF8": 0x00. SQLAlchemy wraps it as DBAPIError.
- The
DBAPIError handler at src/kitaru/server/api/app.py:174-186 only converts deadlocks and lost connections into 503s and re-raises everything else, so Starlette answers a bare 500.
This is a class of defect, not two endpoints. Every user-supplied string that reaches a PostgreSQL text column without passing through a domain entity has the same hole. The two instances below are only what Hypothesis happened to land on at 50 examples per operation.
Two proven instances
1. POST /api/v1/device_authorization — unauthenticated
POST /api/v1/device_authorization
Content-Type: application/x-www-form-urlencoded
python_version=3.14%00
→ 500 Internal Server Error. No Authorization header needed — the router is mounted with no auth dependency and the handler takes no credential, which is correct for the device grant's first leg. The same failure occurs via the hostname field.
The NUL lands in an INSERT INTO device (...), so this one is a write path.
Path: src/kitaru/server/adapters/rest/routers/auth.py:186 declares python_version: Annotated[str | None, Form()] = None → packed into DeviceFingerprint (src/kitaru/server/application/models/device.py:39-43, plain str | None, no validators) → DeviceService.request_authorization (src/kitaru/server/application/services/device_service.py:82-92) → repository.create(device).
Controls, all on the same server instance: python_version=3.14 → 200. python_version=3.14☃ (non-ASCII but valid UTF-8) → 200. GET /health after the failures → 200, so the process and connection pool survive.
2. POST /api/v1/imports — authenticated
POST /api/v1/imports
Authorization: Bearer <token>
Content-Type: application/json
{"importer": "a\u0000b", "agent_id": "e3e70682-c209-1cac-a29f-6fbed82c07cd",
"payload_blob_id": "e3e70682-c209-1cac-a29f-6fbed82c07cd", "params": {}}
→ 500 Internal Server Error. Both UUIDs are nonexistent and that does not matter — the crash happens on the plugin lookup query before the blob or agent is touched.
Path: src/kitaru/api_models/v1/imports.py:28 declares importer: str with no validation → create_import (src/kitaru/server/adapters/rest/routers/imports.py:52-53) → JobService.create_import (src/kitaru/server/application/services/job_service.py:321) → resolve_plugin (src/kitaru/server/application/services/plugin_resolution.py:36) → PluginRepository.get_by_name (src/kitaru/server/adapters/db/repositories/plugin_repository.py:126-129).
Control: the same request with "importer": "ab" (no NUL, still nonexistent) → 404 {"detail":"Plugin ab was not found"}. So the 500 is caused by the byte, not by the missing row.
The divergence from intent is sharp here. Kitaru already has a name validator (_validate in src/kitaru/server/domain/names.py:152-177, exposed as the Name/NamespacedName annotated types), and Plugin.name is typed NamespacedName (src/kitaru/server/domain/plugin.py:272). Any write path builds that entity and correctly rejects the byte with a 422 — verified: POST /api/v1/importers and POST /api/v1/agents with the same "a\u0000b" name both return a clean 422. The lookup path never constructs a domain entity, so it never validates, and a name that could never have been stored is queried for anyway.
Impact
Not a vulnerability, and it should be filed and fixed publicly:
- No process crash. Ten consecutive failures left the connection pool healthy and the next request returned 200.
- No injection. Parameters are bound; asyncpg refuses to encode the byte rather than passing it through.
- No data exposure. The response body is the bare Starlette
Internal Server Error with no SQL or schema leaked.
- No amplification. A failed request costs about the same as a successful one.
The real cost is operational. Any caller — anonymous, in the device_authorization case — can mint 500s at will, which pollutes error dashboards and trips 5xx alerting. And any client generated from openapi/openapi.json sees an undocumented status.
Suggested fix
Two halves that belong in the same change, because the first can never be provably complete:
Validate at the boundary. Add one shared annotated string type in api_models that rejects \x00 (and sensibly the other C0 control characters apart from tab, newline, and carriage return), so Pydantic turns it into a 422 with a clear message. The repo already has the pattern at src/kitaru/api_models/v1/evaluation.py:58 (Annotated[str, AfterValidator(...)]). Apply it to the four Form() parameters in auth.py:184-187, to the string fields of DeviceFingerprint, and to ImportCreateRequest.importer. Note that api_models is the shared SDK package while domain/names.py is server-side, so either move the validator (or an equivalent regex pattern=) into api_models, or check inside resolve_plugin and raise PluginNotFound. For the lookup path a 404 is semantically exact and matches the route's own docstring, since a name violating the character rules can never exist in the plugin table.
Then sweep the other request models the same way. replay_config.evaluator (src/kitaru/api_models/v1/replay_config.py:63) has the identical raw-string-used-for-lookup shape.
Add a backstop. Extend the DBAPIError handler at src/kitaru/server/api/app.py:174-186 with a check alongside is_deadlock/is_connection_unavailable for asyncpg's CharacterNotInRepertoireError and DataError, returning 422 with a "request contained a value the database cannot store" message instead of re-raising into a 500. That way the next unvalidated string field is a bad request rather than a crash.
Regression tests
POST /api/v1/device_authorization with python_version=3.14\x00 → 422, not 500.
- A clean non-ASCII value on the same endpoint still → 200.
POST /api/v1/imports with a NUL-bearing importer → 404, not 500.
Two notes for whoever picks this up
This is invisible on SQLite. SQLite stores NUL in text happily, so a SQLite-backed test suite would never catch any member of this family. The regression tests need PostgreSQL.
Worth checking separately: whether any other instance of this class fails after a side effect has already happened (a blob written, an external call made), which would turn a noisy 500 into an orphaned-record problem.
Found by the Schemathesis API fuzzing spike (#923). Verified by hand against the real app on real PostgreSQL, not just in the fuzz log.
What happens
A NUL byte (
\x00) inside any user-supplied string travels from the HTTP boundary all the way to PostgreSQL, which refuses to store it, and the request answers500 Internal Server Errorinstead of a 4xx.The chain:
\u0000escape or as%00in a urlencoded form field, so no exotic client, raw socket, or proxy trick is needed.str | Nonewith no character rules, and a zero byte is a legal Python string character.CharacterNotInRepertoireError: invalid byte sequence for encoding "UTF8": 0x00. SQLAlchemy wraps it asDBAPIError.DBAPIErrorhandler atsrc/kitaru/server/api/app.py:174-186only converts deadlocks and lost connections into 503s and re-raises everything else, so Starlette answers a bare 500.This is a class of defect, not two endpoints. Every user-supplied string that reaches a PostgreSQL text column without passing through a domain entity has the same hole. The two instances below are only what Hypothesis happened to land on at 50 examples per operation.
Two proven instances
1.
POST /api/v1/device_authorization— unauthenticated→
500 Internal Server Error. NoAuthorizationheader needed — the router is mounted with no auth dependency and the handler takes no credential, which is correct for the device grant's first leg. The same failure occurs via thehostnamefield.The NUL lands in an
INSERT INTO device (...), so this one is a write path.Path:
src/kitaru/server/adapters/rest/routers/auth.py:186declarespython_version: Annotated[str | None, Form()] = None→ packed intoDeviceFingerprint(src/kitaru/server/application/models/device.py:39-43, plainstr | None, no validators) →DeviceService.request_authorization(src/kitaru/server/application/services/device_service.py:82-92) →repository.create(device).Controls, all on the same server instance:
python_version=3.14→ 200.python_version=3.14☃(non-ASCII but valid UTF-8) → 200.GET /healthafter the failures → 200, so the process and connection pool survive.2.
POST /api/v1/imports— authenticated→
500 Internal Server Error. Both UUIDs are nonexistent and that does not matter — the crash happens on the plugin lookup query before the blob or agent is touched.Path:
src/kitaru/api_models/v1/imports.py:28declaresimporter: strwith no validation →create_import(src/kitaru/server/adapters/rest/routers/imports.py:52-53) →JobService.create_import(src/kitaru/server/application/services/job_service.py:321) →resolve_plugin(src/kitaru/server/application/services/plugin_resolution.py:36) →PluginRepository.get_by_name(src/kitaru/server/adapters/db/repositories/plugin_repository.py:126-129).Control: the same request with
"importer": "ab"(no NUL, still nonexistent) →404 {"detail":"Plugin ab was not found"}. So the 500 is caused by the byte, not by the missing row.The divergence from intent is sharp here. Kitaru already has a name validator (
_validateinsrc/kitaru/server/domain/names.py:152-177, exposed as theName/NamespacedNameannotated types), andPlugin.nameis typedNamespacedName(src/kitaru/server/domain/plugin.py:272). Any write path builds that entity and correctly rejects the byte with a 422 — verified:POST /api/v1/importersandPOST /api/v1/agentswith the same"a\u0000b"name both return a clean 422. The lookup path never constructs a domain entity, so it never validates, and a name that could never have been stored is queried for anyway.Impact
Not a vulnerability, and it should be filed and fixed publicly:
Internal Server Errorwith no SQL or schema leaked.The real cost is operational. Any caller — anonymous, in the
device_authorizationcase — can mint 500s at will, which pollutes error dashboards and trips 5xx alerting. And any client generated fromopenapi/openapi.jsonsees an undocumented status.Suggested fix
Two halves that belong in the same change, because the first can never be provably complete:
Validate at the boundary. Add one shared annotated string type in
api_modelsthat rejects\x00(and sensibly the other C0 control characters apart from tab, newline, and carriage return), so Pydantic turns it into a 422 with a clear message. The repo already has the pattern atsrc/kitaru/api_models/v1/evaluation.py:58(Annotated[str, AfterValidator(...)]). Apply it to the fourForm()parameters inauth.py:184-187, to the string fields ofDeviceFingerprint, and toImportCreateRequest.importer. Note thatapi_modelsis the shared SDK package whiledomain/names.pyis server-side, so either move the validator (or an equivalent regexpattern=) intoapi_models, or check insideresolve_pluginand raisePluginNotFound. For the lookup path a 404 is semantically exact and matches the route's own docstring, since a name violating the character rules can never exist in theplugintable.Then sweep the other request models the same way.
replay_config.evaluator(src/kitaru/api_models/v1/replay_config.py:63) has the identical raw-string-used-for-lookup shape.Add a backstop. Extend the
DBAPIErrorhandler atsrc/kitaru/server/api/app.py:174-186with a check alongsideis_deadlock/is_connection_unavailablefor asyncpg'sCharacterNotInRepertoireErrorandDataError, returning 422 with a "request contained a value the database cannot store" message instead of re-raising into a 500. That way the next unvalidated string field is a bad request rather than a crash.Regression tests
POST /api/v1/device_authorizationwithpython_version=3.14\x00→ 422, not 500.POST /api/v1/importswith a NUL-bearingimporter→ 404, not 500.Two notes for whoever picks this up
This is invisible on SQLite. SQLite stores NUL in text happily, so a SQLite-backed test suite would never catch any member of this family. The regression tests need PostgreSQL.
Worth checking separately: whether any other instance of this class fails after a side effect has already happened (a blob written, an external call made), which would turn a noisy 500 into an orphaned-record problem.