Skip to content

release: v0.4.13 — routes that fabricated their answers - #193

Merged
justrach merged 1 commit into
mainfrom
release/0.4.13
Jul 26, 2026
Merged

release: v0.4.13 — routes that fabricated their answers#193
justrach merged 1 commit into
mainfrom
release/0.4.13

Conversation

@justrach

Copy link
Copy Markdown
Owner

Seven HTTP routes returned results they never obtained from the browser. Each answered 200 with a well-formed body, so no caller could tell "Chrome reported nothing" apart from "kuri never asked Chrome".

Routes that fabricated their answers

route before after
/react/tree {"react":false} on every React page real tree — FunctionComponent, Context.Consumer, HostComponent, depths
/trace/stop empty ack, nothing written ~400 KB of real trace on disk
/expose binding registered, invocations dropped captures the call with its payload
/network canned {"status":"ok"} real requests with url, method, mime
/mainframe same hard-coded string for every caller real frameId, loaderId, url
/screencast/stop canned ok real frame_count
/console, /errors regressed to empty capturing again

/react/tree is the interesting one. React double-buffers fibers, and __reactContainer$… is stamped at mount, so after the first commit it addresses the off-screen side of the pair, whose .child is null — detection concluded there was no React at all. Confirmed on React 19.2.5: hasChild:false, altHasChild:true. It now follows whichever side actually rendered.

Script injection

  • /evaluate and /evalhandle escaped their expression twice. A newline became \n, then \\n, which decodes back to a literal backslash-n in the JS source — Chrome rejected every multi-line or quote-containing expression with SyntaxError. The two routes whose whole purpose is running arbitrary JS could not run any non-trivial JS.
  • stealth.js polluted every page's error stream. A top-level const in a file injected twice by design (addScriptToEvaluateOnNewDocument plus a one-shot evaluate into the loaded page). Re-running it in the same global re-declared the const; the SyntaxError aborted the whole re-injection at parse time and surfaced in the page's own error stream, so /errors reported a kuri-internal failure as if it were the page's. Now an IIFE behind an idempotence guard.

Memory safety

  • The event rings handed out pointers into storage the next wrapping event would overwrite — a use-after-free reachable from ordinary traffic. All four rings now dupe into a caller-supplied per-request arena.
  • extractObject/extractField scanned for a closing brace or quote without honouring \", truncating any string value with an escaped quote and ending early on unbalanced literal braces (a GraphQL query sent over GET, for instance).

Performance

  • Route dispatch ~13× faster. A linear chain of 148 std.mem.eql comparisons — cost depended on position, and every unknown path paid all 148 — replaced by a comptime StaticStringMap feeding an exhaustive switch. ~81ns → ~6ns average, ~150ns → ~15ns worst case. zig build bench carries a permanent guard over all 148 routes.
  • CdpClient 520 KiB → 62 KiB per tab (−88%). ws_read_buf was 512 KiB of which ~97% was unreachable headroom (message bodies allocate their own buffer); ws_write_buf had no readers anywhere.

Tests

378 in the main suite, 76 for kuri-fetch, 22 for kuri-browse — all passing, with a regression test per fix. Clean ReleaseFast build. The dispatch rewrite was checked live against Chrome (storage/localLOCAL_VAL vs storage/sessionSESSION_VAL, clipboard read ≠ write, unknown path still 404s), which was its catastrophic-if-wrong failure mode, plus intercept and dialog round-trips.

Note on macOS assets

This repository has no GitHub Actions secrets, so the workflow's sign-and-notarize step takes its signed=false branch and CI publishes unsigned macOS binaries. The macOS tarballs for this release are signed with Developer ID Application: Rachit Pradhan (WWP9DLJ27P) and notarized locally via the codedb-notary profile, then uploaded over CI's.

🤖 Generated with Claude Code

https://claude.ai/code/session_017yzwAh6c95Ygb4LYttdVCs

Seven HTTP routes returned results they never obtained from the browser.
Each answered 200 with a well-formed body, so no caller could tell
"Chrome reported nothing" apart from "kuri never asked Chrome".

/react/tree said {"react":false} on every React page. React double-buffers
fibers and __reactContainer$ is stamped at mount, so after the first commit
it addresses the off-screen side of the pair, whose .child is null —
detection concluded there was no React at all. It now follows whichever side
actually rendered. /trace/stop never asked Tracing.start for
transferMode: ReturnAsStream, so there was no stream to drain and the route
reported success while writing nothing. /network returned a canned
{"status":"ok"}, /mainframe the same hard-coded string to every caller,
/screencast/stop a canned ok, /expose registered its binding but captured no
invocation, and /console and /errors had regressed to empty.

/evaluate and /evalhandle escaped their expression twice. A newline became
\n and then \\n, which decodes back to a literal backslash-n in the JS
source, so Chrome rejected every multi-line or quote-containing expression
with SyntaxError — the two routes whose whole purpose is running arbitrary
JS could not run any non-trivial JS.

stealth.js was a top-level script with a const in it, injected twice by
design (addScriptToEvaluateOnNewDocument plus a one-shot evaluate into the
loaded page). Re-running it in the same global re-declared the const; the
SyntaxError aborted the entire re-injection at parse time and surfaced in
the page's own error stream, so /errors reported a kuri-internal failure as
if it were the page's. Now an IIFE behind an idempotence guard.

Two memory bugs: the event rings handed out pointers into storage the next
wrapping event would overwrite, and extractObject/extractField scanned for
a closing brace or quote without honouring \" — truncating any string value
with an escaped quote and ending early on unbalanced literal braces, such as
a GraphQL query sent over GET.

Route dispatch was a linear chain of 148 std.mem.eql comparisons, so cost
depended on position in the chain and every unknown path paid all 148. It is
now a comptime StaticStringMap feeding an exhaustive switch: ~81ns to ~6ns
average, ~150ns to ~15ns worst case, with a permanent bench guard over all
148 routes. CdpClient is 62 KiB per tab rather than 520 KiB: ws_read_buf was
512 KiB of which ~97% was unreachable headroom (message bodies allocate
their own buffer), and ws_write_buf had no readers anywhere.

378 tests in the main suite, plus 76 for kuri-fetch and 22 for kuri-browse.
Every fix above has a regression test, and the dispatch rewrite was checked
live against Chrome for intercept, dialog, storage and clipboard behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yzwAh6c95Ygb4LYttdVCs
@justrach
justrach merged commit 9c0e306 into main Jul 26, 2026
3 of 4 checks passed
@justrach
justrach deleted the release/0.4.13 branch July 26, 2026 03:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant