From 96eee5d0c080d0e043e3c3be71ad8a5e4a0d7af2 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Thu, 30 Jul 2026 08:55:41 -0500 Subject: [PATCH] feat: origins can finally read the edge's verdict, and Next.js stops leaking it (#481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/express/src/middleware.ts | 8 + packages/fastify/src/plugin.ts | 7 + packages/nextjs/jest.config.js | 8 + packages/nextjs/src/edge-verdict.ts | 72 ++++++++ packages/nextjs/src/index.ts | 5 + packages/nextjs/src/middleware.test.ts | 107 ++++++++++++ packages/nextjs/src/middleware.ts | 27 ++- packages/webdecoy/src/edge.test.ts | 137 +++++++++++++++ packages/webdecoy/src/edge.ts | 156 ++++++++++++++++++ packages/webdecoy/src/index.ts | 7 + .../webdecoy/src/rules/filter/evaluator.ts | 21 +++ packages/webdecoy/src/rules/types.ts | 8 + packages/webdecoy/src/sdk.ts | 19 +++ packages/webdecoy/src/types.ts | 10 ++ 14 files changed, 587 insertions(+), 5 deletions(-) create mode 100644 packages/nextjs/jest.config.js create mode 100644 packages/nextjs/src/edge-verdict.ts create mode 100644 packages/nextjs/src/middleware.test.ts create mode 100644 packages/webdecoy/src/edge.test.ts create mode 100644 packages/webdecoy/src/edge.ts diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index a3f22dc..5f7db15 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -4,6 +4,7 @@ import { Request, Response, NextFunction } from 'express'; import { WebDecoy, WebDecoyConfig, RequestMetadata, ProtectOptions } from '@webdecoy/node'; +import type { EdgeVerdict } from '@webdecoy/node'; export interface WebDecoyMiddlewareOptions extends ProtectOptions { /** @@ -180,6 +181,11 @@ export function webdecoy( if (result.allowed) { // Attach detection info to request for downstream use (req as any).webdecoy = result.detection; + // And what the edge validator said, typed (#481). A handler can branch on + // req.webdecoyEdge.isScript instead of string-matching x-wd-class, and + // `present: false` tells it the edge was never in front of this request — + // which is no information, not a clean bill of health. + (req as any).webdecoyEdge = result.edge; return next(); } else { // Block the request @@ -208,6 +214,8 @@ declare global { detection_id: string; rule_enforced: boolean; }; + /** What the edge validator said about this request (#481). */ + webdecoyEdge?: EdgeVerdict; } } } diff --git a/packages/fastify/src/plugin.ts b/packages/fastify/src/plugin.ts index 2c8a0e5..efb239b 100644 --- a/packages/fastify/src/plugin.ts +++ b/packages/fastify/src/plugin.ts @@ -5,6 +5,7 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import fp from 'fastify-plugin'; import { WebDecoy, WebDecoyConfig, RequestMetadata, ProtectOptions } from '@webdecoy/node'; +import type { EdgeVerdict } from '@webdecoy/node'; export interface WebDecoyPluginOptions extends ProtectOptions { /** @@ -91,6 +92,8 @@ interface WebDecoyDetection { declare module 'fastify' { interface FastifyRequest { webdecoy?: WebDecoyDetection; + /** What the edge validator said about this request (#481). */ + webdecoyEdge?: EdgeVerdict; } } @@ -124,6 +127,9 @@ async function webdecoyPluginImpl( // Add decorator for webdecoy property fastify.decorateRequest('webdecoy', null); + // The edge validator's verdict, typed (#481), so a handler can branch on + // request.webdecoyEdge.isScript rather than string-matching x-wd-class. + fastify.decorateRequest('webdecoyEdge', null); // Add preHandler hook for protection fastify.addHook('preHandler', async (req, reply) => { @@ -188,6 +194,7 @@ async function webdecoyPluginImpl( if (result.allowed) { // Attach detection info to request for downstream use req.webdecoy = result.detection as WebDecoyDetection; + req.webdecoyEdge = result.edge; } else { // Block the request onBlocked(req, reply, result.detection); diff --git a/packages/nextjs/jest.config.js b/packages/nextjs/jest.config.js new file mode 100644 index 0000000..57b4da5 --- /dev/null +++ b/packages/nextjs/jest.config.js @@ -0,0 +1,8 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.ts'], + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'], +}; diff --git a/packages/nextjs/src/edge-verdict.ts b/packages/nextjs/src/edge-verdict.ts new file mode 100644 index 0000000..1cd3ee4 --- /dev/null +++ b/packages/nextjs/src/edge-verdict.ts @@ -0,0 +1,72 @@ +/** + * Read the edge validator's verdict inside a Next.js app (#481). + * + * The Cloudflare clearance worker annotates every forwarded request with + * `x-wd-clearance` and, when the sensor classified the client, `x-wd-class`. + * Nothing in this SDK read either header until now, so the only thing an + * application could do with a scoped route was let the edge 403 it — which on + * public content means 403ing every client that cannot run JavaScript. + * + * This is the read side. It works in middleware, route handlers, server + * components and server actions, because all it needs is a header bag. + */ + +import { readEdgeVerdict, type EdgeVerdict } from '@webdecoy/node'; + +/** + * Anything that can hand us request headers: a `NextRequest`, a `Request`, the + * `Headers` object, or the result of `await headers()` in a server component. + */ +type HeaderSource = { headers: Headers } | Headers | { get(name: string): string | null }; + +const EDGE_HEADERS = ['x-wd-clearance', 'x-wd-class']; + +function bagFrom(source: HeaderSource): Record { + const getter: { get(name: string): string | null } = + 'headers' in source && !('get' in source) ? source.headers : (source as { get(name: string): string | null }); + + const bag: Record = {}; + for (const name of EDGE_HEADERS) { + const value = getter.get(name); + if (value) bag[name] = value; + } + return bag; +} + +/** + * Parse the edge validator's annotations from a request. + * + * @example + * ```typescript + * // app/api/search/route.ts + * import { getEdgeVerdict } from '@webdecoy/nextjs'; + * + * export async function GET(req: Request) { + * const edge = getEdgeVerdict(req); + * + * // Skip the expensive path for scripted traffic — without 403ing it, and + * // without touching what a verified crawler sees. + * if (edge.isScript) { + * return Response.json(await cheapResults(), { + * headers: { 'cache-control': 'private, no-store' }, + * }); + * } + * return Response.json(await fullResults()); + * } + * ``` + * + * `present: false` means the edge did not front this request. That is no + * information — not "human". Do not treat it as a pass. + * + * IMPORTANT, and the reason this returns data rather than a response: do NOT vary + * a **cacheable response body** on this. 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, Googlebot included — + * and on a cache hit your origin never runs at all. Vary behaviour, or mark the + * response private, as above. + */ +export function getEdgeVerdict(source: HeaderSource): EdgeVerdict { + return readEdgeVerdict(bagFrom(source)); +} + +export type { EdgeVerdict, EdgeClass } from '@webdecoy/node'; diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 12adb2f..02a521e 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -19,6 +19,11 @@ export { withWebDecoy, withBotProtection } from './middleware'; export type { WebDecoyMiddlewareOptions, WithBotProtectionOptions } from './middleware'; +// The edge validator's verdict, readable from a route handler or server +// component — not only from middleware (#481). +export { getEdgeVerdict } from './edge-verdict'; +export type { EdgeVerdict, EdgeClass } from './edge-verdict'; + // Self-hosted captcha route handlers (PoW + detection + tokens) export { createCaptchaHandler } from './captcha'; export type { NextCaptchaHandlers } from './captcha'; diff --git a/packages/nextjs/src/middleware.test.ts b/packages/nextjs/src/middleware.test.ts new file mode 100644 index 0000000..1dc1769 --- /dev/null +++ b/packages/nextjs/src/middleware.test.ts @@ -0,0 +1,107 @@ +import { NextRequest } from 'next/server'; +import { withWebDecoy } from './middleware'; +import { getEdgeVerdict } from './edge-verdict'; + +/** + * The Next.js adapter's annotation, and the edge tag it has to let through (#481). + * + * This adapter shipped a bug that made the feature it advertised impossible: it + * set its annotation with `response.headers.set()` after `NextResponse.next()`, + * which writes RESPONSE headers. The application — route handlers, server + * components — reads REQUEST headers, so it never saw the annotation at all, and + * the visitor we had just judged received our decision and detection id. + * + * These tests assert both halves of the fix, in the only terms that matter: what + * the application receives, and what the browser receives. + */ + +/** + * Next.js exposes forwarded request headers on the middleware response as + * `x-middleware-override-headers` plus one `x-middleware-request-` per + * header. That encoding is how `NextResponse.next({ request })` reaches the app, + * so reading it here is how we assert the app would see the header — asserting on + * `response.headers` alone is precisely the mistake that shipped. + */ +function forwardedToApp(res: Response, name: string): string | null { + return res.headers.get(`x-middleware-request-${name}`); +} + +function req(headers: Record = {}): NextRequest { + return new NextRequest('https://shop.example/products', { headers }); +} + +/** Config with no API key and no rules: protect() fails open locally, no network. */ +const OPTIONS = { skipLocalAnalysis: true } as const; + +describe('withWebDecoy annotates the request, not the response', () => { + it('does not put its annotation on the response, where the browser would see it', async () => { + const res = await withWebDecoy({ ...OPTIONS })(req()); + // The visitor must not be told our decision or our detection id. + expect(res.headers.get('x-webdecoy-decision')).toBeNull(); + expect(res.headers.get('x-webdecoy-detection-id')).toBeNull(); + }); + + it('forwards the edge tag through to the application untouched', async () => { + // The tag the Cloudflare validator set upstream has to survive this middleware + // — it is the whole point of #481 that the application can read it. + const res = await withWebDecoy({ ...OPTIONS })( + req({ 'x-wd-class': 'script', 'x-wd-clearance': 'valid' }) + ); + expect(forwardedToApp(res, 'x-wd-class')).toBe('script'); + expect(forwardedToApp(res, 'x-wd-clearance')).toBe('valid'); + // And it is still not echoed to the browser. + expect(res.headers.get('x-wd-class')).toBeNull(); + }); + + it('an inbound copy of its own annotation never reaches the application', async () => { + // An application trusting x-webdecoy-decision must not be talkable-into by the + // request being judged. + // + // What guarantees this is that every protect() path returns a detection, so the + // annotation is always written over the client's. The explicit delete in the + // middleware is insurance for the day `detection` becomes optional — this test + // asserts the property, not the mechanism, because the property is what a + // customer depends on. + // + // The forged values are deliberately ones the SDK cannot produce. An earlier + // draft forged `decision: allow`, which is exactly what the fail-open path + // legitimately writes, so it passed for the wrong reason. + const res = await withWebDecoy({ ...OPTIONS })( + req({ + 'x-webdecoy-decision': 'trust-me-i-am-a-browser', + 'x-webdecoy-detection-id': 'forged-by-the-client', + }) + ); + expect(forwardedToApp(res, 'x-webdecoy-decision')).not.toBe('trust-me-i-am-a-browser'); + expect(forwardedToApp(res, 'x-webdecoy-detection-id')).not.toBe('forged-by-the-client'); + }); + + it('skipPaths still short-circuits', async () => { + const res = await withWebDecoy({ ...OPTIONS, skipPaths: ['/products'] })(req()); + expect(res.status).toBe(200); + }); +}); + +describe('getEdgeVerdict', () => { + it('reads the tag from a plain Request in a route handler', () => { + const edge = getEdgeVerdict(new Request('https://shop.example/api/search', { + headers: { 'x-wd-class': 'script', 'x-wd-clearance': 'missing' }, + })); + expect(edge.present).toBe(true); + expect(edge.isScript).toBe(true); + expect(edge.clearance).toBe('missing'); + }); + + it('reads the tag from a Headers object', () => { + const edge = getEdgeVerdict(new Headers({ 'x-wd-class': 'verified' })); + expect(edge.isVerified).toBe(true); + // Never cheapen a response for an attested identity. + expect(edge.isUnattestedNonBrowser).toBe(false); + }); + + it('reports no edge as no information', () => { + const edge = getEdgeVerdict(new Request('https://shop.example/api/search')); + expect(edge.present).toBe(false); + expect(edge.isBrowser).toBe(false); + }); +}); diff --git a/packages/nextjs/src/middleware.ts b/packages/nextjs/src/middleware.ts index 68448a1..494751e 100644 --- a/packages/nextjs/src/middleware.ts +++ b/packages/nextjs/src/middleware.ts @@ -193,13 +193,30 @@ export function withWebDecoy( // Handle the result if (result.allowed) { - // Add detection info to request headers for downstream use - const response = NextResponse.next(); + // Annotate the REQUEST, so the application sees it (#481). + // + // This previously did `NextResponse.next()` then `response.headers.set()`, + // which sets RESPONSE headers: the exact opposite of what its own comment + // said. Two consequences, both bad. The application never saw the + // annotation — route handlers and server components read request headers — + // so the feature did not work at all. And the annotation was sent to the + // browser, publishing our decision and detection id to the client we had + // just judged. + // + // In Next.js middleware, forwarding to the app requires passing headers + // through the `request` option; mutating the response is not the same + // thing and never was. + const requestHeaders = new Headers(req.headers); + // Drop any inbound copy first. An application that trusts these must not + // be talkable-into by the request being judged, and `set` only covers the + // branch where we have a detection to write. + requestHeaders.delete('x-webdecoy-decision'); + requestHeaders.delete('x-webdecoy-detection-id'); if (result.detection) { - response.headers.set('x-webdecoy-decision', result.detection.decision || ''); - response.headers.set('x-webdecoy-detection-id', result.detection.detection_id || ''); + requestHeaders.set('x-webdecoy-decision', result.detection.decision || ''); + requestHeaders.set('x-webdecoy-detection-id', result.detection.detection_id || ''); } - return response; + return NextResponse.next({ request: { headers: requestHeaders } }); } else { return onBlocked(req, result.detection); } diff --git a/packages/webdecoy/src/edge.test.ts b/packages/webdecoy/src/edge.test.ts new file mode 100644 index 0000000..b7e1a13 --- /dev/null +++ b/packages/webdecoy/src/edge.test.ts @@ -0,0 +1,137 @@ +import { readEdgeVerdict, EDGE_CLASS_HEADER, EDGE_CLEARANCE_HEADER } from './edge'; +import { filter } from './rules'; +import type { RuleContext } from './rules/types'; + +/** + * Reading the edge validator's tag (#481). + * + * The worker has set `x-wd-clearance` on every forwarded request since the + * validator shipped, and nothing read it — so on a scoped route an application's + * only options were the edge's pass or the edge's 403, and a 403 on public content + * hits every client that cannot run JavaScript. + * + * The tests that matter most here are the ones about ABSENCE. An application will + * use this to decide whether to serve someone less, so "we don't know" must never + * read as "browser", and a value the client supplied must never read as ours. + */ + +describe('readEdgeVerdict', () => { + it('parses both headers into typed values', () => { + const edge = readEdgeVerdict({ + [EDGE_CLEARANCE_HEADER]: 'valid', + [EDGE_CLASS_HEADER]: 'script', + }); + expect(edge.present).toBe(true); + expect(edge.clearance).toBe('valid'); + expect(edge.class).toBe('script'); + expect(edge.isScript).toBe(true); + expect(edge.isBrowser).toBe(false); + }); + + it('reports absence as absence, not as browser', () => { + // The whole point. A request the edge never touched carries no information, + // and an application that reads `isBrowser` off it would be skipping checks + // for exactly the traffic that bypassed the edge. + const edge = readEdgeVerdict({ 'user-agent': 'curl/8.7.1' }); + expect(edge.present).toBe(false); + expect(edge.class).toBeUndefined(); + expect(edge.isBrowser).toBe(false); + expect(edge.isScript).toBe(false); + expect(edge.isUnattestedNonBrowser).toBe(false); + }); + + it('handles no headers at all', () => { + expect(readEdgeVerdict(undefined).present).toBe(false); + expect(readEdgeVerdict({}).present).toBe(false); + }); + + it('tolerates mixed-case header names, since adapters differ', () => { + const edge = readEdgeVerdict({ 'X-WD-Class': 'crawler' }); + expect(edge.class).toBe('crawler'); + expect(edge.isCrawler).toBe(true); + }); + + it('drops an unrecognised class rather than passing it through', () => { + // A value outside the closed set means version skew or something that is not + // our worker. Silence beats handing a caller a string they might branch on. + const edge = readEdgeVerdict({ [EDGE_CLASS_HEADER]: 'definitely-a-human' }); + expect(edge.class).toBeUndefined(); + expect(edge.present).toBe(false); + }); + + it('keeps an unknown clearance label — the validator gains those faster than we ship', () => { + const edge = readEdgeVerdict({ [EDGE_CLEARANCE_HEADER]: 'some-future-label' }); + expect(edge.present).toBe(true); + expect(edge.clearance).toBe('some-future-label'); + expect(edge.class).toBeUndefined(); + }); + + it('isUnattestedNonBrowser excludes verified', () => { + // The predicate most callers want is "cheapen this", and the one population + // you must never cheapen for is the one whose identity was actually attested. + expect(readEdgeVerdict({ [EDGE_CLASS_HEADER]: 'script' }).isUnattestedNonBrowser).toBe(true); + expect(readEdgeVerdict({ [EDGE_CLASS_HEADER]: 'crawler' }).isUnattestedNonBrowser).toBe(true); + expect(readEdgeVerdict({ [EDGE_CLASS_HEADER]: 'verified' }).isUnattestedNonBrowser).toBe(false); + expect(readEdgeVerdict({ [EDGE_CLASS_HEADER]: 'browser' }).isUnattestedNonBrowser).toBe(false); + }); +}); + +/** + * Filter expressions could always match this with 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. + */ +describe('edge.* in filter expressions', () => { + function ctx(headers: Record): RuleContext { + return { + ip: '203.0.113.5', + path: '/search', + method: 'GET', + headers, + timestamp: 1_700_000_000_000, + edge: readEdgeVerdict(headers), + }; + } + + it('matches on edge.class', () => { + const rule = filter({ expression: 'edge.class == "script"', action: 'THROTTLE' }); + expect(rule.evaluate(ctx({ [EDGE_CLASS_HEADER]: 'script' })).action).toBe('THROTTLE'); + expect(rule.evaluate(ctx({ [EDGE_CLASS_HEADER]: 'browser' })).action).toBe('ALLOW'); + }); + + it('does not fire when the edge never classified', () => { + // An undefined class must make the comparison false. If it coerced to a + // match, every request that bypassed the edge would be throttled. + const rule = filter({ expression: 'edge.class == "script"', action: 'THROTTLE' }); + expect(rule.evaluate(ctx({})).action).toBe('ALLOW'); + }); + + it('matches on the boolean shorthands', () => { + const rule = filter({ expression: 'edge.script', action: 'THROTTLE' }); + expect(rule.evaluate(ctx({ [EDGE_CLASS_HEADER]: 'script' })).action).toBe('THROTTLE'); + expect(rule.evaluate(ctx({ [EDGE_CLASS_HEADER]: 'verified' })).action).toBe('ALLOW'); + }); + + it('can require that the edge was actually in front of the request', () => { + const rule = filter({ + expression: 'edge.present and edge.class == "crawler"', + action: 'THROTTLE', + }); + expect(rule.evaluate(ctx({ [EDGE_CLASS_HEADER]: 'crawler' })).action).toBe('THROTTLE'); + expect(rule.evaluate(ctx({})).action).toBe('ALLOW'); + }); + + it('matches on the clearance verdict', () => { + const rule = filter({ expression: 'edge.clearance == "missing"', action: 'DENY' }); + expect(rule.evaluate(ctx({ [EDGE_CLEARANCE_HEADER]: 'missing' })).action).toBe('DENY'); + expect(rule.evaluate(ctx({ [EDGE_CLEARANCE_HEADER]: 'valid' })).action).toBe('ALLOW'); + }); + + it('the raw-header form still works, so nobody has to migrate', () => { + const rule = filter({ + expression: 'req.header("x-wd-class") == "script"', + action: 'THROTTLE', + }); + expect(rule.evaluate(ctx({ [EDGE_CLASS_HEADER]: 'script' })).action).toBe('THROTTLE'); + }); +}); diff --git a/packages/webdecoy/src/edge.ts b/packages/webdecoy/src/edge.ts new file mode 100644 index 0000000..c809099 --- /dev/null +++ b/packages/webdecoy/src/edge.ts @@ -0,0 +1,156 @@ +/** + * The edge validator's verdict, as the origin sees it (#481). + * + * WHY THIS MODULE EXISTS + * + * The Cloudflare clearance worker has set `x-wd-clearance` on every forwarded + * request since the validator shipped, and nothing has ever read it. Grepping the + * header across the monorepo, this SDK and the WordPress plugin returned only the + * worker that writes it, its own tests, and one documentation page. We shipped a + * tag no customer could act on, then documented it as "watch the header in your + * logs." + * + * That gap matters more than it sounds. On a scoped route the gate's only options + * were 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. That is the first "yes" a cautious + * operator can give. + * + * WHAT IT IS NOT + * + * These values are advisory context, not authentication. They are only meaningful + * for requests that actually passed through the edge validator, and the validator + * strips inbound copies precisely so that the origin can attribute them to us. + * A request that reaches the origin WITHOUT passing through the proxy — straight + * to the origin IP, or a route that does not match — can carry anything. Treat + * `present: false` as "no information", never as "not a bot", and keep the origin + * unreachable except through the proxy if you intend to branch on this. + */ + +/** Header the edge validator uses for its clearance verdict. */ +export const EDGE_CLEARANCE_HEADER = 'x-wd-clearance'; + +/** Header the edge validator uses for the sensor's client classification. */ +export const EDGE_CLASS_HEADER = 'x-wd-class'; + +/** + * What the edge sensor concluded the client is. + * + * - `verified` — an identity something other than the client has attested + * (Cloudflare's verified-bot signal). **Never degrade these.** This is + * Googlebot and friends, and quietly serving them less is how a customer loses + * search presence and blames you six weeks later. + * - `crawler` — self-declares as a crawler, unproven. Real-but-unverified + * crawlers and forged ones both land here. + * - `script` — an HTTP client library, a Chromium user agent sending no client + * hints, or no user agent at all. Not a browser. + * - `browser` — nothing that distinguishes a non-human fired. A statement about + * our signals, not a certificate. + */ +export type EdgeClass = 'verified' | 'crawler' | 'script' | 'browser'; + +const EDGE_CLASSES: readonly EdgeClass[] = ['verified', 'crawler', 'script', 'browser']; + +/** + * The edge's annotations on one request, typed. + * + * Every field is optional except `present`, because the edge validator may not be + * in front of this request at all — and the difference between "the edge said + * browser" and "there is no edge here" is the difference between a safe + * optimisation and a security hole. + */ +export interface EdgeVerdict { + /** + * True when the edge validator annotated this request. False means no + * information — not "human", not "safe". + */ + present: boolean; + + /** + * The clearance verdict, e.g. `valid`, `missing`, `verified-bot`, + * `signed-agent`, `machine`, `insufficient-trust`. Free-form on purpose: the + * validator gains labels faster than an SDK release can enumerate them, and an + * unknown label must not become `undefined`. + */ + clearance?: string; + + /** The sensor's classification, when it classified this request. */ + class?: EdgeClass; + + /** `class === 'verified'`. Convenience so callers need no string literals. */ + isVerified: boolean; + /** `class === 'crawler'`. */ + isCrawler: boolean; + /** `class === 'script'`. */ + isScript: boolean; + /** `class === 'browser'`. */ + isBrowser: boolean; + + /** + * True for a class that is not a browser and is not attested — `script` or + * `crawler`. + * + * This is the predicate most applications actually want, and it 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 proven. + */ + isUnattestedNonBrowser: boolean; +} + +/** The verdict for a request the edge never touched. */ +function absent(): EdgeVerdict { + return { + present: false, + isVerified: false, + isCrawler: false, + isScript: false, + isBrowser: false, + isUnattestedNonBrowser: false, + }; +} + +function normaliseClass(raw: string | undefined): EdgeClass | undefined { + if (!raw) return undefined; + const v = raw.trim().toLowerCase(); + return (EDGE_CLASSES as readonly string[]).includes(v) ? (v as EdgeClass) : undefined; +} + +/** + * Read the edge validator's annotations out of a request's headers. + * + * Accepts the header bag the SDK already carries on `RequestMetadata` — lowercase + * keys — and tolerates mixed case, since adapters differ on normalisation. + * + * An unrecognised class value is dropped rather than passed through. The set is + * closed and small; a value outside it means either a version skew or something + * that is not our worker, and in both cases silence beats a value a caller might + * branch on. + */ +export function readEdgeVerdict(headers: Record | undefined): EdgeVerdict { + if (!headers) return absent(); + + let clearanceRaw: string | undefined; + let classRaw: string | undefined; + for (const key of Object.keys(headers)) { + const k = key.toLowerCase(); + if (k === EDGE_CLEARANCE_HEADER) clearanceRaw = headers[key]; + else if (k === EDGE_CLASS_HEADER) classRaw = headers[key]; + } + + const clearance = clearanceRaw?.trim() || undefined; + const cls = normaliseClass(classRaw); + + if (!clearance && !cls) return absent(); + + return { + present: true, + clearance, + class: cls, + isVerified: cls === 'verified', + isCrawler: cls === 'crawler', + isScript: cls === 'script', + isBrowser: cls === 'browser', + isUnattestedNonBrowser: cls === 'script' || cls === 'crawler', + }; +} diff --git a/packages/webdecoy/src/index.ts b/packages/webdecoy/src/index.ts index 36629e9..22ffecc 100644 --- a/packages/webdecoy/src/index.ts +++ b/packages/webdecoy/src/index.ts @@ -37,6 +37,13 @@ export type { ProtectOptions, } from './types'; +// The edge validator's verdict, as the origin sees it (#481). Exported as a +// value, not only a type: readEdgeVerdict() is how an application that is not +// using protect() — a route handler, a server component — reads the tag without +// string-matching a header. +export { readEdgeVerdict, EDGE_CLASS_HEADER, EDGE_CLEARANCE_HEADER } from './edge'; +export type { EdgeClass, EdgeVerdict } from './edge'; + // Rules engine exports export { rateLimit, diff --git a/packages/webdecoy/src/rules/filter/evaluator.ts b/packages/webdecoy/src/rules/filter/evaluator.ts index cc7ba8c..bb668c4 100644 --- a/packages/webdecoy/src/rules/filter/evaluator.ts +++ b/packages/webdecoy/src/rules/filter/evaluator.ts @@ -91,6 +91,27 @@ function resolveProperty(path: string[], context: RuleContext): any { return undefined; } + // The edge validator's verdict (#481). Matching this already worked via + // req.header("x-wd-class"), which is why it exists here: a named field is + // discoverable, is spelled once instead of in every customer's expression, and + // survives us renaming a header. + // + // `edge.class` is undefined when the edge did not classify, so a comparison + // against it is false rather than accidentally true — the important property + // when the expression is deciding whether to serve someone less. + if (namespace === 'edge') { + const e = context.edge; + if (!e) return undefined; + if (prop === 'present') return e.present; + if (prop === 'clearance') return e.clearance; + if (prop === 'class') return e.class; + if (prop === 'verified') return e.isVerified; + if (prop === 'crawler') return e.isCrawler; + if (prop === 'script') return e.isScript; + if (prop === 'browser') return e.isBrowser; + return undefined; + } + // Single ident with no namespace — treat as boolean property shorthand if (path.length === 1) return undefined; diff --git a/packages/webdecoy/src/rules/types.ts b/packages/webdecoy/src/rules/types.ts index 2e5a754..4e84092 100644 --- a/packages/webdecoy/src/rules/types.ts +++ b/packages/webdecoy/src/rules/types.ts @@ -4,6 +4,7 @@ */ import type { AgentVerdict } from '../agent/types'; +import type { EdgeVerdict } from '../edge'; /** * Context available to rules during evaluation @@ -29,6 +30,13 @@ export interface RuleContext { * present). Lets a synchronous rule act on cryptographic agent verification. */ agent?: AgentVerdict; + /** + * The edge validator's annotations on this request (#481), parsed from + * `x-wd-clearance` and `x-wd-class`. Always populated — `present: false` when + * the edge did not front this request. Filter expressions read it as + * `edge.class`, `edge.clearance` and `edge.present`. + */ + edge?: EdgeVerdict; } /** diff --git a/packages/webdecoy/src/sdk.ts b/packages/webdecoy/src/sdk.ts index d061c99..318e3a0 100644 --- a/packages/webdecoy/src/sdk.ts +++ b/packages/webdecoy/src/sdk.ts @@ -10,6 +10,7 @@ import { ViolationReporter } from './violation-reporter'; import { IPEnrichmentClient } from './ip-enrichment'; import { AgentVerifier } from './agent/verifier'; import type { AgentRequestInput, AgentVerdict } from './agent/types'; +import { readEdgeVerdict } from './edge'; import type { RuleContext, RuleEngineResult, ViolationEvent } from './rules/types'; import { WebDecoyConfig, @@ -144,6 +145,10 @@ export class WebDecoy { userAgent: metadata.user_agent, headers: metadata.headers, timestamp: metadata.timestamp || Date.now(), + // Parsed synchronously and unconditionally (#481): it is two header reads, + // it needs no network, and a rule that has to check whether the edge + // verdict was populated is a rule people will get wrong. + edge: readEdgeVerdict(metadata.headers), }; } @@ -247,6 +252,20 @@ export class WebDecoy { async protect( metadata: RequestMetadata, options: ProtectOptions = {} + ): Promise { + // The edge verdict (#481) is attached here rather than at each return inside + // decide(), which has seven of them including two 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 a line someone would + // eventually forget on the branch that mattered. + const edge = readEdgeVerdict(metadata.headers); + const result = await this.decide(metadata, options); + return { ...result, edge }; + } + + private async decide( + metadata: RequestMetadata, + options: ProtectOptions = {} ): Promise { try { // Validate required fields diff --git a/packages/webdecoy/src/types.ts b/packages/webdecoy/src/types.ts index 15c08ac..3bb331f 100644 --- a/packages/webdecoy/src/types.ts +++ b/packages/webdecoy/src/types.ts @@ -203,6 +203,16 @@ export interface ProtectResult { * `verified` agent specially (e.g. allow) without re-verifying. */ agent?: AgentVerdict; + + /** + * What the edge validator said about this request (#481), parsed from + * `x-wd-clearance` and `x-wd-class`. + * + * Always present. Check `edge.present` before branching: false means the edge + * did not front this request, which is no information rather than a clean bill + * of health. + */ + edge?: import('./edge').EdgeVerdict; } /**