Refactor: typed message router (webext-messenger) + zod validation at the page→worker boundary#241
Merged
Merged
Conversation
…at the page→worker boundary
Replaces the hand-rolled 190-line `chrome.runtime.onMessage` switch in
background.ts (12 message types, ~7 blind `as unknown as` casts, a
per-handler guard each) with a typed `webext-messenger` router and uniform
zod validation of every content→worker payload.
Addresses both halves of the review note:
- Untyped routing → every cross-context call is now a named method on a
shared `MessengerMethods` contract (`lib/messenger.ts`). A renamed or
mistyped message is a compile error, not a silent runtime no-op. The
blind casts are gone.
- ~50% payload validation → `lib/message-schemas.ts` decodes every
content-script payload (detection host/url/difficulty, rule counts,
debug-trace entries, page-world inject kind) through a zod schema before
it touches the popup-facing maps or IndexedDB. `recordDetection`'s
attacker-influenceable payload is now validated, not trusted wholesale.
The old hand-rolled rule-count sanitization is preserved exactly (drop
unknown ids, floor, drop non-positive) inside `ruleCountsSchema`.
Design notes:
- zod stays OUT of the content and popup bundles (verified): the shared
`messenger.ts` contract is dependency-free; validation lives in the
background-only `message-schemas.ts`. The trust boundary is the
page→worker hop, so the worker is the only context that validates.
- Fire-and-forget paths use `getNotifier` (no response, no retries,
swallows errors) — same posture as the old `sendMessage(...).catch()`.
- The tab-pause push broadcasts via `frameId: "allFrames"`, matching the
prior all-frames `tabs.sendMessage`.
- Dropped the dead `get-tab-detections` handler (no sender anywhere) and
the now-obsolete `{ type: ... }` envelope interfaces.
- The three `inject-*` messages collapse into one validated
`requestPageWorldInject(kind)` over a zod enum.
Testing:
- New `message-schemas.test.ts` pins the validation boundary (happy path +
attacker-shaped-payload rejection for each message).
- Rule/recorder tests now mock `lib/messenger` and assert the semantic call
rather than a wire envelope.
- `webext-messenger` (ESM-only) gets a test mock via moduleNameMapper, same
as webext-storage/abort-utils; `messenger.ts` is coverage-excluded as
transport glue, like effective-enforcement.ts.
- 2042 tests pass; typecheck, biome+eslint, knip, build (+ background
purity) all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Replaces background.ts's hand-rolled ~190-line
chrome.runtime.onMessageswitch (12 message types, ~7 blindas unknown ascasts, a per-handler guard each) with a typedwebext-messengerrouter, and adds uniform zod validation of every content→worker payload.This is the implementation of the "untyped message router" review note — and crucially, it closes the security half that note flagged, not just the typing half.
Why
Two separable problems were bundled in that note:
MessengerMethodscontract; a rename/mistype is a compile error. The blind casts are gone.rule-detectionwas trusted wholesale, recording attacker-influenceable host/url/difficulty straight into the popup-facing map. Nowlib/message-schemas.tsdecodes every content-script payload through a zod schema before it touches the popup maps or IndexedDB.webext-messenger gives (1); it explicitly does not give (2) — its types are erased at runtime — so zod is applied where each handler is registered. The two compose: the library handles transport + compile-time types + fire-and-forget; zod handles the runtime trust boundary.
Design
dist/): the sharedmessenger.tscontract is dependency-free; validation lives in the background-onlymessage-schemas.ts. The trust boundary is the page→worker hop, so the worker is the only context that validates.recordDetection,reportRuleCounts,reportDebugTraceEvent,requestPageWorldInject) usegetNotifier— no response, no retries, swallows errors — matching the oldsendMessage(...).catch(() => {})posture.getTabRuleCounts,getTabPause,getTabUrl,getTabDebugTrace,openOptions) usegetMethod; callers already fall back to an empty state on throw.frameId: "allFrames", matching the prior all-framestabs.sendMessage.ruleCountsSchema.Cleanups carried along
get-tab-detectionshandler (no sender anywhere in the tree) and the now-obsolete{ type: ... }envelope/request interfaces fromdetection-messages.ts(now purely the payload/data contract).inject-*messages collapse into one validatedrequestPageWorldInject(kind)over a zod enum.Testing
message-schemas.test.tspins the validation boundary: happy path + attacker-shaped-payload rejection for each message (unknown kind, out-of-range difficulty, missing fields, non-object counts, …).lib/messengerand assert the semantic call (recordDetection({...}),requestPageWorldInject("webdriver-probe")) rather than a wire envelope.webext-messengeris ESM-only, so it gets a test mock viamoduleNameMapper— same treatment aswebext-storage/abort-utils.messenger.tsis coverage-excluded as transport glue (same posture aseffective-enforcement.ts).🤖 Generated with Claude Code