feat(protect): thread request into the response phase + cheap prefilter for response rules - #106
Merged
Merged
Conversation
The response phase evaluated rules against a phantom request built from `_response` alone, so a
response rule's `when: { path, method }` scope resolved REQUEST_URI/METHOD to defaults ('/','GET')
and was effectively inert — a route-scoped response rule never matched its route.
Thread a minimal request context (method / originalUrl / request headers) from every screening
entry point — .fetch(handler), .express()/.node() (screenResponses), and the public
screenResponse(response, request) — into screenText, spread alongside `_response`. Now:
- response rules honour `when` route/method scope (the enabling fix), and
- request Host/Origin are visible to response rules — the foundation for open-redirect
(Location vs Host), CORS reflection, and IDOR field-scoping rules (follow-ups).
Backward-compatible: unscoped response rules and callers that pass no request are unchanged.
599 tests pass (+4 new); typecheck clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Adds response prefilter and request context threading for route-scoped rules. 🎯 Quality: 100% Elite · 📦 Size: Small 📈 This month: Your 50th PR — above team average · Averaging Elite |
Contributor
Author
|
/review |
devlob
approved these changes
Aug 12, 2026
Response screening runs one regex pass per rule over the (up to 512 KiB) body — ~9 default rules means ~9 full passes on every response, even when there is obviously no secret. A rule may now declare `prefilter: [literal, ...]`; screenText runs the rule's regex only when at least one anchor is present in the body (case-insensitive, body lowercased once, lazily). Bodies with no candidate — the common case — skip the scan entirely, cutting CPU/latency and shrinking the regex/ReDoS surface. Rules without a prefilter are unchanged. +4 tests; 603 pass; typecheck clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
daniloradovic
approved these changes
Aug 12, 2026
This was referenced Aug 12, 2026
patchstackdave
added a commit
that referenced
this pull request
Aug 12, 2026
Add an `off_origin` response-phase primitive: true (→ block) when a 3xx redirect's Location header points to a different origin than the request's own Host. Relative / same-origin Locations never match; lenient when the request Host is unknown. This is the first capability unlocked by threading the request into the response phase (#106). Also derive the request Host from the URL in reqContextFromFetch — a fetch Request doesn't expose a Host header, and origin-comparing response rules (open-redirect, later CORS) need it. Not a default rule (many apps redirect off-site legitimately); it's authored and route-scoped via `when`. +6 tests; 632 pass; typecheck clean. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
patchstackdave
added a commit
that referenced
this pull request
Aug 12, 2026
…tection Add a `cors_reflected` response-phase primitive (dispatched like cross_origin/off_origin): true (→ block) when a response allows credentials AND either uses `Access-Control-Allow-Origin: *` or reflects the caller's own Origin — the combination that lets any malicious site read the authenticated response. A fixed allowlisted origin, or reflection without credentials, is not flagged. Uses the request Origin threaded into the response phase (#106). Third origin-comparison primitive after cross_origin (CSRF) and off_origin (open-redirect). Not a default; authored + route-scoped via `when`. +6 tests; 632 pass; typecheck clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
patchstackdave
added a commit
that referenced
this pull request
Aug 12, 2026
…tection (#110) Add a `cors_reflected` response-phase primitive (dispatched like cross_origin/off_origin): true (→ block) when a response allows credentials AND either uses `Access-Control-Allow-Origin: *` or reflects the caller's own Origin — the combination that lets any malicious site read the authenticated response. A fixed allowlisted origin, or reflection without credentials, is not flagged. Uses the request Origin threaded into the response phase (#106). Third origin-comparison primitive after cross_origin (CSRF) and off_origin (open-redirect). Not a default; authored + route-scoped via `when`. +6 tests; 632 pass; typecheck clean. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.
Why
The response phase built its engine input from
_responsealone, with no originating request. So a response rule'swhen: { path, method }scope resolvedserver.REQUEST_URI/REQUEST_METHODto defaults (/,GET) and was inert — a route-scoped response rule silently never matched its route (flagged in the output-filtering review, issue #6).This is the single highest-leverage engine change for output filtering: it repairs
whenscoping and is the prerequisite for the response rules people actually want next.What
Thread a minimal request context —
method/originalUrl/ requestheaders— from every screening entry point intoscreenText, spread alongside_response:.fetch(handler)→reqContextFromFetch(request).express()/.node()withscreenResponses→reqContextFromNode(req)screenResponse(response, request)(new optional 2nd arg; Supabase guard can pass it)No body is threaded — response rules key on request method/path/Host/Origin, not the request body.
Unlocks (follow-ups, not in this PR)
Locationhost to the request Host.ACAO: */reflected-Origin +ACAC: true(needs AND-across-two-headers in a response rule).Compatibility & tests
Backward-compatible — unscoped response rules and callers that pass no request are unchanged.
tests/protect/response-when-scope.test.ts(4 new): route match / non-match / method non-match / unscoped-no-request. 599 pass; typecheck clean.🤖 Generated with Claude Code