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; } /**