fix(agent-bff): reject unknown keys on list and count bodies - #1855
fix(agent-bff): reject unknown keys on list and count bodies#1855Tonours wants to merge 16 commits into
Conversation
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (5)
🛟 Help
|
fc77aeb to
f32fa58
Compare
f32fa58 to
4f86fcb
Compare
nbouliol
left a comment
There was a problem hiding this comment.
No blocker: the closure holds end-to-end on list, count and both relation parsers, and filter/timezone/parentId still pass. A few remarks inline, mostly about the unfolded document staying more open than the generic one.
nbouliol
left a comment
There was a problem hiding this comment.
Closure verified end-to-end on list, count and both relation parsers, and the generic document is derived from the same schemas rather than restated. Remaining remarks are about the unfolded document staying more open than the runtime, none blocking.
|
Second push, from the review passes.
Rejections are logged. The message names the right key. One published claim corrected. Not doing here, and why:
|
nbouliol
left a comment
There was a problem hiding this comment.
The five points from the previous round are fixed and verified — SortClause_${key} closed including the degraded branch, relation bodies flattened out of the allOf while keeping the foreign refs and required: ['parentId'], OverridesOf, the count break tested and documented, and the relation prose mentioning search.
The filter-tree strictness in 259dc95 is the one that landed without the document following it: the published note, the FilterLeaf_* schema, one test's premise and the Known limitations bullet all still describe the old behaviour. Details inline.
| 'of `filter` is an error rather than a silently unfiltered result. Inside the filter tree the ' + | ||
| 'check stops there: an unknown key on a condition node still reaches the agent, so a leaf ' + | ||
| 'carrying `valu` instead of `value` is not rejected here and what the agent then does with it ' + |
There was a problem hiding this comment.
assertNoStrayKey now rejects valu on a leaf with a 400, so this note says the opposite of what the runtime does — and it ships on all four bodies in both document forms. Worth rewriting alongside the Known limitations bullet that still lists the leaf typo as open?
| }); | ||
|
|
||
| it('should not forbid an unknown extra key on a filter node, which the runtime strips', () => { | ||
| it('should not forbid an unknown extra key on a filter node, which the runtime forwards', () => { |
There was a problem hiding this comment.
This locks in the pre-259dc95 contract: the runtime no longer forwards the extra key, it 400s. FilterLeaf_* and Filter_* still publish no additionalProperties: false, so a generated client sends valu and gets rejected.
| `Filter, sort and projection apply to ${quoted(foreign.collection.name)}, the foreign ` + | ||
| `collection of ${quoted(plan.collection.name)}.${quoted(relation.name)}; the parent only ` + | ||
| 'resolves which records are related.'; | ||
| `Filter, sort, projection and search apply to ${quoted(foreign.collection.name)}, the ` + |
There was a problem hiding this comment.
One description serves both bodies, so RelationCountRequest_* advertises sort and projection while body(foreign.properties.count) now closes without them — a client following the prose gets a 400. schemas.ts keeps the bare note on the count for that reason.
| page: PageInput.optional(), | ||
| search: SearchInput.optional(), | ||
| searchExtended: SearchExtendedInput.optional(), | ||
| timezone: TimezoneInput.optional(), |
There was a problem hiding this comment.
z.string() still admits "", and resolveTimezone skips empty candidates rather than rejecting them, so {"timezone": ""} falls back to the header or the default — the same silent wrong-timezone path the How section says this closes. .regex(/\S/) like ParentIdInput?
| const reason = path ? `${path}: ${issue.message}` : issue.message; | ||
|
|
||
| throw invalidRequest(path ? `${path}: ${issue.message}` : issue.message); | ||
| logger('Warn', 'Request body rejected', { reason }); |
There was a problem hiding this comment.
Only the zod path logs, so the filter-tree strays, a non-object body, filter must be an object and every parseParentId failure still reject silently — and on relation routes parseParentId runs before the schema. "Every rejected body logs a Warn" needs either the other throw sites or a narrower claim.
nbouliol
left a comment
There was a problem hiding this comment.
Eight of the ten points from the two earlier rounds are fully closed. Worth calling out that the filter-tree closure avoids the JSON Schema trap the allOf composition hit: additionalProperties: false sits inside each anyOf member rather than beside it, so it actually bites, and the published key sets match LEAF_KEYS/BRANCH_KEYS exactly.
Two are partial: the blank-timezone rejection holds on the four data routes but not on the action ones, and serializePage is the one body rejection still throwing without a log. Nothing blocking.
One line in the description to narrow: "Every request body in both document forms carries additionalProperties: false" — action bodies deliberately do not (schemas.ts:152 is a plain z.object, per the hook rationale at unfolded-paths.ts:404).
| 'Used when the X-Forest-Timezone header is absent. The header wins when both are sent. A ' + | ||
| 'deployment with no configured default rejects a request carrying neither with 400 ' + | ||
| 'missing_timezone.', | ||
| 'blank or whitespace-only string is rejected with 400 rather than silently resolving from ' + |
There was a problem hiding this comment.
These two sentences disagree: the timezone middleware runs before the data routes, so with X-Forest-Timezone: Europe/Paris and {"timezone": ""} the header does resolve and the body still 400s. Worth saying the header wins only when the body key is absent?
| relationCountOverrides, | ||
| ).openapi('RelationCountRequest', { description: CLOSED_BODY_NOTE }); | ||
|
|
||
| export const ActionRequestSchema = z |
There was a problem hiding this comment.
The Timezone component is reused here, but action bodies are never zod-parsed — action-routes-middleware.ts:191 reads them by cast — so {"timezone": ""} still resolves from the header or the default on /actions/*, which is what its description now says cannot happen.
| @@ -31,7 +34,7 @@ const ConditionTreeSchema: z.ZodType = z | |||
| .lazy(() => | |||
| z.union([ | |||
There was a problem hiding this comment.
agent-query.ts:82 keeps filter: {} allowed on purpose, but it matches neither union member, so a generated client refuses a body the BFF accepts. An empty-object member, or dropping the runtime allowance?
| const LEAF_KEYS = ['field', 'operator', 'value']; | ||
| const BRANCH_KEYS = ['aggregator', 'conditions']; | ||
|
|
||
| function rejectBody(logger: Logger, error: BffHttpError): never { |
There was a problem hiding this comment.
serializePage at :143 is the one body rejection still throwing without this, so Observability's "covers every throw site" is one short. It is also the only message carrying submitted values, so routing it through here needs the message reworded first.
|
|
||
| export function parseListRequest(body: unknown): ListRequestBody { | ||
| if (!isPlainObject(body)) throw invalidRequest('Request body must be an object'); | ||
| function parseRequest<S extends ZodType>(schema: S, body: unknown, logger: Logger): z.output<S> { |
There was a problem hiding this comment.
logger is now threaded through six functions, including the exported parseParentId, only to emit one fixed message. A try/catch here and in the two relation parsers logging error.message would remove all six parameters, keep the exported signature, and would have caught the serializePage site.
| export const SearchExtendedInput = z.boolean(); | ||
|
|
||
| export const TimezoneInput = z.string(); | ||
| export const TimezoneInput = z.string().regex(/\S/); |
There was a problem hiding this comment.
/\S/ is now the second inline non-blank rule in this file, and it is hard-coded twice more as '\\S' in unfolded-paths.ts. One named NonBlankString would carry the rule and one message.
| ); | ||
|
|
||
| it.each(FLAT_PARSERS)( | ||
| 'should reject a blank timezone on %s rather than silently resolve another one', |
There was a problem hiding this comment.
This asserts the same {type, status} as the non-string test above, so it cannot tell a blank-timezone rejection from any other 400 — worth asserting the reason names timezone.
Keep strict-body openapi tests from this branch, add page-optional and ai-query coverage from main, and align the 415 prose test with #1856.
21d1aec to
3fd09c6
Compare

What
List and count bodies reject an undeclared top-level key with 400
invalid_request. Same for a stray key inside asortclause,page, or a filter node.{"filters": {...}}Unrecognized key: "filters"{"projections": ["id"]}{"sort": [{"field": "a", "direciton": "desc"}]}{"parentId": "7"}on a plain list{"projection": [...]}on a count{"filter": ..., "timezone": ..., "parentId": ...}fixes PRD-1098
Why
The bodies were parsed with plain zod objects, which strip unknown keys.
filtersinstead offilterwas dropped silently, so the query ran unfiltered and answered 200: a typo returned the wrong rows with a success status. The TS client protects nobody using curl, plain JS or an LLM-built body, and that is this BFF's audience.How
Every request object is
z.strictObject.filterandparentIdare declared but not re-validated: each already has its own validator, and a second rule would only disagree with the first at the margins.timezoneis typed,z.string().regex(/\S/).optional(). It cannot disagree withresolveTimezone, which is strictly stricter — it needs a non-empty valid IANA name and rejects the rest with its own error. What the type adds is a 400 for a non-string, and that case mattered:bodyTimezonereturnsundefinedfor anything that is not a string, sotimezone: nullused to be dropped and the request resolved from the header or the deployment default. A blank used to take the same silent path from the other side —resolveTimezoneskips empty candidates rather than rejecting them — so the regex refuses one instead. A date filter (Today,PreviousXDays) then ran in the wrong timezone, in a 200, with no trace.The published components are derived from those same schemas, with a
satisfies OverridesOf<...>clause that refuses a decoration for a key the runtime does not accept. The document cannot advertise a shape the parser rejects. Body types arez.inferof the same schemas.The document closes too, structurally
Every request body in both document forms carries
additionalProperties: false, including the per-collectionSortClause_${key}and every leaf and branch of the filter tree — a generated client now refusesdirecitonorvalubefore the request leaves.Getting there meant dropping the
allOfcomposition on relation bodies:additionalProperties: falseon one branch forbids the key the other adds, andoas31has nounevaluatedPropertiesto lift that. Relation bodies now spread the foreign collection's properties into one flat closed object withparentId, which is whatschemas.tsalready did withRelationListFlatInputs.extend(...).Breaking
A stray top-level key now draws a 400 where it used to give a silently different result set. The full surface, on all four routes (list, count, relation list, relation count):
sortelement or insidepage;timezone(nulland""included), which used to be silently dropped or skipped;parentIdon a plain list or count, where a parent id means nothing;projection,sortorpageon a count or a relation count, where paging and projection mean nothing.The last two are the likely ones in practice: a client reusing one body builder across route families used to have the extra keys stripped.
Observability
Every body the parser rejects logs a
Warncarrying the reason — the offending key name, never a submitted value, which a test asserts. That covers every throw site, not only the zod one: a non-object body, a non-objectfilter, a stray key anywhere inside the filter tree, and a badparentId, which on relation routes is parsed before the schema. Without it this contract change would ship blind:BffHttpErroris serialized and returned beforeerror-middlewarereaches its logger, and there is no access log, so nobody would see a client that has been sendingfiltersfor six months.When several rules fail at once, the message names the unrecognized key rather than whichever issue zod happened to list first —
{"filters": {}, "page": {"limit": 0}}used to answerpage.limit: Too small, sending the caller to fix the wrong thing.Known limitations
One sibling of the same bug stays open, worth a ticket:
valuesmisspelled on an action body: the key is read with a cast, not parsed, sovaleusexecutes the action with no submitted values at all.PRD-1099 — a non-JSON content type makes
@koa/bodyparsersetctx.request.body = {}, so afetchPOST with noContent-Typeheader loses the whole body and answers 200 unfiltered. That one is the same symptom as this ticket one layer up, and it is fixed in #1856.Stale claim spotted, not rewritten here: the
ConditionTreedescription says relation list and count forward the filter without checks, while both checks run.How to test
By hand, POST a list body with
filtersinstead offilter: 400 naming the key, where it used to return every row.Definition of Done
General
Security