json_rpc: look up handlers with at, not bracket indexing - #1514
Conversation
`dispatch` resolved a handler with `handlers[method]`, where `method` is
untrusted wire data and `handlers` is an ordinary object, so inherited
`Object.prototype` names resolved to callables:
- `{method: 'constructor'}` threw a `TypeError` out of `dispatch`
- `{method: 'toString'}` destructured `"[object Undefined]"` character-wise
into `{"jsonrpc":"2.0","error":"o","id":1}`, a schema-violating response
Switch to `at` from `fjs/types/object` — the same own-property lookup every
other dispatch site in the repo already uses — and test for `null`. The
hand-written `Handler | undefined` annotation, needed only because the
index-signature type lied, goes away.
Proofs cover `toString` / `constructor` answering `-32601`, plus a handler
table that genuinely owns a `toString` key still dispatching normally.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018h9EovzEiFnrBj5m7kg4MU
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018h9EovzEiFnrBj5m7kg4MU
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
functionalscript | 3dd34d0 | Commit Preview URL Branch Preview URL |
Aug 13 2026, 06:47 AM |
The 0.44.0 release commit wrote the new section as a bare `0.44.0` line, so it never became a heading: every entry from 0.44.0 down to 0.43.1 rendered inside `## Unreleased`, this PR's entry among them. Merging main also left a whitespace-only line in place of the blank separator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018h9EovzEiFnrBj5m7kg4MU
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Approving. I treated this as a behaviour change rather than a refactor and reproduced the before/after directly instead of reasoning about it.
The bug is real, and worse than "unsafe indexing". Same probe (dispatch({ping}), request {jsonrpc:'2.0', method:<name>, id:4}), run against origin/main (37db36c) and against this head:
method |
main | this PR |
|---|---|---|
ping |
{result:"pong"} |
{result:"pong"} |
nope |
-32601 |
-32601 |
toString |
{"jsonrpc":"2.0","error":"o","id":4} |
-32601 |
constructor |
TypeError thrown out of dispatch |
-32601 |
__proto__ |
TypeError | -32601 |
hasOwnProperty |
TypeError | -32601 |
valueOf |
TypeError | -32601 |
isPrototypeOf |
TypeError | -32601 |
So main is not merely permissive: five of the six prototype names throw out of a function documented as pure and total, and toString emits a response that violates the errorResponse schema (error is the string "o" — "[object Undefined]" destructured character-wise). Every one of these is reachable from untrusted wire data. The todo file this PR deletes described exactly the two shapes I reproduced.
Not over-corrected either: an own toString handler still dispatches ({result:"own"}) on both main and this head, and a notification (id absent) still short-circuits to null before the lookup on both. at uses getOwnPropertyDescriptor + fromUndefined, so an own property whose value is undefined still yields -32601, matching the old === undefined test — the only behavioural delta is the inherited-name one.
The new proofs are not vacuous. I mutated the fix back (const handler = handlers[method] ?? null, verified the edit landed) and re-ran node ./fjs/module.mjs t: 2 failures — inheritedToString ("assertion failed") and inheritedConstructor ("TypeError: handler is not a function..."), i.e. the two distinct failure shapes each get their own guard. Restored, 0 failures. ownPrototypeName passes under the mutation too, but it is a regression guard for the opposite direction, which is the right thing for it to be.
Worth saying explicitly since coverage is now measurable: coverage could not have caught this. npm run cov reports fjs/protocol/json_rpc/module.f.mjs at 100.00 / 100.00 / 100.00 on main as well — the buggy line was already fully covered. The behavioural cases are the only thing that could have found it, which is an argument for this PR's shape rather than against it.
Verified at 3dd34d0, both trees clean before testing:
npx tsc --noEmit— exit 0 (also 0 on main).npm run prepackfrom a freshly cleaned tree — exit 0 (both passes). Also 0 on main.node ./fjs/module.mjs t— 2498 pass / 0 fail vs 2495 / 0 on main. +3 is exactly the three added cases, nothing else moved.npm run cov—tests 2434, pass 2434, fail 0(main:2431),all files 99.93 / 98.07 / 99.73— byte-identical totals to main.- Dual-axis public surface (
prepackin both trees, thenextract.mjs/consts.mjs, outputs 45.9 KB / 64.7 KB so non-empty): zero differences in exported type aliases and zero in exported const signatures. No newexport type— so no §6.2_-prefix question arises — anddispatch's emitted signature is unchanged, no widening toany. Dropping the hand-written/** @type {Handler | undefined} */cast costs nothing at the boundary;Handleris still exported fromtypes.tsviaHandlers. linkcheck.mjs— broken-link sets identical to main, not just counts. Deletingtodo/dispatch-at-lookup.mdstrands nothing:grep -rn dispatch-at-lookup --include='*.md'finds no remaining reference (the cross-link ran the other way, from the deleted file).- Rust gates skipped —
nanvm-lib/untouched.
Two smaller things, both correct:
- The
## 0.44.0heading restore is a genuine fix, not a rewrite of a released section. #1513 added the line as bare text0.44.0under## Unreleased; every other release in the file is a##heading (## 0.43.1,## 0.43.0, …). Restoring it is what makes the new Unreleased entry land in the right section. - CHANGELOG entry links only
/pull/1514, no issue ortodo/link, and sits under## Unreleased. No**BREAKING CHANGES:**prefix, which I agree with: the exported surface is provably identical and the change only replaces a throw / malformed response with the documented-32601.
Nothing to fix. Comment in module.f.mjs naming the hazard matches the three existing at dispatch sites (effects/match, cli/dispatch, cas/evo), so json_rpc is no longer the odd one out.
Fixes
fjs/protocol/json_rpc/todo/dispatch-at-lookup.md(deleted in this PR).Problem
dispatchresolved a handler withhandlers[method].methodis untrustedwire data and
handlersis an ordinary object, so an inheritedObject.prototypename resolves to a callable. Reproduced againstmain:The first destructures
"[object Undefined]"character-wise (t2 = '[',result = 'o') and emits a response that violates the JSON-RPC schema — theerrormember is a string, not an error object. The second throws straight outof a function documented as pure. A dispatcher whose contract says "unknown
method →
-32601" did neither.Fix
const handler = at(method)(handlers), testing=== null.at(
fjs/types/object) goes throughgetOwnPropertyDescriptor, so only ownproperties resolve. This is the same lookup every other dispatch site in the
repo already uses, each with a comment naming this hazard —
match(
fjs/effects),cli/dispatch(fjs/cli),addRevisionToCache(
fjs/cas/evo).json_rpcwas the one reading raw.The hand-written
/** @type {Handler | undefined} */annotation, needed onlybecause the index-signature type claims every string key is present, is gone
along with the now-unused
Handler@import.Verification
After the change, every
Object.prototypename answers-32601:Three new proofs,
throw-free per §3.5:inheritedToStringandinheritedConstructorassert-32601, andownPrototypeNamedispatchesagainst a table that genuinely owns a
toStringhandler — pinning thedistinction the fix rests on (own property yes, inherited no) rather than just
the negative case.
npx tscclean;npm start test2498 pass / 0 fail, re-run after mergingmain.One change outside that scope
The 0.44.0 release commit (#1513) wrote its new CHANGELOG section as a bare
0.44.0line, without the##prefix. It is therefore not a heading, soevery entry from 0.44.0 down to
## 0.43.1renders inside## Unreleased—including this PR's entry, which is why it is fixed here rather than left for a
separate pass. §8.3's "don't rewrite a released section" still holds: no
released wording changes, only the missing
##. Mergingmainalso left awhitespace-only line where the blank separator belongs; that is cleaned up too.
Notes
fjs/protocol/json_rpc/todo/effectful-dispatch-skeleton.mdquotes these exactlines; its snippet is updated to the new code. That issue's own proposal is
untouched — it said "whichever lands first should carry the fix".
mcpStep(fjs/protocol/mcp) routes methods with explicit===comparisonsand a validated tool table, so it never had this hazard.
🤖 Generated with Claude Code
https://claude.ai/code/session_018h9EovzEiFnrBj5m7kg4MU