Skip to content

Commit 577cd27

Browse files
baozhoutaoclaude
andauthored
fix(rest): a declared 5xx no longer ships its own message to the client (#5437) (#5464)
`resolveErrorResponse` — the value side of `sendError`, and therefore the error path of every metadata / UI / discovery / batch route — passed an explicit status straight through for the whole 400-599 band. A declared 5xx returned `error.message` verbatim, past `isSqlLeak`, past `looksLikeInternalErrorLeak`, past the `Internal data error` envelope, while `mapDataError`'s sibling branch stops at 4xx on purpose and says why: "5xx messages keep going through the sanitizing heuristics below so internal/SQL details never reach the client verbatim". Two opposite verdicts on one question. `metadata-protocol` interpolates the raw driver error into two client-facing 500s (overlay persist / delete), and a real driver line is far shorter than the 500-character bound that was the only thing standing here, so the whole thing arrived intact. Length was never a proxy for leakage; on this side of the bound it failed open. The 5xx band now drops the message unconditionally and keeps the producer's status and `code`. Unconditional rather than heuristic: a keyword gate only moves the question to "does the predicate know this dialect". Sanitised in the branch rather than by falling through to `mapDataError`, which derives status from message TEXT — measured first, and it answers 404 OBJECT_NOT_FOUND for the overlay 500s, 404 "Object '<name>' is not registered" for the atomic batch's 501, and 400 with the driver text still verbatim for anything its keywords miss. The withheld text still reaches the log: `handleRouteError` already prints a genuine fault, and a new line covers the 502/503 gap its predicate leaves. 4xx truncation (#5423 / #5436) is untouched. Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh Co-authored-by: Claude <noreply@anthropic.com>
1 parent ef19b94 commit 577cd27

4 files changed

Lines changed: 659 additions & 28 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): a declared 5xx no longer ships its own message to the client (#5437)
6+
7+
**Behaviour change — read this if you operate a deployment or parse REST error
8+
bodies.** An error that carries an explicit `status` of 500 or above now reaches
9+
the client as `{ "error": "Internal server error", "code": "<the producer's
10+
code>" }`. The status and the code are unchanged; only the free-text message is
11+
withheld, and the full original text is written to the server log.
12+
13+
**What was wrong.** `sendError` — the error path of the metadata, UI, discovery
14+
and batch routes — passed an explicit status straight through for the whole
15+
400-599 band, so a declared 5xx returned `error.message` verbatim without
16+
passing through any of the sanitizing heuristics (`isSqlLeak`,
17+
`looksLikeInternalErrorLeak`, the `Internal data error` envelope). The sibling
18+
branch in `mapDataError` stops at 4xx on purpose, with the reason written down:
19+
"5xx messages keep going through the sanitizing heuristics below so
20+
internal/SQL details never reach the client verbatim". Two opposite verdicts on
21+
one question, and the routes that report through `sendError` got the permissive
22+
one.
23+
24+
That was reachable, not theoretical. `metadata-protocol` interpolates the raw
25+
driver error into two client-facing 500s — the customization-overlay persist and
26+
delete failures — so a real driver line such as `SQLITE_ERROR: no such table:
27+
sys_metadata`, `relation "sys_metadata" does not exist`, or a unique-constraint
28+
payload naming physical columns was returned to whoever made the request. The
29+
only thing standing in the way was a 500-character bound, and driver errors are
30+
far shorter than that. Length was never a proxy for leakage; on this side of the
31+
bound it failed open.
32+
33+
**Accepted cost.** A 5xx message written *for* the caller now reaches them as
34+
the generic sentence plus its code. Two concrete examples: the overlay-persist
35+
failure's "In-memory registry was updated but will be lost on restart", and the
36+
atomic-batch refusal's "retry without options.atomic, or probe
37+
capabilities.transactionalBatch on /discovery first". Both remain fully readable
38+
in the server log, and the machine-readable `code` (`OVERLAY_PERSISTENCE_FAILED`,
39+
`NOT_IMPLEMENTED`) still rides on the response, so a client keying on codes is
40+
unaffected. If you were surfacing 5xx `error` text in an operator console, read
41+
it from the log instead — `[REST] Unhandled error` for a genuine fault, and a
42+
new `[REST] 5xx message withheld from client` line for the 502/503 lifecycle
43+
statuses that the unhandled-error predicate deliberately keeps quiet.
44+
45+
The message is dropped unconditionally rather than filtered by keyword: a
46+
predicate would only move the question to "does the heuristic know this
47+
dialect", which is the failure mode that produced the bug. 4xx behaviour is
48+
untouched — an over-long client message is still truncated rather than erased
49+
(#5423 / #5436).

packages/rest/src/rest-4xx-message-truncation.test.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
// mangling messages that were always fine).
3030

3131
import { describe, it, expect, vi } from 'vitest';
32+
import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
3233
import { mapDataError, RestServer } from './rest-server';
3334

3435
/** The bound both branches use. Unchanged by #5423 — only what happens at it. */
@@ -288,13 +289,17 @@ describe('sendError: the same bound, walked through a real route (#5423)', () =>
288289
expect(res.body).toEqual({ error: msg, code: 'NO_DRAFT' });
289290
}, 60_000);
290291

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.
292+
it('a 5xx is NOT truncated — it is withheld, whatever its length (#5437)', async () => {
293+
// This case used to pin the 400-599 passthrough, where an over-long 5xx
294+
// became the literal 'Request failed' while a SHORT one went out word
295+
// for word. That asymmetry WAS the #5437 leak: length is not a proxy
296+
// for "this text is safe to publish", and `metadata-protocol`
297+
// interpolates raw driver errors into 500s far shorter than the bound.
298+
//
299+
// The 4xx/5xx split survives and is still this file's subject; what
300+
// changed is the 5xx disposition — "withheld regardless of length"
301+
// instead of "withheld only above 500 characters". Full coverage lives
302+
// in `rest-5xx-message-sanitization.test.ts`.
298303
const rest = setup({
299304
getMetaItem: vi.fn().mockRejectedValue(
300305
Object.assign(new Error('z'.repeat(600)), { code: 'INTERNAL', status: 503 }),
@@ -305,6 +310,11 @@ describe('sendError: the same bound, walked through a real route (#5423)', () =>
305310
});
306311

307312
expect(res.statusCode).toBe(503);
308-
expect(res.body.error).toBe('Request failed');
313+
expect(res.body.error).toBe(INTERNAL_ERROR_MESSAGE);
314+
// Withheld, not truncated: no prefix of the original survives at all.
315+
expect(String(res.body.error)).not.toContain('z');
316+
// The producer's own code still rides along — a SCREAMING_SNAKE
317+
// constant is what the client keys on and is not a leak.
318+
expect(res.body.code).toBe('INTERNAL');
309319
}, 60_000);
310320
});

0 commit comments

Comments
 (0)