Skip to content

Commit 5407a49

Browse files
committed
fix(hono): createHonoApp's catch arms run the 5xx disclosure filter before shipping an undeclared throw's message
An undeclared throw that reached either catch arm of createHonoApp was answered with its own message unread, so a driver diagnostic (an unsynced table's "no such table: ghost") reached the client. The fallback in thrownJson now asks looksLikeInternalErrorLeak at a 5xx and ships INTERNAL_ERROR_MESSAGE in place of a flagged message, as the dispatcher, the REST registrars and the declared-envelope limb already do. A plain message and any 4xx message are unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
1 parent 086fa46 commit 5407a49

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)