Stop id/nano_id from being client-settable on create/update - #2568
Stop id/nano_id from being client-settable on create/update#2568harry-rhesis wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
[Improvement] Defense-in-depth: crud_utils._prepare_item_data still allows id through on create for dict-based callers. Consider stripping id by default and only allowing it for the known pre-generated-PK use case(s) (e.g. FileCreate).
Everything else looks aligned with the stated goal: schema split + SDK/frontend payload tightening + strong route-introspection tests should make this hard to regress.
|
|
||
| # nano_id is server-owned: nothing in the backend ever writes one, so drop it here as | ||
| # well as at the schema layer. This covers the dict-based callers that skip Pydantic. | ||
| # `id` is deliberately NOT dropped -- FileCreate pre-generates its primary key so the |
There was a problem hiding this comment.
[Improvement] _prepare_item_data still allows id through on create for any dict-based caller.
Fix: consider popping
idhere too, and only allowing it for the specific create path(s) that need pre-generated PKs (e.g.FileCreate/File model) via an explicit allowlist/flag. That keeps the defense-in-depth guarantee symmetrical with the update path and avoids reintroducing UUID-squatting if a future endpoint bypasses Pydantic.
There was a problem hiding this comment.
Good news: with the new schema split + route-introspection test, a client shouldn’t be able to send id on normal creates anymore.
The remaining reason I still suggested popping id in _prepare_item_data is purely defense-in-depth for dict-based internal call paths: I found at least crud/embedding.py:create_embedding() and crud/metric.py:create_metric() build a plain dict and pass it into create_item() (so _prepare_item_data will accept any id key if one is ever present). Today that dict originates from a validated Pydantic model, so it’s fine — but if a future endpoint/task bypasses validation, it could reintroduce UUID-squatting on create.
If you want to keep the “FileCreate can pre-generate id” behavior, one clean pattern would be data.pop("id", None) by default + an explicit allow_client_id=True flag/allowlist at the specific File create call site.
There was a problem hiding this comment.
Fixed in e45f44e: _prepare_item_data now pops id by default and only lets it through when model.__name__ == "File" — matching the pattern you suggested. Confirmed crud/file.py:51 is the only internal caller that passes an explicit id through create_item; crud/embedding.py/crud/metric.py don't set one. Verified with the full backend suite (3198 passed) plus all File-specific tests (57 passed) to confirm the pre-generated-PK path still works.
There was a problem hiding this comment.
Yep, that addresses my defense-in-depth concern — popping id by default and only allowing it for File keeps the create path safe.
Minor thought: using model is File (or an explicit allow_client_id flag) is a bit more robust than model.__name__ == "File", but the behavior you implemented is the key part. Feel free to mark this thread resolved.
There was a problem hiding this comment.
Good call to double-check, but I'll keep the string comparison. This same file already dispatches on model.__name__ elsewhere: line 951 (model.__name__ == "TypeLookup") and create_item's exempt_models = ["User", "Organization", "Token"]. Matching that existing convention seemed better than introducing a different pattern (identity comparison + a new model import) for the same kind of check in the same file. Thanks for confirming the behavior is right.
There was a problem hiding this comment.
Yep, fair — if crud_utils.py already uses model.__name__/string allowlists for similar dispatch, keeping that convention here is totally reasonable.
The main downside is it’s a bit more refactor/name-collision sensitive than model is File, but given this is an internal check and you’ve got test coverage around the File pre-generated-id path, I’m good with the string comparison.
id and nano_id were declared on the shared Base schema, inherited by every entity's Create/Update payload as well as its response. A client could send its own id (UUID squatting) or nano_id on create, or repoint either on update, since the CRUD layer only stripped project_id. Split Base (no identity) from a new ServerIdentity mixin, added only to response schemas. Create/Update schemas now silently drop id/nano_id as unrecognized fields. The CRUD layer strips nano_id on create (no internal writer ever sets one) and id+nano_id on update (identity is immutable), as defense-in-depth for callers that bypass Pydantic validation. Closes #744 Signed-off-by: Harry Cruz <harry@rhesis.ai>
The backend now ignores these fields, but the SDK was still sending them: BaseEntity.push() put id in every PUT body, and Model.push() and native metric sync sent "id": null on POST. Pop both before building the request body; id still addresses the resource via the URL on update. Signed-off-by: Harry Cruz <harry@rhesis.ai>
EndpointEditData and updateOrganization's data param both accepted id/nano_id at the type level via Partial<Entity>, though no current caller sends them. Omit both so a future caller can't spread a fetched entity straight into an update payload. Signed-off-by: Harry Cruz <harry@rhesis.ai>
Route-introspection test walks the live FastAPI route table and fails if any request body schema exposes id/nano_id, so a future entity can't reopen this by omission; it also pins that responses consumers actually read nano_id from keep exposing it. A behavioral test hits the category endpoints with spoofed id/nano_id and checks the server's own values win. Updates the SDK test that had the old PUT-with-id body locked in as expected. Signed-off-by: Harry Cruz <harry@rhesis.ai>
bbb9c65 to
82f977e
Compare
There was a problem hiding this comment.
Solid fix: splitting Base vs ServerIdentity makes it hard to accidentally re-expose server-owned identity on write, and the route-introspection test is a great guardrail.
One remaining defense-in-depth gap: _prepare_item_data() still allows id through on create for dict-based callers (even though schemas now hide it). Consider default-dropping id there too and explicitly allowing it only for the specific pre-generated-id create path(s) (e.g. FileCreate) so UUID-squatting can’t be reintroduced via a future validation-bypassing endpoint/task.
Found 1 issue (0 critical, 1 improvement).
_prepare_item_data stripped nano_id on create but left id alone entirely, reasoning that FileCreate needs to pre-generate its primary key. That left every other model's id open to a dict-based caller that bypasses Pydantic validation -- asymmetric with the update path, which already strips both fields unconditionally. Scope the exception to File specifically (the only internal caller that passes an explicit id through create_item), matching the existing exempt_models idiom in the same file. Addresses peqy review feedback on PR #2568. Signed-off-by: Harry Cruz <harry@rhesis.ai>
Purpose
API endpoints for creating and updating entities currently accept
idandnano_idfields in request bodies. Both are backend-assigned:idvia a Postgresgen_random_uuid()server default,nano_idvia a Python-side default on the SQLAlchemy base model. A client could set its ownid(UUID squatting) on create, or repoint either field on an existing row via update, since the CRUD layer only strippedproject_id. Closes #744.What Changed
apps/backend/.../schemas/base.py: split the sharedBaseschema (no identity fields, inherited by both write and read schemas today) from a newServerIdentitymixin holdingid/nano_id.ServerIdentityis added only to response schemas that need it — never to aCreate/Updateschema. This makes new entities safe by default: forgetting to mix inServerIdentityon a future entity only costs a display convenience, never reopens the vulnerability, unlike stripping the fields the other way (opt-out on write) would.apps/backend/.../utils/crud_utils.py: defense-in-depth for callers that bypass Pydantic validation — stripsnano_idon create (no internal writer ever sets one) andid+nano_idon update (identity is immutable;FileCreate's pre-generatedidis a create-only, deliberate exception documented in its schema).sdk/:BaseEntity.push(),Model.push(), and native metric sync were sendingid/nano_idin write bodies (one already asserted by an SDK test as expected behavior). Updated to pop both before building the request body.apps/frontend/: tightened two type-level holes (EndpointEditData,updateOrganization) that acceptedid/nano_idviaPartial<Entity>, though no current caller sends them.tests/backend/schemas/test_server_identity.py: route-introspection guardrail derived from the live FastAPI route table — fails if any request body schema exposesid/nano_id, and pins that the response schemas consumers actually readnano_idfrom keep exposing it.tests/backend/routes/test_server_owned_identity.py: behavioral test — POST/PUT with spoofedid/nano_idget ignored; the server's own values come back in the response.Additional Context
id/nano_idrather than return a 422. Our own SDK sends these fields in write bodies today, and a hard rejection would break any already-released SDK version for no security benefit — the fields already have no effect on the write, since the backend assigns its own values regardless of what's sent. This also matches how Stripe/GitHub handle unrecognized fields on write.Create/Updatebodies.Testing
cd apps/backend && uv run pytest ../../tests/backend/schemas/ ../../tests/backend/crud/ ../../tests/backend/routes/ ../../tests/backend/security/— 3198 passed, 40 pre-existing skips, 0 failures.cd sdk && uv run pytest ../tests/sdk/entities/test_base_entity.py ../tests/sdk/entities/test_model.py ../tests/sdk/metrics/test_metric_scope.py— 26 passed.cd apps/frontend && npx tsc --noEmit && npx eslint src/utils/api-client/interfaces/endpoint.ts src/utils/api-client/organizations-client.ts— clean./categorieswith spoofedid/nano_idin the body, asserting the response reflects the server's own values, not the caller's.