Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/express/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -208,6 +214,8 @@ declare global {
detection_id: string;
rule_enforced: boolean;
};
/** What the edge validator said about this request (#481). */
webdecoyEdge?: EdgeVerdict;
}
}
}
7 changes: 7 additions & 0 deletions packages/fastify/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -91,6 +92,8 @@ interface WebDecoyDetection {
declare module 'fastify' {
interface FastifyRequest {
webdecoy?: WebDecoyDetection;
/** What the edge validator said about this request (#481). */
webdecoyEdge?: EdgeVerdict;
}
}

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions packages/nextjs/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
};
72 changes: 72 additions & 0 deletions packages/nextjs/src/edge-verdict.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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<string, string> = {};
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';
5 changes: 5 additions & 0 deletions packages/nextjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
107 changes: 107 additions & 0 deletions packages/nextjs/src/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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-<name>` 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<string, string> = {}): 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);
});
});
27 changes: 22 additions & 5 deletions packages/nextjs/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading