Skip to content

Commit 39396bd

Browse files
baozhoutaoclaude
andauthored
fix(rest): 4xx passthrough truncates an over-long message instead of erasing it (#5423) (#5436)
Both explicit-status passthrough branches in `rest-server.ts` bounded a domain error's message at 500 characters by REPLACING it with the literal 'Request failed' — `status` and `code` landed as usual and every word of the body text disappeared. That inverted the incentive on the whole rejection vocabulary. driver-sql's filter refusals exist only to tell an author which operator or field they got wrong and how the spec declares it, and the two most carefully worded of them (#5158's unlowered FilterArray, #5347's non-boolean $null comparand) are both over the bound — so the more precisely a rejection was written, the more certainly the client read nothing. They were also readable BEFORE they carried a status, through `mapDataError`'s final raw-message fallback: #4436 added `status: 400` to give them an ADR-0112 wire identity and, in this band, cost them their body. An over-long message is now truncated to `slice(0, 499) + '…'` — same shape as the drivers' own `safeShapePreview`. These messages front-load the main clause (operator, field, path, what arrived, what the spec declares) and back-load attribution and issue numbers, which belong in the log. The bound stays at 500; what changed is what happens AT it. Messages under it are byte-for-byte unchanged. `resolveErrorResponse`'s passthrough range is 400-599, wider than `mapDataError`'s; only its 4xx half changes. 5xx keeps the wholesale replacement, matching the sibling branch's recorded "deliberately limited to 4xx ... so internal/SQL details never reach the client verbatim". Also corrects `sql-driver.ts`'s `unsupportedFilterError` docblock, which claimed `status: 400` "makes sendError pass the message through instead of routing it to the SQL-leak heuristic" — the opposite of the measured behaviour. Comment only; no driver behaviour change. Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5a45b9b commit 39396bd

5 files changed

Lines changed: 416 additions & 11 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
REST 的显式状态直通:4xx 错误消息超过 500 字符时**截断**,不再整条替换成 `Request failed`
6+
7+
`mapDataError``resolveErrorResponse`(`sendError` 的取值端)两处 4xx 直通分支,过去都以 500 字符为界把整条 message 换成字面量 `Request failed` —— `status``code` 照常落地,正文一个字不剩。这把激励方向弄反了:驱动层那些拒收信息**唯一的存在意义**就是告诉作者哪个操作符/字段写错了、协议是怎么声明的,而 driver-sql 里写得最细的两条(#5158 未降解的 `FilterArray`#5347 非布尔 `$null` 比较值)恰好都越过 500 字符,于是客户端只收到 `{ "code": "INVALID_FILTER", "error": "Request failed" }`。更反直觉的是:这两条**不带** `status` 时反而能原文直达(走 `mapDataError` 末尾的 `{ status: 400, body: { error: raw } }`),#4436 给它们加 `status: 400` 是为了赋予 ADR-0112 的 wire 身份,却在这一档让可读性变差了。
8+
9+
现在超长消息按 `message.slice(0, 499) + '…'` 截断,与驱动侧 `safeShapePreview` 同源。这些消息把主句(操作符、字段、path、收到了什么、协议怎么声明)放在最前,被截掉的是尾部的归因和 issue 号 —— 本就该留在日志里而非响应里的部分。上限仍是 500,变的是**到达上限时的处理方式**;短于 500 的消息逐字不变。
10+
11+
影响面不止过滤器:任何携带 4xx `status` 的领域错误同享此修复,包括 metadata save 校验的 422(实测一条五 issue 的 `INVALID_METADATA` 就在这条线上下)、plugin-sharing 的 record-scope 403 等。
12+
13+
`sendError` 一侧的直通区间是 400–599,其中 **5xx 的整条替换刻意保持不变**:4xx 的正文是写给调用方的补救说明,5xx 的正文是服务端故障的日志诊断 —— 这与 `mapDataError` 同族分支「deliberately limited to 4xx」的既有取向一致。

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -450,10 +450,22 @@ const SQLITE_TIME_EXPR_REFS = 8;
450450
* (`malformedFilterArrayError` / `unusableFilterError`): one condition — "this
451451
* filter cannot run" — has one wire code however the caller reached it.
452452
*
453-
* `status: 400` makes `@objectstack/rest`'s `sendError` pass the message
454-
* through instead of routing it to the SQL-leak heuristic, and puts the
455-
* rejection on the `isExpectedQueryRejection` list so a client mistake stops
456-
* being logged as an unhandled server error.
453+
* `status: 400` puts the rejection on `@objectstack/rest`'s
454+
* `isExpectedQueryRejection` list, so a client mistake stops being logged as an
455+
* unhandled server error.
456+
*
457+
* It does NOT decide whether the message text survives, and the claim that it
458+
* "makes `sendError` pass the message through instead of routing it to the
459+
* SQL-leak heuristic" was backwards (#5423): WITHOUT a status these messages
460+
* already reached the client verbatim through `mapDataError`'s final
461+
* `{ status: 400, body: { error: raw } }` — the leak heuristics do not match
462+
* this wording. WITH the status they entered the explicit-status passthrough,
463+
* whose 500-character bound used to swap the whole body text for
464+
* `'Request failed'` — so in that band adding the status made the message LESS
465+
* readable, the opposite of what this comment promised. That bound now
466+
* truncates instead of replacing, so the main clause survives either way; the
467+
* tail (attribution, issue numbers) may be cut. Keep the actionable part —
468+
* operator, field, path, what arrived, what the spec declares — at the FRONT.
457469
*
458470
* The `[sql-driver]` prefix these messages used to carry is GONE from the text:
459471
* it is driver-internal wording, and shipping it to clients is exactly what the
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#5423] A 4xx domain error's message is TRUNCATED at the passthrough bound,
4+
// never swapped wholesale for 'Request failed'.
5+
//
6+
// Both explicit-status passthrough branches in `rest-server.ts` bounded the
7+
// message at 500 characters by REPLACING it: `code` and `status` landed as
8+
// usual and every word of the body text disappeared. Nothing in the suite ever
9+
// looked at the long-message side of either branch — the one assertion that
10+
// touched it (`rest.test.ts`, "guards the passthrough message length") pinned
11+
// the replacement as if it were the intent — which is how the behaviour stayed
12+
// invisible while the messages it silences grew past the bound.
13+
//
14+
// It inverted the incentive on the whole rejection vocabulary. driver-sql's
15+
// filter refusals exist ONLY to tell an author which operator or field they got
16+
// wrong and how the spec declares it; #5158's unlowered-`FilterArray` and
17+
// #5347's non-boolean `$null` refusals are both over 500 characters, so the two
18+
// most carefully worded rejections in the driver were the two the client could
19+
// not read at all. Worse, they were readable BEFORE they carried `status: 400`
20+
// (`mapDataError`'s final `{ status: 400, body: { error: raw } }` ships the raw
21+
// text and the leak heuristics do not match this wording) — #4436 added the
22+
// status to give them a wire identity and, in this band, cost them their body.
23+
//
24+
// Reverse verification, direction predicted BEFORE running: restoring the
25+
// wholesale replacement turns every "long" case here RED (they assert on text
26+
// that only exists once the message survives) and leaves every "short" case
27+
// GREEN (short messages are byte-for-byte unchanged by this fix — that is what
28+
// those cases are for: they catch the opposite overreach, a "fix" that starts
29+
// mangling messages that were always fine).
30+
31+
import { describe, it, expect, vi } from 'vitest';
32+
import { mapDataError, RestServer } from './rest-server';
33+
34+
/** The bound both branches use. Unchanged by #5423 — only what happens at it. */
35+
const MAX = 500;
36+
37+
// ---------------------------------------------------------------------------
38+
// Realistic long 4xx messages
39+
// ---------------------------------------------------------------------------
40+
41+
/**
42+
* driver-sql's `nonBooleanNullComparandError` (#5347/#5368), instantiated the
43+
* way a real request produces it: `{ status: { $null: "false" } }`.
44+
*
45+
* Copied rather than imported — `@objectstack/rest` must not take a dependency
46+
* on a driver package to run its own tests. The wording is what matters: the
47+
* MAIN CLAUSE (operator, field, what arrived, what the spec declares) is at the
48+
* front, the attribution and issue number at the back.
49+
*/
50+
const NULL_COMPARAND_MESSAGE =
51+
`Operator "$null" on field "status" requires a boolean comparand (true or false). ` +
52+
`Received string ("false") at where.status.$null. ` +
53+
`@objectstack/spec FieldOperatorsSchema declares $null as a boolean. It is refused rather ` +
54+
`than coerced because the backends read a non-boolean in OPPOSITE directions — this driver ` +
55+
`compiled IS NULL (anything but false), driver-memory's query path and driver-mongodb ` +
56+
`compiled IS NOT NULL (anything but true), and driver-memory's matcher dropped the ` +
57+
`constraint entirely. Note "false" the STRING is truthy, so it landed on the side opposite ` +
58+
`the false it was written to mean (#5347).`;
59+
60+
function invalidFilterError(message: string) {
61+
return Object.assign(new Error(message), { code: 'INVALID_FILTER', status: 400 });
62+
}
63+
64+
/** A 600-character 4xx whose leading sentence is identifiable after slicing. */
65+
function longClientError(status: number, code: string) {
66+
const head = 'The main clause a caller must read is right here at the front. ';
67+
return Object.assign(
68+
new Error(head + 'x'.repeat(600 - head.length)),
69+
{ code, status },
70+
);
71+
}
72+
73+
// ---------------------------------------------------------------------------
74+
// mapDataError — the branch the generic data routes reach directly
75+
// ---------------------------------------------------------------------------
76+
77+
describe('mapDataError: 4xx passthrough truncates an over-long message (#5423)', () => {
78+
it('a 600-character 4xx keeps its main clause instead of becoming "Request failed"', () => {
79+
const r = mapDataError(longClientError(400, 'INVALID_FILTER'), 'showcase_account');
80+
81+
expect(r.status).toBe(400);
82+
expect(r.body.code).toBe('INVALID_FILTER');
83+
expect(r.body.object).toBe('showcase_account');
84+
// The regression this issue is about.
85+
expect(r.body.error).not.toBe('Request failed');
86+
// The part worth reading survived, verbatim and at the front.
87+
expect(r.body.error).toContain('The main clause a caller must read is right here at the front.');
88+
// ...and it is still bounded.
89+
expect(String(r.body.error)).toHaveLength(MAX);
90+
expect(String(r.body.error).endsWith('…')).toBe(true);
91+
});
92+
93+
it("#5347's $null refusal reaches the client with its operator/field/spec sentence intact", () => {
94+
// The concrete case #5423 was raised on. Guard the premise first: if
95+
// this message ever drops under the bound the assertions below stop
96+
// proving anything, so assert it is genuinely in the truncated band.
97+
expect(NULL_COMPARAND_MESSAGE.length).toBeGreaterThanOrEqual(MAX);
98+
99+
const r = mapDataError(invalidFilterError(NULL_COMPARAND_MESSAGE), 'showcase_account');
100+
101+
expect(r.status).toBe(400);
102+
expect(r.body.code).toBe('INVALID_FILTER');
103+
expect(r.body.error).toContain('Operator "$null" on field "status" requires a boolean comparand');
104+
expect(r.body.error).toContain('Received string ("false") at where.status.$null');
105+
expect(r.body.error).toContain('FieldOperatorsSchema declares $null as a boolean');
106+
// What is cut is the tail — attribution and issue number, the part that
107+
// belongs in the log rather than in the response.
108+
expect(r.body.error).not.toContain('(#5347)');
109+
});
110+
111+
it('the truncated text is a PREFIX of the original — no reordering, no summarising', () => {
112+
const r = mapDataError(invalidFilterError(NULL_COMPARAND_MESSAGE));
113+
const body = String(r.body.error);
114+
115+
expect(body.slice(0, -1)).toBe(NULL_COMPARAND_MESSAGE.slice(0, MAX - 1));
116+
expect(NULL_COMPARAND_MESSAGE.startsWith(body.slice(0, -1))).toBe(true);
117+
});
118+
});
119+
120+
describe('mapDataError: short 4xx messages are byte-for-byte unchanged (#5423)', () => {
121+
it('a normal-length message passes through with no ellipsis and no slicing', () => {
122+
const msg = 'FORBIDDEN: insufficient privileges to update showcase_inquiry rec1';
123+
const r = mapDataError(Object.assign(new Error(msg), { code: 'FORBIDDEN', status: 403 }));
124+
125+
expect(r.status).toBe(403);
126+
expect(r.body.error).toBe(msg);
127+
});
128+
129+
it('exactly 499 characters is still verbatim; exactly 500 is the first truncated length', () => {
130+
const at499 = mapDataError(Object.assign(new Error('y'.repeat(499)), { status: 400 }));
131+
expect(at499.body.error).toBe('y'.repeat(499));
132+
133+
const at500 = mapDataError(Object.assign(new Error('y'.repeat(500)), { status: 400 }));
134+
expect(String(at500.body.error)).toHaveLength(MAX);
135+
expect(at500.body.error).toBe(`${'y'.repeat(MAX - 1)}…`);
136+
});
137+
138+
it('an absent or empty message still degrades to generic text — nothing to truncate', () => {
139+
expect(mapDataError({ status: 400, code: 'X' }).body.error).toBe('Request failed');
140+
expect(mapDataError(Object.assign(new Error(''), { status: 400, code: 'X' })).body.error)
141+
.toBe('Request failed');
142+
});
143+
144+
it('5xx never enters this branch at all (unchanged: sanitizing heuristics own it)', () => {
145+
const r = mapDataError(
146+
Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432 '.repeat(20)), { status: 502 }),
147+
);
148+
expect(r.status).not.toBe(502);
149+
});
150+
});
151+
152+
// ---------------------------------------------------------------------------
153+
// sendError — walked through a real route, in-process
154+
//
155+
// The issue read this branch statically and said so ("`sendError` 那处是同款
156+
// 写法,未单独走通"). It is walked here: a registered metadata route rejects,
157+
// the handler's catch calls `sendError`, and the assertions read the body the
158+
// client would actually receive.
159+
// ---------------------------------------------------------------------------
160+
161+
function createMockServer() {
162+
return {
163+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(),
164+
listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
165+
};
166+
}
167+
168+
function makeRes() {
169+
const res: any = { statusCode: 200, body: undefined };
170+
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
171+
res.json = vi.fn((b: any) => { res.body = b; return res; });
172+
res.header = vi.fn(() => res);
173+
res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn();
174+
return res;
175+
}
176+
177+
function setup(protocolOverrides: Record<string, unknown> = {}) {
178+
const protocol: any = {
179+
getDiscovery: vi.fn().mockResolvedValue({
180+
version: 'v0', endpoints: { data: '', metadata: '', ui: '', auth: '/auth' },
181+
}),
182+
getMetaTypes: vi.fn().mockResolvedValue([]),
183+
getMetaItems: vi.fn().mockResolvedValue([]),
184+
getMetaItem: vi.fn().mockResolvedValue({}),
185+
saveMetaItem: vi.fn().mockResolvedValue({}),
186+
findData: vi.fn().mockResolvedValue([]),
187+
...protocolOverrides,
188+
};
189+
const rest = new RestServer(
190+
createMockServer() as any,
191+
protocol,
192+
{ api: { requireAuth: false } } as any,
193+
);
194+
(rest as any).resolveExecCtx = async () => ({ userId: 'u1' });
195+
rest.registerRoutes();
196+
return rest;
197+
}
198+
199+
async function callRoute(rest: any, method: string, path: string, req: Record<string, unknown>) {
200+
const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path);
201+
if (!route) throw new Error(`${method} ${path} route not registered`);
202+
const res = makeRes();
203+
await route.handler({ method, params: {}, query: {}, body: {}, headers: {}, ...req }, res);
204+
return res;
205+
}
206+
207+
/**
208+
* The metadata save validator's 422 — the NON-FILTER 4xx the issue asked to be
209+
* sampled, confirming the bound bites well outside driver-sql's filter family.
210+
*
211+
* Built the way `metadata-protocol`'s `saveMetaItem` builds it: the first THREE
212+
* issues are summarised as `<path>: <message>` joined by `; `, behind an
213+
* `[invalid_metadata] <type>/<name> failed spec validation: ` prefix, with a
214+
* `(+N more)` suffix for the remainder.
215+
*
216+
* Worth recording how close this family runs to the line: the same fixture with
217+
* three issues and no suffix measured 492 characters — under the bound by 8.
218+
* A metadata save is not an exotic path and a five-issue rejection is not an
219+
* exotic mistake, so this family straddles the cliff exactly as #5423 suspected
220+
* the near-miss filter refusals (#5240 at ~469, #5327 at ~454) do.
221+
*/
222+
function invalidMetadataError() {
223+
const issues = [
224+
{ path: 'fields.amount.type', message: 'Invalid enum value. Expected one of text | number | currency | date | datetime | boolean | select | lookup | master_detail | formula | rollup, received "money"', code: 'invalid_enum_value' },
225+
{ path: 'fields.owner.referenceTo', message: 'Required — a lookup field must name the object it references, and the name must be a registered object', code: 'invalid_type' },
226+
{ path: 'views.grid_default.columns', message: 'Expected array, received string — a grid view declares its columns as a list of field names', code: 'invalid_type' },
227+
{ path: 'fields.status.options', message: 'Required — a select field must declare its options', code: 'invalid_type' },
228+
{ path: 'label', message: 'Required', code: 'invalid_type' },
229+
];
230+
const summary = issues.slice(0, 3).map((i) => `${i.path}: ${i.message}`).join('; ');
231+
return Object.assign(
232+
new Error(
233+
`[invalid_metadata] object/maint_asset failed spec validation: ${summary}`
234+
+ (issues.length > 3 ? ` (+${issues.length - 3} more)` : ''),
235+
),
236+
{ code: 'INVALID_METADATA', status: 422, issues },
237+
);
238+
}
239+
240+
describe('sendError: the same bound, walked through a real route (#5423)', () => {
241+
it('a metadata-save 422 keeps its leading sentence and its structured issues', async () => {
242+
const err = invalidMetadataError();
243+
// Premise guard: this must actually be in the truncated band.
244+
expect(err.message.length).toBeGreaterThanOrEqual(MAX);
245+
246+
const rest = setup({ saveMetaItem: vi.fn().mockRejectedValue(err) });
247+
const res = await callRoute(rest, 'PUT', '/api/v1/meta/:type/:name', {
248+
params: { type: 'object', name: 'maint_asset' },
249+
body: { name: 'maint_asset', label: 'Asset' },
250+
});
251+
252+
expect(res.statusCode).toBe(422);
253+
expect(res.body.code).toBe('INVALID_METADATA');
254+
expect(res.body.error).not.toBe('Request failed');
255+
expect(res.body.error).toContain('[invalid_metadata] object/maint_asset failed spec validation');
256+
expect(res.body.error).toContain('fields.amount.type');
257+
expect(String(res.body.error)).toHaveLength(MAX);
258+
expect(String(res.body.error).endsWith('…')).toBe(true);
259+
// The structured half of the envelope is untouched by any of this.
260+
expect(Array.isArray(res.body.issues)).toBe(true);
261+
expect(res.body.issues).toHaveLength(5);
262+
}, 60_000);
263+
264+
it('a 600-character 404 truncates too — this is not special-cased per status', async () => {
265+
const rest = setup({ getMetaItem: vi.fn().mockRejectedValue(longClientError(404, 'NO_DRAFT')) });
266+
const res = await callRoute(rest, 'GET', '/api/v1/meta/:type/:name', {
267+
params: { type: 'object', name: 'showcase_account' },
268+
});
269+
270+
expect(res.statusCode).toBe(404);
271+
expect(res.body.code).toBe('NO_DRAFT');
272+
expect(res.body.error).toContain('The main clause a caller must read is right here at the front.');
273+
expect(String(res.body.error)).toHaveLength(MAX);
274+
}, 60_000);
275+
276+
it('a short message is byte-for-byte what it always was', async () => {
277+
const msg = '[no_draft] No pending draft exists for object/showcase_account.';
278+
const rest = setup({
279+
getMetaItem: vi.fn().mockRejectedValue(
280+
Object.assign(new Error(msg), { code: 'NO_DRAFT', status: 404 }),
281+
),
282+
});
283+
const res = await callRoute(rest, 'GET', '/api/v1/meta/:type/:name', {
284+
params: { type: 'object', name: 'showcase_account' },
285+
});
286+
287+
expect(res.statusCode).toBe(404);
288+
expect(res.body).toEqual({ error: msg, code: 'NO_DRAFT' });
289+
}, 60_000);
290+
291+
it('an over-long 5xx is DELIBERATELY still replaced — the asymmetry is the point', async () => {
292+
// This branch's passthrough range is 400-599, wider than mapDataError's.
293+
// A 4xx message is addressed to the caller and is the remedy; a 5xx
294+
// message is a server fault's log diagnostic that happens to be
295+
// reachable here, and `mapDataError`'s sibling branch is already
296+
// "deliberately limited to 4xx ... so internal/SQL details never reach
297+
// the client verbatim". #5423 does not widen 5xx leniency.
298+
const rest = setup({
299+
getMetaItem: vi.fn().mockRejectedValue(
300+
Object.assign(new Error('z'.repeat(600)), { code: 'INTERNAL', status: 503 }),
301+
),
302+
});
303+
const res = await callRoute(rest, 'GET', '/api/v1/meta/:type/:name', {
304+
params: { type: 'object', name: 'showcase_account' },
305+
});
306+
307+
expect(res.statusCode).toBe(503);
308+
expect(res.body.error).toBe('Request failed');
309+
}, 60_000);
310+
});

0 commit comments

Comments
 (0)