|
| 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 | +}); |
0 commit comments