Skip to content

Commit fa6a608

Browse files
fix(hono): createHonoApp's catch arms run the 5xx disclosure filter before shipping an undeclared throw's message (#23532) (#23564)
Fixes #23532 Clause-②: no ## What this does `createHonoApp` (`@objectstack/hono`) has two catch arms: the auth mount's and the `${prefix}/*` catch-all's around `HttpDispatcher.dispatch()`. Both end in `thrownJson`. A throw that `declaredEnvelopeForThrow` does not accept falls to the arm's own answer, `errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500)`, and that line shipped the thrown message without asking the disclosure filter. A driver diagnostic therefore reached the client. The fallback now asks `looksLikeInternalErrorLeak` (`@objectstack/types`) when the status it resolves is 5xx. A flagged message is replaced by `INTERNAL_ERROR_MESSAGE`, the constant the other doors ship (`HttpDispatcher.error`, `packages/rest`'s registrars, `declaredEnvelopeForThrow`'s 5xx limb, and this file's `/auth/config` arm). No new literal is written. Everything else is unchanged: the status, the `code`, a message the predicate does not flag, and every 4xx message. The edit is in `thrownJson` alone, so both arms get it. `declaredEnvelopeForThrow` (`plugin-hono-server/src/adapter.ts`) is read, not edited. ## Measured through a real `createHonoApp` (one-shot, not committed) The rig is the card's own: `packages/qa/http-conformance`'s real `LiteKernel`, `ObjectQLPlugin` over the in-memory SQLite WASM driver, the real `HttpDispatcher`, and `@objectstack/hono` aliased to this checkout's source. Object `ghost` is registered and its table is never synced. The request is `POST /api/v1/data/ghost`. The probe test file was written into the worktree, run, and deleted by the script's trap. Readings were taken at `5407a49e2`, and for "before" the fix line was reverted through `scripts/ablation-replace.mjs`. | | Answer | |---|---| | Before (filter removed) | `500 {"success":false,"error":{"code":"INTERNAL_ERROR","message":"insert into [statement and bound values redacted] - no such table: ghost"}}` | | After (`5407a49e2`) | `500 {"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error"}}` | The "before" row is byte-for-byte the answer the card quotes. The throw reached the fallback undeclared: a declared envelope would have been answered by `declaredEnvelopeForThrow`, whose own 5xx limb already filters. ## Mechanism hypotheses, as measured - **H1, the only unfiltered exit:** confirmed. At `origin/main` `086fa4640`, `index.ts` has two exits that carry a thrown message. One is `thrownJson`'s fallback (`:536`, reached from the auth arm's catch at `:814` and the catch-all's at `:865`). The other is the `/auth/config` arm (`:746`), which already filters. `errorJson`'s third caller passes the literal `'Not Found'`. - **H2, already importable:** confirmed. `looksLikeInternalErrorLeak` and `INTERNAL_ERROR_MESSAGE` are imported at `index.ts:13-14`. No dependency was added. - **H3, the predicate flags the probe:** confirmed on the real string, through `packages/types/src/error-leak.ts`. `looksLikeInternalErrorLeak('insert into [statement and bound values redacted] - no such table: ghost')` is `true`. The bare tail `no such table: ghost` alone is `true` as well. `boom` is `false`. - **H4, scoped to 5xx:** adopted, with the same expression as `HttpDispatcher.error` and `declaredEnvelopeForThrow` (status 500 or above, and the predicate). A 4xx `statusCode` keeps its message. This is pinned, and Ablation leg B shows the pin can fail. ## Tests New file: `packages/adapters/hono/src/hono-thrown-message-disclosure.test.ts`, 7 cases. `createHonoApp`, both of its arms and `thrownJson` are the real source. The dispatcher is the package's stubbed runtime, so each case can hand the arm the throw it needs. - **Catch-all arm.** The probe answers `500` with body `{ success: false, error: { code: 'INTERNAL_ERROR', message: INTERNAL_ERROR_MESSAGE } }`, and `ghost` and `no such table` appear nowhere in it. A plain `new Error('boom')` keeps `boom`. A probe that declares `statusCode: 503` is withheld at `503 SERVICE_UNAVAILABLE`. A probe that declares `statusCode: 400` keeps its message. - **Auth-mount arm.** An auth service whose `handleRequest` throws the probe gets the withheld message, and one that throws `boom` keeps it. The legacy `dispatcher.handleAuth` fallback (no auth service) throwing the probe is withheld too. Results: - `pnpm --filter @objectstack/hono test`: 8 files, 161 tests passed (the 7 new ones included). `pnpm --filter @objectstack/hono typecheck` exited 0 under both programs. `tsc --noEmit -p tsconfig.test.json --listFiles` lists the new test file. - The existing pins still pass unchanged. `hono.test.ts` (`returns error on dispatch exception` for `Service unavailable`, the default-message case, and the `statusCode: 403` case) and `http-conformance`'s `hono-declared-envelope.conformance.test.ts` control rows all use messages the predicate does not flag. ## Ablation (`scripts/ablation-replace.mjs` in wrap mode, plus an outer `trap … EXIT INT TERM` restore) The test imports the subject as `./index` (source). No build sits on its resolution path, so no `dist/` preflight applies. - **Leg A: the filter removed** (the fallback ships `message` unread). The anchor hit once, x1 to x0. The blob went `446655b1e9b5` to a mutated one. Result: **4 failed, 3 passed**. The four withheld pins (catch-all probe, `statusCode: 503`, auth-service probe, legacy `handleAuth` probe) went red. The two plain controls and the 4xx pin stayed green. Received body: `"message": "insert into [statement and bound values redacted] - no such table: ghost"`. - **Leg B: the 5xx bound removed** (the filter runs at every status). Result: **1 failed, 6 passed**. Only the 4xx pin went red: it expected the probe and received `Internal server error`. - Restore was proven per leg: the blob equals HEAD (`446655b1e9b5`) and `git diff HEAD` is empty. - The first attempt was a no-op and was discarded. Its replacement text (`message,`) already occurred in the anchor, so `ablation-replace` refused on "replacement count rose 0, not 1" and restored the file before the child ran. The legs above re-ran with replacements carrying a unique marker. ## Gates (all at `5407a49e2`, the head of this PR) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands. Each exit code was captured to a file before any pipe. All 63 ended at exit 0. Three first returned exit 3 (PREREQUISITE NOT MET) and were re-run once their prerequisite was met: - `node scripts/check-plugin-teardown-shape.mjs --self-test` needed its pinned control commit (`621a487607`) in this shallow clone. After `git fetch --depth=1` of that commit, it exited 0 (48 cases). - `pnpm check:type-check-debt` first timed out in the shared verify lock's queue (a sibling's run held it for 17 minutes). On re-run: "1 ledger entr(ies) re-measured … none above its recorded number". - `pnpm check:dual-build-cjs-loads` needs the whole tree's `dist/`. The re-measure above built `./packages/*`, and the re-run passed: "110 published require entry point(s) across 68 package(s) load". - `node scripts/pm/dispatch-gates.mjs --ran ran.list`: "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN". - Lint, narrowed to the diff's two `.ts` files: `eslint --no-inline-config` linted 2 files with 0 errors and 0 warnings. Positive control: a stdin file in `packages/adapters/hono/src/` importing a root namespace from `@objectstack/spec` exits 1 (`no-restricted-imports`). Repo-wide `pnpm lint` is CI's run. - The hono `dist/` built at this head loads as CJS and as ESM (`createHonoApp` is a function in both) and carries the new expression. ## Changeset `.changeset/23532-hono-undeclared-500-leak-filter.md`: `patch` for `@objectstack/hono`. It is a bug fix in a published package, adds no export, and changes no status or code. ## Acceptance notes - Out of scope, untouched: the standing tiering of an undeclared 5xx's legibility. A bare `Error` whose message does not look like a dump still names itself, as the card's acceptance requires. Also untouched: the declared-5xx disagreement between the Hono doors and the dispatcher's own exit. It stays where PR #23530's acceptance notes put it. - Observation, not filed: in the rig, the unsynced-table insert left the engine as an undeclared bare `Error`. That is why it reached this fallback rather than the declared limb. The predicate's own header prefers that a producer declare its fault (`code` plus a 5xx `status`) over phrasing heuristics. Whether the insert path should declare is the producer's question, not this door's. Carrier: none. - `main` gained `b57228913` and `7ffc57846` after the base. They touch `packages/spec` tests and `packages/metadata-protocol`, nothing this diff touches, so the branch was not merged. CI runs on the merge ref, and the merge queue rebuilds on `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent cdc42eb commit fa6a608

3 files changed

Lines changed: 198 additions & 1 deletion

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/hono': patch
3+
---
4+
5+
fix(hono): `createHonoApp`'s catch arms withhold a driver diagnostic from an undeclared `500` instead of shipping it to the client (#23532)
6+
7+
Clause-②: no
8+
9+
`@objectstack/hono`'s `createHonoApp` has two catch arms: the auth mount's, and the `${prefix}/*` catch-all's around `HttpDispatcher.dispatch()`. A throw that does not declare an ADR-0112 envelope is answered from its own `message`, and that message was shipped unread. A driver diagnostic therefore reached the client verbatim: on a host whose object `ghost` was registered but whose table was never synced, `POST /api/v1/data/ghost` answered `500 INTERNAL_ERROR` with the message `insert into [statement and bound values redacted] - no such table: ghost`, naming the SQLite error and the physical table.
10+
11+
**What changed.** Before it ships that message, the arm now asks `looksLikeInternalErrorLeak` (`@objectstack/types`), as every other door that emits a thrown message already does: `HttpDispatcher.error`, the REST registrars, `@objectstack/plugin-hono-server`'s direct-mount routes and this adapter's own `/auth/config` arm. When the answer is a `5xx` and the message looks like a driver or SQL dump, the message is replaced by the same text those doors use, `Internal server error`. The status and the `code` are unchanged.
12+
13+
**What did NOT change:**
14+
15+
- a `5xx` whose message does not look like a dump keeps it: a bare `new Error('boom')` is still `500 INTERNAL_ERROR` with the message `boom`;
16+
- a `4xx` keeps its message, as on the other doors, because a `4xx` message is an answer meant for the caller;
17+
- a throw that declares an ADR-0112 envelope is answered as declared, as before.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23532] `createHonoApp`'s two catch arms run the 5xx disclosure filter
5+
* before they ship an UNDECLARED throw's message.
6+
*
7+
* ## The defect
8+
*
9+
* Both arms (the `${authMount}/*` mount's and the `${prefix}/*` catch-all's
10+
* around `HttpDispatcher.dispatch()`) end in `thrownJson`. A throw the ADR-0112
11+
* recogniser does not accept fell to `errorJson(c, err.message, …)` with the
12+
* message unread, so a driver diagnostic reached the client verbatim. Measured
13+
* through a real host (an object registered, its table never synced):
14+
*
15+
* POST /api/v1/data/ghost
16+
* 500 {"success":false,"error":{"code":"INTERNAL_ERROR",
17+
* "message":"insert into [statement and bound values redacted] - no such table: ghost"}}
18+
*
19+
* The driver had already redacted the statement and its values; the SQLite
20+
* diagnostic and the physical table name still went out. Every other door that
21+
* emits a thrown message asks `looksLikeInternalErrorLeak` first
22+
* (`HttpDispatcher.error`, `packages/rest`'s registrars, the recogniser's own
23+
* 5xx limb, and this file's `/auth/config` arm), so this one now does too.
24+
*
25+
* ## What is pinned, per arm
26+
*
27+
* - the probe above, as the throw: `500`, `INTERNAL_ERROR`, and the twins'
28+
* `INTERNAL_ERROR_MESSAGE` in place of the message; neither the table name
29+
* nor the diagnostic is anywhere in the body;
30+
* - a plain `new Error('boom')`: `500`, `INTERNAL_ERROR`, `boom` — an ordinary
31+
* undeclared 5xx still names itself.
32+
*
33+
* And the filter's scope on the catch-all, the same as the twins': a 5xx that
34+
* declares its status through `statusCode` is filtered at that status, and a
35+
* 4xx keeps its message, because a 4xx message is a caller-facing answer.
36+
*
37+
* ## What this file is
38+
*
39+
* This package's vitest config aliases `@objectstack/runtime` to a stub, so the
40+
* dispatcher here is a fixture whose methods throw what a case hands them;
41+
* `createHonoApp`, both of its arms and `thrownJson` are the real source.
42+
* The composed-host sibling, a real kernel and the real dispatcher, is
43+
* `packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts`.
44+
*/
45+
46+
import { describe, it, expect, vi, beforeEach } from 'vitest';
47+
import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
48+
49+
const mockDispatcher = {
50+
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }),
51+
handleAuth: vi.fn(),
52+
dispatch: vi.fn(),
53+
};
54+
55+
vi.mock('@objectstack/runtime', () => ({
56+
HttpDispatcher: function HttpDispatcher() { return mockDispatcher; },
57+
}));
58+
59+
import { createHonoApp } from './index';
60+
61+
const PREFIX = '/api/v1';
62+
/** The card's probe: the message the SQL driver threw for an unsynced table. */
63+
const PROBE = 'insert into [statement and bound values redacted] - no such table: ghost';
64+
const PLAIN = 'boom';
65+
66+
interface Answer { status: number; body: any; text: string }
67+
68+
const send = async (app: ReturnType<typeof createHonoApp>, method: string, path: string): Promise<Answer> => {
69+
const res = await app.request(`http://localhost${path}`, {
70+
method,
71+
...(method === 'GET' ? {} : { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'x' }) }),
72+
});
73+
const text = await res.text();
74+
return { status: res.status, body: JSON.parse(text), text };
75+
};
76+
77+
/** A kernel whose `auth` service's `handleRequest` throws `thrown`. */
78+
const kernelWithThrowingAuth = (thrown: unknown) => ({
79+
name: 'test-kernel',
80+
getServiceAsync: vi.fn(async () => ({ handleRequest: vi.fn(async () => { throw thrown; }) })),
81+
});
82+
83+
const withheld = { success: false, error: { code: 'INTERNAL_ERROR', message: INTERNAL_ERROR_MESSAGE } };
84+
const kept = (message: string) => ({ success: false, error: { code: 'INTERNAL_ERROR', message } });
85+
86+
/** The disclosure itself, independent of the replacement text. */
87+
const expectNoDiagnostic = (a: Answer) => {
88+
expect(a.text).not.toContain('ghost');
89+
expect(a.text).not.toContain('no such table');
90+
};
91+
92+
beforeEach(() => {
93+
vi.clearAllMocks();
94+
});
95+
96+
describe('[#23532] the dispatch() catch-all arm', () => {
97+
const app = () => createHonoApp({ kernel: { name: 'test-kernel' } as any, prefix: PREFIX, cors: false });
98+
99+
it('a driver diagnostic thrown undeclared: 500 INTERNAL_ERROR with the withheld message', async () => {
100+
mockDispatcher.dispatch.mockRejectedValueOnce(new Error(PROBE));
101+
const answer = await send(app(), 'POST', `${PREFIX}/data/ghost`);
102+
expect(answer.status, answer.text).toBe(500);
103+
expect(answer.body).toEqual(withheld);
104+
expectNoDiagnostic(answer);
105+
});
106+
107+
it('control: a plain bare Error keeps its message', async () => {
108+
mockDispatcher.dispatch.mockRejectedValueOnce(new Error(PLAIN));
109+
const answer = await send(app(), 'POST', `${PREFIX}/data/ghost`);
110+
expect(answer.status, answer.text).toBe(500);
111+
expect(answer.body).toEqual(kept(PLAIN));
112+
});
113+
114+
it('a 5xx declared through `statusCode` alone is filtered at that status', async () => {
115+
mockDispatcher.dispatch.mockRejectedValueOnce(Object.assign(new Error(PROBE), { statusCode: 503 }));
116+
const answer = await send(app(), 'GET', `${PREFIX}/data/ghost`);
117+
expect(answer.status, answer.text).toBe(503);
118+
expect(answer.body).toEqual({ success: false, error: { code: 'SERVICE_UNAVAILABLE', message: INTERNAL_ERROR_MESSAGE } });
119+
expectNoDiagnostic(answer);
120+
});
121+
122+
it('scope: a 4xx keeps its message, as on the twin doors', async () => {
123+
mockDispatcher.dispatch.mockRejectedValueOnce(Object.assign(new Error(PROBE), { statusCode: 400 }));
124+
const answer = await send(app(), 'GET', `${PREFIX}/data/ghost`);
125+
expect(answer.status, answer.text).toBe(400);
126+
expect(answer.body.error.message).toBe(PROBE);
127+
});
128+
});
129+
130+
describe('[#23532] the auth-mount arm', () => {
131+
it('an auth service that throws a driver diagnostic: 500 INTERNAL_ERROR with the withheld message', async () => {
132+
const app = createHonoApp({ kernel: kernelWithThrowingAuth(new Error(PROBE)) as any, prefix: PREFIX, cors: false });
133+
const answer = await send(app, 'GET', `${PREFIX}/auth/get-session`);
134+
expect(answer.status, answer.text).toBe(500);
135+
expect(answer.body).toEqual(withheld);
136+
expectNoDiagnostic(answer);
137+
});
138+
139+
it('control: an auth service that throws a plain bare Error keeps its message', async () => {
140+
const app = createHonoApp({ kernel: kernelWithThrowingAuth(new Error(PLAIN)) as any, prefix: PREFIX, cors: false });
141+
const answer = await send(app, 'GET', `${PREFIX}/auth/get-session`);
142+
expect(answer.status, answer.text).toBe(500);
143+
expect(answer.body).toEqual(kept(PLAIN));
144+
});
145+
146+
it('the legacy dispatcher fallback (no auth service) throwing a driver diagnostic: withheld', async () => {
147+
mockDispatcher.handleAuth.mockRejectedValueOnce(new Error(PROBE));
148+
const app = createHonoApp({ kernel: { name: 'test-kernel' } as any, prefix: PREFIX, cors: false });
149+
const answer = await send(app, 'POST', `${PREFIX}/auth/sign-in/email`);
150+
expect(answer.status, answer.text).toBe(500);
151+
expect(answer.body).toEqual(withheld);
152+
expectNoDiagnostic(answer);
153+
});
154+
});

