Skip to content

fix(agent-bff): reject unknown keys on list and count bodies - #1855

Open
Tonours wants to merge 16 commits into
mainfrom
fix/prd-1098-strict-request-bodies
Open

fix(agent-bff): reject unknown keys on list and count bodies#1855
Tonours wants to merge 16 commits into
mainfrom
fix/prd-1098-strict-request-bodies

Conversation

@Tonours

@Tonours Tonours commented Aug 28, 2026

Copy link
Copy Markdown
Member

What

List and count bodies reject an undeclared top-level key with 400 invalid_request. Same for a stray key inside a sort clause, page, or a filter node.

Body Before After
{"filters": {...}} 200, all rows 400 Unrecognized key: "filters"
{"projections": ["id"]} 200, all fields 400
{"sort": [{"field": "a", "direciton": "desc"}]} 200, sorted ascending 400
{"parentId": "7"} on a plain list 200, ignored 400
{"projection": [...]} on a count 200, ignored 400
{"filter": ..., "timezone": ..., "parentId": ...} 200 200, unchanged

fixes PRD-1098

Why

The bodies were parsed with plain zod objects, which strip unknown keys. filters instead of filter was 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. filter and parentId are declared but not re-validated: each already has its own validator, and a second rule would only disagree with the first at the margins.

timezone is typed, z.string().regex(/\S/).optional(). It cannot disagree with resolveTimezone, 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: bodyTimezone returns undefined for anything that is not a string, so timezone: null used 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 — resolveTimezone skips 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 are z.infer of the same schemas.

The document closes too, structurally

Every request body in both document forms carries additionalProperties: false, including the per-collection SortClause_${key} and every leaf and branch of the filter tree — a generated client now refuses direciton or valu before the request leaves.

Getting there meant dropping the allOf composition on relation bodies: additionalProperties: false on one branch forbids the key the other adds, and oas31 has no unevaluatedProperties to lift that. Relation bodies now spread the foreign collection's properties into one flat closed object with parentId, which is what schemas.ts already did with RelationListFlatInputs.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):

  • any undeclared top-level key;
  • an undeclared key inside a sort element or inside page;
  • a non-string or blank timezone (null and "" included), which used to be silently dropped or skipped;
  • parentId on a plain list or count, where a parent id means nothing;
  • projection, sort or page on 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 Warn carrying 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-object filter, a stray key anywhere inside the filter tree, and a bad parentId, which on relation routes is parsed before the schema. Without it this contract change would ship blind: BffHttpError is serialized and returned before error-middleware reaches its logger, and there is no access log, so nobody would see a client that has been sending filters for 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 answer page.limit: Too small, sending the caller to fix the wrong thing.

Known limitations

One sibling of the same bug stays open, worth a ticket:

  • values misspelled on an action body: the key is read with a cast, not parsed, so valeus executes the action with no submitted values at all.

PRD-1099 — a non-JSON content type makes @koa/bodyparser set ctx.request.body = {}, so a fetch POST with no Content-Type header 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 ConditionTree description says relation list and count forward the filter without checks, while both checks run.

How to test

yarn workspace @forestadmin/agent-bff test && yarn workspace @forestadmin/agent-bff lint

By hand, POST a list body with filters instead of filter: 400 naming the key, where it used to return every row.

Definition of Done

General

  • Write an explicit title for the Pull Request, following Conventional Commits specification
  • Test manually the implemented changes
  • Validate the code quality (indentation, syntax, style, simplicity, readability)

Security

  • Consider the security impact of the changes made

@linear-code

linear-code Bot commented Aug 28, 2026

Copy link
Copy Markdown

PRD-1098

@qltysh

qltysh Bot commented Aug 28, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (5)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent-bff/src/data/request-schemas.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/openapi/unfolded-paths.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/data/agent-query.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/openapi/schemas.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/data/data-routes-middleware.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@Tonours
Tonours force-pushed the fix/prd-1098-strict-request-bodies branch from fc77aeb to f32fa58 Compare August 31, 2026 09:12
Comment thread packages/agent-bff/src/openapi/unfolded-paths.ts Outdated
@Tonours
Tonours force-pushed the fix/prd-1098-strict-request-bodies branch from f32fa58 to 4f86fcb Compare August 31, 2026 09:14

@nbouliol nbouliol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/agent-bff/src/openapi/unfolded-paths.ts Outdated
Comment thread packages/agent-bff/src/openapi/unfolded-paths.ts Outdated
Comment thread packages/agent-bff/src/openapi/unfolded-paths.ts Outdated
Comment thread packages/agent-bff/src/openapi/schemas.ts Outdated
Comment thread packages/agent-bff/test/data/agent-query.test.ts

@nbouliol nbouliol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Tonours

Tonours commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

Second push, from the review passes.

timezone is typed now. The PR said re-typing it would turn a tolerated null into a 400, and that reasoning was wrong: z.string() cannot disagree with resolveTimezone, which is strictly stricter — it needs a non-empty valid IANA name and rejects everything else with its own error. All the type adds is a 400 for a non-string, and that case is not benign. bodyTimezone (timezone-middleware.ts:18) returns undefined for anything that is not a string, so timezone: null was dropped and the request resolved from the header or the deployment default. A Today or PreviousXDays filter then ran in the wrong timezone — rows around midnight in or out of the result — in a 200, with no trace. That is the same class of bug as filters, which is what this PR is about. The test that pinned the old behaviour was named "should leave a null timezone to the middleware"; the middleware never handled it, it discarded it.

Rejections are logged. BffHttpError is serialized and returned before error-middleware reaches its logger, and there is no access log — so this contract change would have shipped with no way to see it working. Every rejected body now logs a Warn with the reason. The reason is a key name, never a submitted value, and a test asserts that.

The message names the right key. assertFlatInputs took issues[0], and zod does not order issues by relevance: {"filters": {}, "page": {"limit": 0}} answered page.limit: Too small, so the caller fixed their page, replayed, and got a second 400 for the typo that actually changed their results. It now prefers the unrecognized_keys issue.

One published claim corrected. CLOSED_BODY_NOTE said a leaf carrying valu "runs with no value". Optimistic — the leaf is built with value: undefined, TypeGetter reads it as null, FieldValidator accepts it, and query-converter emits { [Op.eq]: undefined }. What comes back is datasource-dependent. The note now says the contract does not define it.

Not doing here, and why:

@nbouliol nbouliol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +99 to +101
'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 ' +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ` +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 nbouliol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines 64 to +65
'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 ' +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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([

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/\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',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@Tonours
Tonours force-pushed the fix/prd-1098-strict-request-bodies branch from 21d1aec to 3fd09c6 Compare September 2, 2026 16:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants