Origins can read the edge's verdict; Next.js stops leaking it (#481) - #12
Merged
Conversation
…leaking it (#481)
The Cloudflare validator has set `x-wd-clearance` on every forwarded request since
it shipped. Grepping the header across the app monorepo, this SDK and the
WordPress plugin returned only the worker that writes it, its own tests, and one
docs page. We shipped a tag no customer could act on, then documented it as
"watch the header in your logs."
That gap is a safety problem, not a missing feature. On a scoped route the gate's
only options are pass or challenge, and a challenge on public content is a 403 to
every client that cannot run JavaScript — search crawlers included. Reading the
tag gives the application a third option it owns: log it, meter it, skip an
expensive query, serve something cheaper.
THE NEXT.JS ADAPTER WAS BROKEN IN BOTH DIRECTIONS
`middleware.ts` did `NextResponse.next()` and then `response.headers.set(...)`,
which writes RESPONSE headers — the exact opposite of what its own comment said.
The application never saw the annotation, because route handlers and server
components read REQUEST headers, so the feature did not work at all. And the
annotation went to the browser, publishing our decision and detection id to the
client we had just judged. Forwarding needs `NextResponse.next({ request: {
headers } })`; mutating the response was never the same thing.
WHAT ORIGINS GET
`readEdgeVerdict()` parses both headers into a typed EdgeVerdict, exposed as
`ProtectResult.edge`, `req.webdecoyEdge` (Express), `request.webdecoyEdge`
(Fastify), and `getEdgeVerdict()` for Next route handlers and server components.
Filter expressions gain an `edge.*` namespace — `edge.class == "script"` instead
of `req.header("x-wd-class") == "script"`, which already worked but was neither
discoverable nor rename-proof.
ABSENCE IS THE PART THAT MATTERS
`present: false` means the edge did not front this request. An application will
use this to decide whether to serve someone less, so "we don't know" must never
read as "browser" — and an unrecognised class value is dropped rather than passed
through, because a value outside the closed set means version skew or something
that is not our worker.
`isUnattestedNonBrowser` deliberately excludes `verified`: the common intent is
"cheapen this response", and the one population you must never cheapen for is the
one whose identity was actually attested.
The middleware also strips inbound copies of its own annotation, so an app that
trusts it cannot be talked into a verdict by the request it is judging.
Refs #477
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.
Part of WebDecoy/app#481. Pairs with the worker-side change in WebDecoy/app (emits
x-wd-class).The Cloudflare validator has set
x-wd-clearanceon every forwarded request since it shipped, and nothing has ever read it. Grepping the header across the app monorepo, this SDK and the WordPress plugin returns only the worker that writes it, its own tests, and one docs page. We shipped a tag no customer could act on, then documented it as "watch the header in your logs."That gap is a safety problem, not a missing feature. On a scoped route the gate's only options are pass or challenge, and a challenge on public content is a 403 to every client that cannot run JavaScript — search crawlers included. Reading the tag gives the application a third option it owns: log it, meter it, skip an expensive query, serve something cheaper.
The Next.js adapter was broken in both directions
Its own comment said "Add detection info to request headers for downstream use" while the code set response headers. Two consequences:
What origins get
ProtectResult.edgereq.webdecoyEdgerequest.webdecoyEdgegetEdgeVerdict(req)edge.class == "script",edge.script,edge.present,edge.clearancereadEdgeVerdict(headers)The
edge.*filter namespace matched already viareq.header("x-wd-class"). A named field exists so it is discoverable, spelled once rather than in every customer's expression, and survives us renaming a header. The raw-header form still works — there is a test pinning that, so nobody has to migrate.Absence is the part that matters
present: falsemeans the edge did not front this request. An application uses this to decide whether to serve someone less, so:browserisUnattestedNonBrowserexcludesverified, because the common intent is "cheapen this response" and the one population you must never cheapen for is the one whose identity was actually attestedThe middleware also strips inbound copies of its own annotation, so an app that trusts it cannot be talked into a verdict by the request it is judging.
Notes for review
ProtectResult.edgeis attached inprotect()around a new privatedecide()rather than at each of its seven returns (two of which are fail-open paths). It is information about the request, not a product of the decision, so it must be present on every outcome — and a per-return copy is the line someone eventually forgets on the branch that mattered.packages/nextjshadjest --passWithNoTestsand no jest config, so it had never run a test. Added the config and the first tests.decision: allow, which is exactly what the fail-open path legitimately writes. Now forges values the SDK cannot produce, and its comment says which mechanism actually guarantees the property.check:edgepasses for both@webdecoy/nodeand@webdecoy/nextjs.Testing
turbo test: 169 tests green (142 node + 20 client + 7 new nextjs)turbo buildgreen including the strict tsup DTS buildspackages/webdecoy/src/edge.test.tscovering parsing, absence, mixed-case headers, unknown-value rejection, and everyedge.*filter fieldDocs caveat that must land with this
Do not vary a cacheable response body on
x-wd-class. Cloudflare's default cache key excludes arbitrary request headers and header-based cache keys are Enterprise-only, so the first variant cached for a URL is served to everyone including Googlebot — and on a cache hit the origin never runs at all. Safe uses: logging, metering, skipping work,Cache-Control: privateresponses. This is stated ingetEdgeVerdict's doc comment and in the app-side docs page.Refs WebDecoy/app#477