Stop a host's callback from breaking the guard that calls it - #160
Merged
Conversation
createProtection takes three callbacks from the host: onError, onDetect and onSkip. Only onSkip was wrapped so a throw could not escape, with the reason written next to it — a reporting callback must never affect request handling. The other two were unguarded, across 23 call sites. onDetect is the sharp one, because it runs on the request path. A handler that throws propagates out of the fetch guard and the request fails, and it fires only when a rule matched — so a bug in the host's logging turns "we noticed something" into "the app broke", on precisely the requests that mattered. That is the opposite of the one promise this package makes. onError costs boots and refreshes. Reporting a recovered condition — the rule fetch failed, falling back to cached or bundled rules — to a broken handler aborted createProtection, so an app lost protection entirely over a bug in its own logging. On the refresh path the same throw becomes an unhandled rejection in a poll loop, which kills a long-lived process long after the mistake was made. So this is not a new rule, it is the rule onSkip already demonstrated, applied where it was missing. One helper at every site, returning whether the callback ran so a caller can fall back: the engine uses that to keep its own report-once logging when a host handler throws, rather than letting a rule error disappear between two broken reporters. Not silent either — the first failure per callback is warned about once per process, since these run per request and the choice is between an unbounded log flood and hiding the host's bug forever. Every containment test is paired with a control, because containment has a cheap wrong implementation: never call the callback at all. That mutation is caught only by the controls. One further test says containment is not a bypass — a broken onDetect still blocks in block mode, because a reporting callback sits downstream of the decision and costs the report, never the enforcement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Comprehensive callback-containment feature correctly wraps host callbacks contextually. 🎯 Quality: 100% Elite · 📦 Size: Medium 📈 This month: Your 109th PR — above team average · Averaging Excellent |
Two holes in the previous commit, both from scoping the problem by callback name
instead of by what the code actually calls.
An async callback escapes a try/catch entirely. `async () => { throw ... }` does
not throw — it returns a rejected promise, which settles after containment has
returned, and on Node an unhandled rejection terminates the process by default.
So the fix as written contained synchronous hosts and left async ones able to
kill the app: worse than the throw it set out to catch, because the app dies
rather than losing a report. notify now attaches a rejection handler when the
callback hands back a thenable, and routes it to the same one-time warning.
That hole was not limited to the new code. The two pre-existing try/catch
wrappers around onSkip had it as well, which is the argument for the containment
living in one place instead of being written out at each site.
onBlock, onEgressBlock and onScan were still called directly, because the first
pass enumerated three callbacks by name rather than everything the code invokes.
onBlock runs after the decision to block and before the block response is
produced, so an escaping throw did not let the request through — it replaced the
403 with the callback's exception. That is not availability, it is enforcement
integrity: what a blocked request returns has to come from the rule and never
from reporting code. onScan is worse in a quieter way, since a throw there
aborted lazy init and left every later request unscreened.
Reachability, stated because severity depends on it: onEgressBlock is wired from
createProtection today, while onBlock and onScan are reachable only through the
adapters, which the public entry point does not forward to. They are guarded
anyway — the invariant belongs at the call site, not in an assumption that
nobody passes the option.
Return contract now documents its limit: for an async callback, a true result can
only mean the call started, so the engine's fall-through to its own logging
applies to synchronous handlers only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Author
|
/review |
mariojgt
approved these changes
Aug 20, 2026
daniloradovic
approved these changes
Aug 20, 2026
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.
ENG-3648
createProtectiontakes three callbacks from the host —onError,onDetect,onSkip. OnlyonSkipwas wrapped so a throw could not escape, with the reason written beside it:The other two were unguarded, across 23 call sites. This is not a new rule; it is that rule applied where it was missing.
onDetectis the sharp one — it runs on the request pathVerified directly rather than reasoned about: a handler that throws propagates out of the fetch guard and the request fails. And it fires only when a rule matched, so a bug in the host's logging callback turns "we noticed something" into "the app broke", on precisely the requests that mattered. That is the inverse of the single promise this package makes.
onErrorcosts boots and refreshesresolveRulesreports a recovered condition throughonError— the rule fetch failed, falling back to cached or bundled rules. Handing that to a broken handler abortedcreateProtection, so an app lost protection entirely over a bug in its own logging. On the refresh path the same throw becomes an unhandled rejection inside a poll loop, which kills a long-lived process long after the mistake was made.Found while writing #158's test: an assertion about that diagnostic being contained failed for an unrelated reason, which was this. I removed my one-off guard there rather than ship it, because a single wrapped call site implies a containment the other 22 don't have.
Shape
One
notify(fn, arg, label)helper at every site. It returns whether the callback ran, and the engine uses that: a host handler which runs takes over error reporting, one which throws falls through to the built-in report-once logging — otherwise a rule error would vanish between two broken reporters.Not silent. A throwing hook is a bug in the host's code, so the first failure per hook is reported — once per process, because these run per request and the alternative is an unbounded log flood.
Tests
tests/protect/callback-containment.test.ts, 9 cases. Every containment test is paired with a control, because "contained" has a cheap wrong implementation: never call the callback at all.onDetectthrows, and is not wedged for later requestsonDetectthrows — containment must not become a bypass; a reporting callback is downstream of the decision, so a broken one costs the report, never the enforcementonErrorthrows; refreshes whenonErrorthrowsonSkipstill contained (locking pre-existing behaviour while generalising the rule it demonstrated)notifyreports delivery accurately, warns once rather than per call, and survives a runtime with noconsoleMutation-checked, four ways: letting the throw escape fails seven; never calling the callback fails only the controls; warning per failure fails only the warn-once test; always claiming delivery fails only the fall-through test.
Full suite green (1276 passed, 6 skipped), typecheck and template typecheck clean.