Skip to content

Commit 59768f7

Browse files
os-helpclaude
andauthored
fix(rest): refuse unknown query parameters on GET /approvals/requests (#7527) (#7607)
`?assignedToMe=true` answered 200 with every request the caller could see — the handler read the keys it knew and dropped the rest, so a caller who believed they asked for "the requests assigned to me" got the unfiltered list and could not tell, because an unfiltered result is shaped exactly like a genuinely broad match. The route now declares a closed parameter set, measured from the handler's own reads (five filters, `q`, the paging pair, and the snake_case aliases it honours), and refuses anything outside it with a located 400 carrying the ADR-0112 nested envelope — the same position and code the repeated-parameter refusal on this same handler already answers with. The message names the unrecognised parameters and lists the supported ones. `assignedToMe` is refused rather than implemented: the Console already asks this question as `approverId=<id>,role:user`, so a second spelling would be surface with no pull behind it. Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy Co-authored-by: Claude <noreply@anthropic.com>
1 parent 61ea810 commit 59768f7

4 files changed

Lines changed: 489 additions & 0 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): `GET /approvals/requests` refuses an unknown query parameter instead of returning every request (#7527)
6+
7+
`GET /api/v1/approvals/requests?assignedToMe=true` answered **200 with every
8+
request the caller could see**. The handler read the keys it knew off the query
9+
string and ignored the rest, so a caller who believed they had asked for "the
10+
requests assigned to me" was handed the **unfiltered** list — and could not
11+
tell, because an unfiltered result is shaped exactly like a genuinely broad
12+
match. No status, header or field distinguished "your filter matched
13+
everything" from "your filter was thrown away".
14+
15+
That is the anti-pattern #7463's defect 2 names, pointed the other way: there an
16+
unrecognised key silently NARROWS to zero, here an unrecognised parameter
17+
silently WIDENS to everything. Both are the server accepting a request it does
18+
not understand and answering with something plausible.
19+
20+
**The route now declares a closed parameter set** and refuses anything outside
21+
it with a located `400` — the ADR-0112 nested envelope
22+
(`{ error: { code: 'VALIDATION_ERROR', message } }`), the same position and code
23+
the repeated-parameter refusal on this same handler already answers with. The
24+
message names the parameters that were not understood and lists every one that
25+
is supported, so a caller can fix the request from the response alone.
26+
27+
The closed set was measured from the handler's own reads, not from the filter
28+
list: the five filters (`object`, `recordId`, `status`, `approverId`,
29+
`submitterId`), the free-text `q`, the paging pair (`limit`, `offset`), and the
30+
`snake_case` alias spellings the handler honours. Paging is inside the set on
31+
purpose — a whitelist built from the filters alone would have traded a silent
32+
widening bug for a loud paging outage.
33+
34+
**`assignedToMe` is refused, not implemented.** The capability it reaches for
35+
already exists and is already reachable: the Console asks exactly this question
36+
as `approverId=<id>,role:user`, which the `approverId` multi-identity arm was
37+
built for. A second spelling for a question that already has one is surface with
38+
no pull behind it, and it would have to be carried forever; refusing costs one
39+
error path and makes every future typo self-reporting.
40+
41+
**If you were sending `assignedToMe`** — nothing was ever honouring it, so no
42+
filtering behaviour changes; the request that used to return everything now
43+
returns a `400` telling you to use `approverId`. Every parameter the endpoint
44+
actually reads is unaffected, including the unparameterised call that returns
45+
the full list.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Query-parameter RECOGNITION, for the routes that declare a closed parameter
5+
* set (#7527).
6+
*
7+
* The sibling rule in `query-multiplicity.ts` answers "how many times may a
8+
* parameter I understand be supplied". This one answers the question before
9+
* it: **do I understand this parameter at all**.
10+
*
11+
* ## The defect this exists for
12+
*
13+
* `GET /api/v1/approvals/requests?assignedToMe=true` answered **200 with every
14+
* request the caller can see**. The handler reads the keys it knows off the
15+
* query string and ignores the remainder, so a caller who believes they asked
16+
* for "the requests assigned to me" is handed the UNFILTERED list — and cannot
17+
* tell, because an unfiltered result is shaped exactly like a genuinely broad
18+
* one. There is no status code, no header and no field that distinguishes
19+
* "your filter matched everything" from "your filter was thrown away".
20+
*
21+
* That is the same anti-pattern as #7463's defect 2 (an unknown field inside
22+
* `where` answers 200/0 instead of a located `400`), pointed the other way:
23+
* there an unrecognised key silently NARROWS to zero, here it silently WIDENS
24+
* to everything. Both are the server accepting a request it does not
25+
* understand and returning a plausible-looking answer — the #5714 / #5931 /
26+
* #7463 family norm is to refuse instead of guessing.
27+
*
28+
* ## Why refusal, and not an alias
29+
*
30+
* The obvious-looking alternative — teach the endpoint what `assignedToMe`
31+
* means — is surface expansion with no pull behind it. The capability already
32+
* exists and is already reachable: the Console asks precisely this question as
33+
* `approverId=<id>,role:user`, which the `approverId` multi-identity arm was
34+
* built for. Adding a second spelling for a question that already has one buys
35+
* nothing and has to be carried forever. Refusing costs one error path and
36+
* makes every FUTURE typo self-reporting.
37+
*
38+
* ## Why a whitelist rather than a blacklist of known-bad names
39+
*
40+
* The bug is not `assignedToMe` specifically. `assigned_to_me`, `assignee`,
41+
* `mine`, `approver` and every other plausible-but-wrong spelling fails the
42+
* identical way, silently, and a blacklist can only ever name the ones someone
43+
* already tripped over. A closed set is the only shape with no escape valve
44+
* (#5794: one fix, no escape hatch).
45+
*
46+
* ⚠️ The closed set must be MEASURED from what the handler actually reads —
47+
* filters, paging, ordering, and any alias spelling it honours — not from the
48+
* filters alone. A whitelist that forgets `limit` / `offset` converts a
49+
* silent-widening bug into a loud paging outage, which is worse.
50+
*
51+
* ## The envelope
52+
*
53+
* `400` with the ADR-0112 **nested** body `{ error: { code, message } }` —
54+
* byte-identical in position and code to the multiplicity refusal that runs on
55+
* the same handler, so one route never answers two dialects for two flavours
56+
* of "this request is malformed". `VALIDATION_ERROR` is the standard catalog's
57+
* member for 400 (`spec/src/api/errors.zod.ts`); nothing in `packages/spec`
58+
* moves for this.
59+
*/
60+
61+
/**
62+
* The one refusal message for unrecognised query parameters, so every route
63+
* adopting this rule answers it identically.
64+
*
65+
* The message is LOCATED in both directions a caller needs: it names the
66+
* parameters that were not understood, and it lists the ones that are — so a
67+
* caller who guessed wrong can fix the request from the response alone,
68+
* without reading our source or our docs.
69+
*
70+
* @param unknown the unrecognised names, in the order they should be
71+
* reported (the caller sorts them for determinism)
72+
* @param supported every name this route accepts, sorted
73+
*/
74+
export function unknownQueryParamMessage(
75+
unknown: readonly string[],
76+
supported: readonly string[],
77+
): string {
78+
const subject = unknown.length === 1
79+
? `The "${unknown[0]}" query parameter is not supported by this endpoint.`
80+
: `The query parameters ${unknown.map(n => `"${n}"`).join(', ')} are not supported by this endpoint.`;
81+
return `${subject} This endpoint will not silently ignore a parameter it does not `
82+
+ `understand — an ignored filter is indistinguishable from one that matched everything. `
83+
+ `Supported parameters: ${supported.join(', ')}.`;
84+
}
85+
86+
/**
87+
* Refuse a request carrying any query parameter outside this route's closed
88+
* set. Returns `true` when it answered the request — the caller `return`s
89+
* immediately, exactly like {@link refuseRepeatedQueryParams} and the
90+
* capability gates it sits beside.
91+
*
92+
* Runs BEFORE the multiplicity rule on purpose: "I do not know this parameter"
93+
* outranks "this parameter I do know was supplied twice", so a request that
94+
* commits both errors gets the answer that explains the more fundamental one.
95+
*
96+
* @param req the handler's request (`IHttpRequest`-shaped; `any` because
97+
* `rest-server.ts` types its handlers that way)
98+
* @param res the handler's response
99+
* @param allowed every parameter name this route accepts — including paging,
100+
* ordering and alias spellings, not only filters
101+
*/
102+
export function refuseUnknownQueryParams(
103+
req: any,
104+
res: any,
105+
allowed: readonly string[],
106+
): boolean {
107+
const query = req?.query;
108+
if (!query || typeof query !== 'object') return false;
109+
const permitted = new Set(allowed);
110+
// Sorted so a request carrying several unknown names always produces the
111+
// same message, whatever order the adapter happened to build the object in.
112+
const unknown = Object.keys(query).filter(k => !permitted.has(k)).sort();
113+
if (unknown.length === 0) return false;
114+
res.status(400).json({
115+
error: {
116+
code: 'VALIDATION_ERROR',
117+
message: unknownQueryParamMessage(unknown, [...allowed].sort()),
118+
},
119+
});
120+
return true;
121+
}

0 commit comments

Comments
 (0)