Skip to content

Origins can read the edge's verdict; Next.js stops leaking it (#481) - #12

Merged
cport1 merged 1 commit into
mainfrom
feat/origin-reads-edge-class-481
Jul 30, 2026
Merged

Origins can read the edge's verdict; Next.js stops leaking it (#481)#12
cport1 merged 1 commit into
mainfrom
feat/origin-reads-edge-class-481

Conversation

@cport1

@cport1 cport1 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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-clearance on 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

-const response = NextResponse.next();
-response.headers.set('x-webdecoy-decision', ...);   // RESPONSE headers
+const requestHeaders = new Headers(req.headers);
+requestHeaders.set('x-webdecoy-decision', ...);
+return NextResponse.next({ request: { headers: requestHeaders } });

Its own comment said "Add detection info to request headers for downstream use" while the code set response headers. Two consequences:

  1. The application never saw it. Route handlers and server components read request headers, so the advertised feature did not work at all.
  2. The browser did see it. We published our decision and detection id to the client we had just judged.

What origins get

surface how
any Node app ProtectResult.edge
Express req.webdecoyEdge
Fastify request.webdecoyEdge
Next route handler / server component getEdgeVerdict(req)
filter expressions edge.class == "script", edge.script, edge.present, edge.clearance
anything else readEdgeVerdict(headers)

The edge.* filter namespace matched already via req.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: false means the edge did not front this request. An application uses this to decide whether to serve someone less, so:

  • "we don't know" never reads as browser
  • an unrecognised class value is dropped, not passed through — a value outside the closed set means version skew or something that isn't our worker
  • isUnattestedNonBrowser excludes verified, because 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.

Notes for review

  • ProtectResult.edge is attached in protect() around a new private decide() 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/nextjs had jest --passWithNoTests and no jest config, so it had never run a test. Added the config and the first tests.
  • One test in this PR initially passed for the wrong reason: it forged 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:edge passes for both @webdecoy/node and @webdecoy/nextjs.

Testing

  • turbo test: 169 tests green (142 node + 20 client + 7 new nextjs)
  • turbo build green including the strict tsup DTS builds
  • 13 new tests in packages/webdecoy/src/edge.test.ts covering parsing, absence, mixed-case headers, unknown-value rejection, and every edge.* filter field

Docs 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: private responses. This is stated in getEdgeVerdict's doc comment and in the app-side docs page.

Refs WebDecoy/app#477

…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
@cport1
cport1 merged commit b5d01a3 into main Jul 30, 2026
2 checks passed
@cport1
cport1 deleted the feat/origin-reads-edge-class-481 branch July 30, 2026 14:03
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