‎packages/adapters/hono/src/index.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -529,11 +529,37 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
529529
* before: a bare `Error` is still `500` carrying `code` and `message`, with
530530
* no `cause`, and a throw declaring `statusCode` with no registered code
531531
* keeps that status.
532+
*
533+
* [#23532] …with one exception, the disclosure filter. That answer ships the
534+
* thrown message, so this arm is a door emitting a thrown message, and it
535+
* runs `looksLikeInternalErrorLeak` (`@objectstack/types`) exactly as the
536+
* other such doors do: `HttpDispatcher.error`, `packages/rest`'s registrars,
537+
* the recogniser's own 5xx limb above and this file's `/auth/config` arm.
538+
* Without it a driver diagnostic reached the client verbatim. Measured
539+
* through this host (an object registered, its table never synced):
540+
*
541+
* POST /api/v1/data/ghost
542+
* 500 {"success":false,"error":{"code":"INTERNAL_ERROR",
543+
* "message":"insert into [statement and bound values redacted] - no such table: ghost"}}
544+
*
545+
* Same scope as the twins: only a 5xx, because a 4xx message is a
546+
* caller-facing answer by design, and only a message the predicate flags,
547+
* so an ordinary bare `Error` still names itself. The replacement is the
548+
* twins' `INTERNAL_ERROR_MESSAGE`, never a new literal. ⛔ This does not
549+
* withhold every undeclared 5xx: whether an undeclared 5xx stays legible is
550+
* a standing decision this arm does not reopen. The status and `code` are
551+
* unchanged; only the message is replaced.
532552
*/
533553
const thrownJson = (c: any, err: any) => {
534554
const declared = declaredEnvelopeForThrow(err);
535555
if (declared) return c.json(declared.body, declared.status);
536-
return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500);
556+
const status = err.statusCode || 500;
557+
const message = err.message || 'Internal Server Error';
558+
return errorJson(
559+
c,
560+
status >= 500 && looksLikeInternalErrorLeak(message) ? INTERNAL_ERROR_MESSAGE : message,
561+
status,
562+
);
537563
};
538564

539565
const toResponse = (c: any, result: HttpDispatcherResult) => {

0 commit comments

Comments
 (0)