Skip to content

Commit 67cf0b1

Browse files
fix(runtime)!: the dispatcher's GET /data/:object/:id refuses an unknown query parameter as RestServer does (#26947)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XF3QFJAFuZhRRxEVZmmSGCV)_ Fixes #26898 Clause-②: yes (narrowing) Metadata-protocol: none Skills-docs: updated here — `content/docs/api/data-api.mdx` (the by-id read's parameter sentence said "all other parameters are discarded"); no `skills/**` file teaches this route's query set ## 维护者速读 - **改了什么**:运行时 dispatcher 的 `GET /data/:object/:id` 遇到 `select` / `expand` 以外的查询参数,改为 `400 VALIDATION_ERROR` 拒绝,与 RestServer 同状态、同 code、同一句文案;两边读同一个参数集 `DATA_RECORD_READ_PARAMS`(由 `@objectstack/rest` 导出)。 - **为什么改**:同一个拼错的参数(`?selct=name`)在 RestServer 上被拒,在 dispatcher 上被静默丢掉并返回全部列,客户端得不到任何提示。 - **风险与回滚**:原先被容忍的多余参数现在会 400(只影响 dispatcher 承载的按 id 读取);官方 SDK 的 `data.get` 不带查询参数,不受影响。回滚 = revert 本 PR。 - **先看**:`packages/runtime/src/domains/data.ts`(按 id 读取分支的新拒绝与门序)、`packages/runtime/src/domains/data-record-read-status-parity.test.ts`(两宿主对拍)、`packages/rest/src/index.ts`(新增的一个导出)。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/data-record-read-status-parity.test.ts`,预期 19/19 通过,其中 `?nope=1` 各例两宿主都是 400。 - **需要你做的**:无,席位落地。 ## What changes - **The dispatcher's by-id read asks the route's closed set.** `packages/runtime/src/domains/data.ts`: after the object's API exposure gate (`refuseUnexposedDataAction(..., 'get', ...)`, where `RestServer` asks `enforceApiAccess`), the branch asks `unknownQueryParamRefusalMessage(query, DATA_RECORD_READ_PARAMS)` and answers any name outside `select` / `expand` `400 VALIDATION_ERROR` in the dispatcher's envelope, the same sentence `RestServer`'s `refuseUnknownQueryParams` writes. It used to drop those names and serve the read (`200`). The refusal comes before the read, so it is one answer for an existing record, a missing id and a row the caller may not read. - **Where the one set lives, and why.** `DATA_RECORD_READ_PARAMS` stays where it was declared (`packages/rest/src/rest-server.ts`, #7606 / #8039 docblock) and `@objectstack/rest` now exports it from its entry. `@objectstack/runtime` already depends on `@objectstack/rest` (`packages/runtime/package.json`) and `data.ts` already imports two `/data` rules from it (`assertFilterParamSuppliedOnce`, `repeatedQueryParamMessage`); `@objectstack/rest` does not depend on `@objectstack/runtime`. So no new dependency edge and no cycle. `packages/spec/**` is untouched. The precedent is #23072 (`metaItemQueryRefusal`) and #26020 (`unknownQueryParamRefusalMessage`): a closed-set decision `RestServer` owns, exported for the dispatcher's twin. - **Docs.** `content/docs/api/data-api.mdx` said the by-id read discards other parameters; it now says they are refused, naming them. - **Changeset.** `patch` on `@objectstack/runtime` and `@objectstack/rest`, `fix(runtime)!:`, BREAKING banner, ADR-0087 `not-required (no-migration-prescription)`. **Clause-② measured on the diff.** The claim carried `no (narrowing)` as likely. The diff narrows one door's accept set (the `(narrowing)` arm) and also adds one name to `@objectstack/rest`'s entry, which the criterion counts as a wider public surface ("本卡放宽接受集或扩大公开面吗", `references/execution-duties.md`). So the measured line is `yes (narrowing)`, the line #23072's PR (8024e12) declared for the same shape. If the seat reads a re-exported constant as not widening the surface, the line becomes `no (narrowing)` and nothing else changes. ## Tests - **Red first, on the base `57820085fa`** (test commit only, `data.ts` at base): `data-record-read-status-parity.test.ts` 6 failed / 13 passed. Every failure was the dispatcher assertion: `?nope=1`, `?select=title&nope=1`, `?fields=title`, `?nope=1&selct=title` on the own record answered `200`; `?nope=1` on the hidden row and the missing id answered `404`. `RestServer` answered all six `400 VALIDATION_ERROR`. - **Green after the fix**: 19 / 19. Both hosts answer the six cases `400 VALIDATION_ERROR` with the same sentence. Controls kept on both hosts: `?select=title` and `?expand=parent` on the own record `200`, a missing id `404`, a hidden row `404`, a hidden row `?select=title` `404`, the #26816 `400` cases unchanged. - **Gate order, dispatcher pin**: `?nope=1` on an object with `apiEnabled: false` answers `404 OBJECT_API_DISABLED`, the same answer as without the parameter. Pinned on the dispatcher alone: this rig's `RestServer` has no metadata store, so its exposure gate falls open there (measured: a plain read of that object answered `404 RECORD_NOT_FOUND`) and would not measure the order. `RestServer`'s order is `enforceApiAccess` before `refuseUnknownQueryParams` in `crud-routes.ts`. - **Ablation** (`scripts/ablation-replace.mjs`, `if (unknownParams !== undefined)` → `if (false && ...)`, blob `a351a771` → `992a9a63`): the six card cases red, 13 passed; restored, blob == HEAD and `git diff HEAD` empty. The test reaches `data.ts` through a relative source import, so no build sits between the mutation and the run. - **Package suites** (under the verify lock): `@objectstack/runtime` test 1 failed / 462 files on the first run: `http-dispatcher.test.ts` pinned the old drop ("should NOT pass non-allowlisted params"). That pin now asserts the refusal (`400 VALIDATION_ERROR`, the sentence naming `filter` and `malicious`, `getData` not asked): `http-dispatcher.test.ts` 248 / 248. `@objectstack/runtime` typecheck 0, `@objectstack/rest` test 329 / 329 files (6525 passed), typecheck 0. - **Lint** on the touched files: `eslint --no-inline-config` exit 0. ## Acceptance notes - **The dispatcher's other `/data` routes** (`route-ledger.ts` rows), each against its `RestServer` twin. None has a declared closed set on the `RestServer` side, so none is fixed here. - `GET /data/:object` (list): open by design on both hosts; a leftover key is an implicit field filter and an unknown field is refused `400 INVALID_FIELD` one layer down (`query-allowlist.ts` "do not add this gate"). - `POST /data/:object/query`, `POST /data/:object`, `PATCH /data/:object/:id`: neither host reads the querystring; an unknown parameter is ignored on both. No split. - `DELETE /data/:object/:id`: both read `expectedVersion` (once, arity-gated on both) and ignore every other name. No split. - Closing those four would be a new closed set on both hosts at once, a #7606-policy question, not this card's parity defect. - **An observation, not filed.** A host whose `/data` is answered by the dispatcher alone (no `RestServer`) has no `GET /data/:object/export` route, so that path reaches the by-id branch with id `export`. It answered `404 RECORD_NOT_FOUND` before; with `?format=csv` it now answers this route's `400` naming `format`. Both are refusals of a route that host does not serve; not measured on a live host. - **The wire carries no `param` key** on either host for this refusal: `RestServer` writes `{ error: { code, message } }`, the dispatcher `{ success: false, error: { code, message } }`. The refused names are in the sentence, identical on both (the rig asserts the messages equal). ## Verification All on the final head `f5f4f537`. <details><summary>Derived gate families: 89 derived, 89 run, all exit 0</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 89 families over the 6 changed paths (merge base `57820085f`). Each was run with its exit code captured before any pipe; `--ran` reconciles: "89 derived famil(ies) accounted for — 89 run, 0 NOT-MEASURED (a DERIVED zero — all 89 recorded an exit code and none of them is 3)". - Four first answered `exit 3` (prerequisite, nothing measured) and were re-run to `exit 0`: `check-plugin-teardown-shape --self-test` (after `git fetch --depth=1` of its pinned fixture commit), `check:type-check-debt` (run under the verify lock: "126/129 workspace packages type-checked, 0 in the DEBT ledger"), `check:skill-examples` and `check:dual-build-cjs-loads` (after that gate's closure build: "112 published require entry point(s) across 69 package(s) load"). - Re-run after the last commit (a test-file change): `check:test-source-alias`, `check:cross-package-test-inputs`, `check:engine-double-contract`, `check:tier-file-adoption`, `check:rest-log-spy-declared`, `check:registry-log-declared`, `check:nul-bytes`, `check:issue-citations`, `check:type-check-debt`: all exit 0. - Scoped lint: `eslint --no-inline-config` on the four touched source and test files, exit 0. The repo-wide `pnpm lint` is CI's. - `test:repo` is not run locally (CI's `Test Core` runs it); the diff touches no file of that project. </details> <details><summary>Dogfood battery (the narrowing arm): 334 / 334 files at f5f4f53</summary> - Closure built with `pnpm --filter '@objectstack/dogfood^...' build`, then `pnpm --filter @objectstack/dogfood test` under the verify lock, no shard, no path, on `f5f4f537`. - `os-verify-lock: VERDICT command-exit 0 · held the lock 1748s`; `Test Files 334 passed | 3 skipped (337)`; `Tests 2716 passed | 20 skipped (2736)`. No failure. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5oqbhjkiqCJ8i3DzzU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 81b4599 commit 67cf0b1

6 files changed

Lines changed: 109 additions & 16 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/rest': patch
4+
---
5+
6+
fix(runtime)!: the runtime dispatcher's `GET /data/:object/:id` refuses a query parameter other than `select` / `expand` with `400 VALIDATION_ERROR`, as `RestServer` does, instead of dropping it and answering `200`
7+
8+
Clause-②: yes (narrowing) — the dispatcher's by-id read refuses a query parameter it dropped before, and `@objectstack/rest` gains one export (`DATA_RECORD_READ_PARAMS`) so both hosts read one set.
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed. `@objectstack/rest` gains one export, `DATA_RECORD_READ_PARAMS`, the closed set its own route already asked. What narrows is one runtime door's query accept set: a parameter the dispatcher's by-id read dropped now answers a located `400 VALIDATION_ERROR`, and the remedy is in the request a caller sends, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
12+
13+
**BREAKING** accept-set narrowing at one runtime dispatcher door, the by-id record read served by the `@objectstack/hono` catch-all and `dispatcher-plugin`.
14+
15+
- **Before.** `GET /api/v1/data/:object/:id?selct=name` answered `200` with every column on the dispatcher: the branch kept `select` and `expand` and silently dropped every other name. `RestServer` refused the same request `400 VALIDATION_ERROR`, so a typo was ignored on one host and refused on the other.
16+
- **Now.** The dispatcher asks the route's closed set, `DATA_RECORD_READ_PARAMS`, imported from `@objectstack/rest`, the same set `RestServer` asks, and refuses any other name `400 VALIDATION_ERROR` with `RestServer`'s sentence: it names each refused parameter and lists `expand, select`. The refusal comes before the read, so it is the same answer for an existing record, a missing id and a row the caller may not read.
17+
- **Unchanged.** `select` and `expand` are read as before. An object the API does not expose is answered its exposure refusal first, before the query is judged: the dispatcher's `404 OBJECT_API_DISABLED` with or without the unknown name, and `RestServer` likewise asks its closed set only after `enforceApiAccess`. The other `/data` routes are not touched.
18+
19+
**The remedy.** Send only `select` and `expand` to the by-id read. `fields` and `populate` are not synonyms on this route (#8039): spell them `select` and `expand`.

‎content/docs/api/data-api.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ raw rows** with nothing to say the aggregation never happened.
212212

213213
### `GET /data/:object/:id`
214214

215-
Get a single record by ID. Only `select` and `expand` query parameters are allowed; all other parameters are discarded.
215+
Get a single record by ID. Only `select` and `expand` query parameters are allowed; any other parameter is refused `400 VALIDATION_ERROR`, and the message names it and lists the two accepted ones.
216216

217217
| Parameter | Location | Description |
218218
|:----------|:---------|:------------|

‎packages/rest/src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ export { assertFilterParamSuppliedOnce, refuseRepeatedQueryParams, repeatedQuery
8181
// invitation preview) — the same reason `repeatedQueryParamMessage` travels.
8282
export { unknownQueryParamRefusalMessage } from './query-allowlist.js';
8383

84+
// [#26898] The closed set of `GET /data/:object/:id` (#7606), published so the
85+
// runtime dispatcher's twin asks THIS set rather than a copy of it: both hosts
86+
// refuse a name outside it with the one sentence above.
87+
export { DATA_RECORD_READ_PARAMS } from './rest-server.js';
88+
8489
// [#20193] THE per-caller read gate of one `/meta/:type/:name` document — the
8590
// ADR-0046 §6.7 docs audience, the app nav filter (`requiredPermissions`, the
8691
// ADR-0045 §3 publish gate, the docs-audience entry arm) and the ADR-0057 D10

‎packages/runtime/src/domains/data-record-read-status-parity.test.ts‎

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
* their own readers (`declaredEnvelopeForThrow` for `@objectstack/hono`'s
2121
* catch-all, `resolveThrownHttpError` for `dispatcher-plugin`), ⛔ not
2222
* restated — the rig of `data-empty-projection-count.test.ts`.
23+
*
24+
* [#26898] …and an unknown query parameter is one answer too. Measured on the
25+
* base (`57820085fa`): `RestServer` refused `?nope=1` `400 VALIDATION_ERROR`
26+
* through the route's closed set (#7606, `DATA_RECORD_READ_PARAMS`), the
27+
* dispatcher dropped it and answered `200` with the full record. Both now ask
28+
* that one set and answer the same status, code and sentence.
2329
*/
2430

2531
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
@@ -36,6 +42,9 @@ import { installDefaultOrganization } from '../default-organization.testkit.js';
3642

3743
const PREFIX = '/api/v1';
3844
const OBJECT = 'by_id_parity_26816';
45+
// [#26898] An object the API does not expose, for the closed set's place in
46+
// the gate order: after the exposure gate, on both hosts.
47+
const UNEXPOSED = 'by_id_parity_26898_unexposed';
3948
const ME = 'usr_26816_member';
4049
const OTHER = 'usr_26816_other';
4150
const SYS_CTX = { isSystem: true, userId: 'usr_system' };
@@ -72,7 +81,16 @@ const CASES: ReadonlyArray<readonly [string, string, Record<string, string>, Omi
7281
['CONTROL hidden row', HIDDEN, {}, { status: 404, code: 'RECORD_NOT_FOUND' }],
7382
['CONTROL hidden row ?select=title', HIDDEN, { select: 'title' }, { status: 404, code: 'RECORD_NOT_FOUND' }],
7483
['CONTROL own record ?select=title', MINE, { select: 'title' }, { status: 200 }],
84+
['CONTROL own record ?expand=parent', MINE, { expand: 'parent' }, { status: 200 }],
7585
['CONTROL own record', MINE, {}, { status: 200 }],
86+
// [#26898] The route's closed parameter set (#7606), asked by both hosts:
87+
// a name outside it is refused before the read, whichever id it names.
88+
['own record ?nope=1 (the card)', MINE, { nope: '1' }, { status: 400, code: 'VALIDATION_ERROR' }],
89+
['own record ?select=title&nope=1', MINE, { select: 'title', nope: '1' }, { status: 400, code: 'VALIDATION_ERROR' }],
90+
['own record ?fields=title (the #8039 alias)', MINE, { fields: 'title' }, { status: 400, code: 'VALIDATION_ERROR' }],
91+
['own record ?nope=1&selct=title', MINE, { nope: '1', selct: 'title' }, { status: 400, code: 'VALIDATION_ERROR' }],
92+
['hidden row ?nope=1', HIDDEN, { nope: '1' }, { status: 400, code: 'VALIDATION_ERROR' }],
93+
['missing id ?nope=1', NOWHERE, { nope: '1' }, { status: 400, code: 'VALIDATION_ERROR' }],
7694
];
7795

7896
function createMockServer() {
@@ -92,8 +110,8 @@ function makeRes() {
92110

93111
describe('[#26816] GET /data/:object/:id — RestServer and the dispatcher give one answer', () => {
94112
let engine: ObjectQL;
95-
let rest: (id: string, query: Record<string, string>) => Promise<Answer>;
96-
let dispatched: (id: string, query: Record<string, string>) => Promise<Answer>;
113+
let rest: (id: string, query: Record<string, string>, object?: string) => Promise<Answer>;
114+
let dispatched: (id: string, query: Record<string, string>, object?: string) => Promise<Answer>;
97115

98116
beforeAll(async () => {
99117
engine = new ObjectQL();
@@ -116,8 +134,17 @@ describe('[#26816] GET /data/:object/:id — RestServer and the dispatcher give
116134
fields: {
117135
title: { name: 'title', type: 'text' },
118136
owner: { name: 'owner', type: 'text' },
137+
// A lookup, so `?expand=` has a relation to accept.
138+
parent: { name: 'parent', type: 'lookup', reference: OBJECT },
119139
},
120140
},
141+
{
142+
name: UNEXPOSED,
143+
label: 'Hidden ledger',
144+
sharingModel: 'public_read_write',
145+
enable: { apiEnabled: false },
146+
fields: { title: { name: 'title', type: 'text' } },
147+
},
121148
],
122149
} as never);
123150
await engine.syncSchemas();
@@ -156,10 +183,16 @@ describe('[#26816] GET /data/:object/:id — RestServer and the dispatcher give
156183
server.registerRoutes();
157184
const route = server.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/:id');
158185
expect(route).toBeDefined();
159-
rest = async (id, query) => {
186+
rest = async (id, query, object = OBJECT) => {
160187
const res = makeRes();
161-
await route!.handler({ params: { object: OBJECT, id }, query, headers: {} } as any, res);
188+
await route!.handler({ params: { object, id }, query, headers: {} } as any, res);
162189
const body = res._json;
190+
// [#26898] The closed-set gate writes the nested `{ error: { code,
191+
// message } }` body (`refuseUnknownQueryParams`); every other
192+
// refusal here is `mapDataError`'s flat one.
193+
if (body?.error && typeof body.error === 'object') {
194+
return { status: res._status ?? 200, code: body.error.code, message: body.error.message };
195+
}
163196
return { status: res._status ?? 200, code: body?.code, field: body?.field, message: body?.error };
164197
};
165198

@@ -170,8 +203,8 @@ describe('[#26816] GET /data/:object/:id — RestServer and the dispatcher give
170203
const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) };
171204
const dispatcher = new HttpDispatcher(kernel);
172205
(dispatcher as any).timedResolveExecutionContext = async () => ({ ...MEMBER_CTX });
173-
dispatched = async (id, query) => {
174-
const path = `/data/${OBJECT}/${id}`;
206+
dispatched = async (id, query, object = OBJECT) => {
207+
const path = `/data/${object}/${id}`;
175208
const raw = new Request(`http://localhost${PREFIX}${path}`, { method: 'GET' });
176209
let returned: any;
177210
try {
@@ -212,4 +245,19 @@ describe('[#26816] GET /data/:object/:id — RestServer and the dispatcher give
212245
// One refusal, one sentence: the same text whichever host carried it.
213246
expect(restMessage, 'the refusal sentence').toBe(dispatcherMessage);
214247
});
248+
249+
// [#26898] Which parameters a route understands is information: a caller
250+
// the object refuses is answered that refusal, never a verdict on its
251+
// query. `RestServer` asks the closed set after `enforceApiAccess`
252+
// (`crud-routes.ts`); the dispatcher now asks it after the same exposure
253+
// gate. Pinned on the dispatcher alone: this rig's `RestServer` has no
254+
// metadata store to read the declaration from, so its exposure gate falls
255+
// open here (`loadObjectItem` answers nothing) and it would not be
256+
// measuring the gate order at all.
257+
it('the dispatcher: an unexposed object ?nope=1 answers the exposure refusal, not the closed set', async () => {
258+
const withUnknown = await dispatched(MINE, { nope: '1' }, UNEXPOSED);
259+
const without = await dispatched(MINE, {}, UNEXPOSED);
260+
expect(withUnknown).toEqual({ status: 404, code: 'OBJECT_API_DISABLED', field: undefined, message: without.message });
261+
expect(without.code).toBe('OBJECT_API_DISABLED');
262+
});
215263
});

‎packages/runtime/src/domains/data.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.
3333
// `RestServer`'s list asks, imported rather than restated.
3434
// [#23265] …and the repo's one sentence for a repeated single-valued
3535
// parameter, which the delete's `?expectedVersion` answers.
36-
import { assertFilterParamSuppliedOnce, repeatedQueryParamMessage } from '@objectstack/rest';
36+
// [#26898] …and the by-id read's closed parameter set (#7606), with the one
37+
// sentence that refuses a name outside it.
38+
import {
39+
assertFilterParamSuppliedOnce, repeatedQueryParamMessage,
40+
DATA_RECORD_READ_PARAMS, unknownQueryParamRefusalMessage,
41+
} from '@objectstack/rest';
3742
// [#23492] The dispatcher's one constructor for a `400 VALIDATION_FAILED`
3843
// answer, which a refused version token returns in this transport's envelope.
3944
// [#23491] …and the one Zod-issue → `fields[]` mapping, which a write whose
@@ -263,8 +268,20 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m
263268
// GET /data/:object/:id
264269
if (parts.length === 2 && m === 'GET') {
265270
const id = parts[1];
266-
// Spec: Only select/expand are allowlisted query params for GET by ID.
267-
// All other query parameters are discarded to prevent parameter pollution.
271+
// [#26898] The route's closed parameter set (#7606), the one
272+
// `RestServer`'s twin asks, and at the same place in the gate
273+
// order: after the object's API exposure (the twin's
274+
// `enforceApiAccess`), since which parameters a route understands
275+
// is information a caller the object refuses should not learn.
276+
// Every name outside `select` / `expand` used to be dropped here,
277+
// so `?selct=name` answered `200` with every column where the twin
278+
// answered `400`; both now refuse it, before the read, whatever id
279+
// the path names.
280+
await actionExec.refuseUnexposedDataAction(deps, _context, 'get', objectName, _context.environmentId, _context.executionContext);
281+
const unknownParams = unknownQueryParamRefusalMessage(query, DATA_RECORD_READ_PARAMS);
282+
if (unknownParams !== undefined) {
283+
return { handled: true, response: deps.error(unknownParams, 400, { code: 'VALIDATION_ERROR' }) };
284+
}
268285
const { select, expand } = query || {};
269286
const allowedParams: Record<string, unknown> = {};
270287
if (select != null) allowedParams.select = select;

‎packages/runtime/src/http-dispatcher.test.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1584,19 +1584,23 @@ describe('HttpDispatcher', () => {
15841584
);
15851585
});
15861586

1587-
it('should NOT pass non-allowlisted params for GET /data/:object/:id', async () => {
1587+
it('refuses a non-allowlisted param for GET /data/:object/:id, never dropping it (#26898)', async () => {
15881588
mockProtocol.getData.mockResolvedValue({ object: 'task', id: 't1', record: {} });
15891589

1590-
await dispatcher.handleData(
1590+
const result = await dispatcher.handleData(
15911591
'/task/t1', 'GET', {},
15921592
{ expand: 'assignee', malicious: 'drop_table', filter: 'hack' },
15931593
AUTHED_CALLER()
15941594
);
15951595

1596-
// Only expand is passed; malicious and filter are dropped
1597-
expect(mockProtocol.getData).toHaveBeenCalledWith(
1598-
{ object: 'task', id: 't1', expand: 'assignee', context: AUTHED_CALLER().executionContext }
1599-
);
1596+
// The route's closed set (#7606), `RestServer`'s: the names outside
1597+
// it are refused before the read, and the sentence names them.
1598+
// They used to be dropped and the read served (`200`).
1599+
expect(result.handled).toBe(true);
1600+
expect(result.response?.status).toBe(400);
1601+
expect(result.response?.body?.error?.code).toBe('VALIDATION_ERROR');
1602+
expect(result.response?.body?.error?.message).toMatch(/^The query parameters "filter", "malicious" are not supported by this endpoint\./);
1603+
expect(mockProtocol.getData).not.toHaveBeenCalled();
16001604
});
16011605

16021606
it('should pass full query (with expand/populate) for GET /data/:object list', async () => {

0 commit comments

Comments
 (0)