Repository navigation
fix(hono): createHonoApp's catch arms run the 5xx disclosure filter before shipping an undeclared throw's message (#23532) - #23564
Conversation
…efore 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
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5be6314f9128a6f317cfcf63ca5af6250450140c && git checkout 5be6314f9128a6f317cfcf63ca5af6250450140c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7ffc57846a97afde24fbfc59b6b05d7562ef4df0 5407a49e2205257ae1f091b81f3f80a3daf30d26 && git checkout -B drift-repro 7ffc57846a97afde24fbfc59b6b05d7562ef4df0 && git merge --no-ff 5407a49e2205257ae1f091b81f3f80a3daf30d26
node scripts/docs-audit/affected-docs.mjs --json 7ffc57846a97afde24fbfc59b6b05d7562ef4df0
|
✅ ACCEPT: PR #23564 at
|
Fixes #23532
Clause-②: no
What this does
createHonoApp(@objectstack/hono) has two catch arms: the auth mount's and the${prefix}/*catch-all's aroundHttpDispatcher.dispatch(). Both end inthrownJson. A throw thatdeclaredEnvelopeForThrowdoes 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 byINTERNAL_ERROR_MESSAGE, the constant the other doors ship (HttpDispatcher.error,packages/rest's registrars,declaredEnvelopeForThrow's 5xx limb, and this file's/auth/configarm). No new literal is written. Everything else is unchanged: the status, thecode, a message the predicate does not flag, and every 4xx message. The edit is inthrownJsonalone, 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 realLiteKernel,ObjectQLPluginover the in-memory SQLite WASM driver, the realHttpDispatcher, and@objectstack/honoaliased to this checkout's source. Objectghostis registered and its table is never synced. The request isPOST /api/v1/data/ghost. The probe test file was written into the worktree, run, and deleted by the script's trap. Readings were taken at5407a49e2, and for "before" the fix line was reverted throughscripts/ablation-replace.mjs.500 {"success":false,"error":{"code":"INTERNAL_ERROR","message":"insert into [statement and bound values redacted] - no such table: ghost"}}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
origin/main086fa4640,index.tshas two exits that carry a thrown message. One isthrownJson's fallback (:536, reached from the auth arm's catch at:814and the catch-all's at:865). The other is the/auth/configarm (:746), which already filters.errorJson's third caller passes the literal'Not Found'.looksLikeInternalErrorLeakandINTERNAL_ERROR_MESSAGEare imported atindex.ts:13-14. No dependency was added.packages/types/src/error-leak.ts.looksLikeInternalErrorLeak('insert into [statement and bound values redacted] - no such table: ghost')istrue. The bare tailno such table: ghostalone istrueas well.boomisfalse.HttpDispatcher.erroranddeclaredEnvelopeForThrow(status 500 or above, and the predicate). A 4xxstatusCodekeeps 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 andthrownJsonare the real source. The dispatcher is the package's stubbed runtime, so each case can hand the arm the throw it needs.500with body{ success: false, error: { code: 'INTERNAL_ERROR', message: INTERNAL_ERROR_MESSAGE } }, andghostandno such tableappear nowhere in it. A plainnew Error('boom')keepsboom. A probe that declaresstatusCode: 503is withheld at503 SERVICE_UNAVAILABLE. A probe that declaresstatusCode: 400keeps its message.handleRequestthrows the probe gets the withheld message, and one that throwsboomkeeps it. The legacydispatcher.handleAuthfallback (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 typecheckexited 0 under both programs.tsc --noEmit -p tsconfig.test.json --listFileslists the new test file.hono.test.ts(returns error on dispatch exceptionforService unavailable, the default-message case, and thestatusCode: 403case) andhttp-conformance'shono-declared-envelope.conformance.test.tscontrol rows all use messages the predicate does not flag.Ablation (
scripts/ablation-replace.mjsin wrap mode, plus an outertrap … EXIT INT TERMrestore)The test imports the subject as
./index(source). No build sits on its resolution path, so nodist/preflight applies.messageunread). The anchor hit once, x1 to x0. The blob went446655b1e9b5to a mutated one. Result: 4 failed, 3 passed. The four withheld pins (catch-all probe,statusCode: 503, auth-service probe, legacyhandleAuthprobe) 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".Internal server error.446655b1e9b5) andgit diff HEADis empty.message,) already occurred in the anchor, soablation-replacerefused 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/objectstackderived 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-testneeded its pinned control commit (621a487607) in this shallow clone. Aftergit fetch --depth=1of that commit, it exited 0 (48 cases).pnpm check:type-check-debtfirst 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-loadsneeds the whole tree'sdist/. 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"..tsfiles:eslint --no-inline-configlinted 2 files with 0 errors and 0 warnings. Positive control: a stdin file inpackages/adapters/hono/src/importing a root namespace from@objectstack/specexits 1 (no-restricted-imports). Repo-widepnpm lintis CI's run.dist/built at this head loads as CJS and as ESM (createHonoAppis a function in both) and carries the new expression.Changeset
.changeset/23532-hono-undeclared-500-leak-filter.md:patchfor@objectstack/hono. It is a bug fix in a published package, adds no export, and changes no status or code.Acceptance notes
Errorwhose 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 fix(hono): createHonoApp's two catch arms answer a declared ADR-0112 envelope as declared, not as 500 (#23365) #23530's acceptance notes put it.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 (codeplus a 5xxstatus) over phrasing heuristics. Whether the insert path should declare is the producer's question, not this door's. Carrier: none.maingainedb57228913and7ffc57846after the base. They touchpackages/spectests andpackages/metadata-protocol, nothing this diff touches, so the branch was not merged. CI runs on the merge ref, and the merge queue rebuilds onmain.Generated by Claude Code