diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index 525890a..7704845 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -6,18 +6,18 @@ import { Request, Response, NextFunction } from 'express'; import { WebDecoy, WebDecoyConfig, RequestMetadata, ProtectOptions } from '@webdecoy/node'; import type { EdgeVerdict, - SiteHoneytoken, TrustedProxies, ProtectResult, SDKDetectionResponse, } from '@webdecoy/node'; import { - siteHoneytoken, injectHoneytokenLink, isInjectableHtml, - tripwire, resolveClientIp, normalizeIp, + shouldSkipPath, + ruleBlockResponse, + armSiteHoneytoken, } from '@webdecoy/node'; export interface WebDecoyMiddlewareOptions extends ProtectOptions { @@ -169,22 +169,6 @@ function defaultOnError(req: Request, res: Response, error: Error): void { // Fail open - allow the request to continue } -/** - * Check if path should be skipped - */ -function shouldSkipPath(path: string, skipPaths?: string[] | RegExp[]): boolean { - if (!skipPaths || skipPaths.length === 0) { - return false; - } - - return skipPaths.some((pattern) => { - if (typeof pattern === 'string') { - return path === pattern || path.startsWith(pattern); - } - return pattern.test(path); - }); -} - /** * Create Express middleware for Web Decoy protection * @@ -218,27 +202,13 @@ export function webdecoy( const onBlocked = config.onBlocked || defaultOnBlocked; const mode = config.mode ?? 'monitor'; - // Honeytoken. Derived from the API key so every replica computes the - // same path without coordinating — a random per-process token would advertise - // a link whose tripwire only one replica had armed. - // - // Resolution is async (WebCrypto HMAC, so this still runs on edge runtimes), - // and requests served before it settles simply carry no link. That is a few - // milliseconds at boot against the alternative of blocking startup on crypto. - const honeytokenEnabled = (config.honeytoken ?? true) && Boolean(config.apiKey); - let token: SiteHoneytoken | null = null; - if (honeytokenEnabled) { - void siteHoneytoken({ secret: config.apiKey as string }) - .then((t) => { - token = t; - // Arm the path we are about to advertise. Without this the link is bait - // with no trap behind it — a crawler follows it and nothing happens. - sdk.addRule(tripwire({ paths: t.activePaths, includeDefaults: false })); - }) - .catch(() => { - // Deriving the token is not worth a failed boot. No token, no injection. - }); - } + // Honeytoken arming lives in the shared core: every adapter derived the same + // token the same way, and a fourth copy is a fourth place the next change can + // fail to land. + const getToken = armSiteHoneytoken(sdk, { + apiKey: config.apiKey, + enabled: config.honeytoken, + }); const onError = config.onError || defaultOnError; const skipPaths = config.skipPaths; @@ -282,8 +252,10 @@ export function webdecoy( // - a committed response is left alone, because headers are already sent // - Content-Length is corrected, or the client truncates the body // - anything thrown falls back to the original write - if (token) { - const ht = token; + // Read once: the getter can settle between calls, and an injected link + // whose tripwire was armed a moment later is bait with no trap. + const ht = getToken(); + if (ht) { const originalWrite = res.write.bind(res); const originalEnd = res.end.bind(res); const chunks: Buffer[] = []; @@ -358,29 +330,15 @@ export function webdecoy( return next(); } - // Handle rule engine results for specific HTTP responses - if (!result.allowed && result.ruleResult) { - const rr = result.ruleResult; - - if (rr.action === 'THROTTLE') { - const retryAfter = rr.metadata?.retryAfter ?? 60; - res.setHeader('Retry-After', String(retryAfter)); - res.status(429).json({ - error: 'Too Many Requests', - message: rr.reason || 'Rate limit exceeded', - retry_after: retryAfter, - }); - return; - } - - if (rr.action === 'DENY') { - res.status(403).json({ - error: 'Forbidden', - message: rr.reason || 'Access denied by rule', - rule: rr.rule, - }); - return; + // A rule refusal answers with the shape every adapter uses; only the + // writing of it is Express's business. + const block = ruleBlockResponse(result); + if (block) { + for (const [name, value] of Object.entries(block.headers)) { + res.setHeader(name, value); } + res.status(block.status).json(block.body); + return; } // Handle the result diff --git a/packages/fastify/src/plugin.ts b/packages/fastify/src/plugin.ts index 416104c..b1942e0 100644 --- a/packages/fastify/src/plugin.ts +++ b/packages/fastify/src/plugin.ts @@ -9,12 +9,13 @@ import { WebDecoyConfig, RequestMetadata, ProtectOptions, - siteHoneytoken, injectHoneytokenLink, isInjectableHtml, - tripwire, resolveClientIp, normalizeIp, + shouldSkipPath, + ruleBlockResponse, + deriveAndArm, } from '@webdecoy/node'; import type { EdgeVerdict, @@ -153,22 +154,6 @@ function defaultOnError(req: FastifyRequest, reply: FastifyReply, error: Error): // Fail open - allow the request to continue } -/** - * Check if path should be skipped - */ -function shouldSkipPath(path: string, skipPaths?: string[] | RegExp[]): boolean { - if (!skipPaths || skipPaths.length === 0) { - return false; - } - - return skipPaths.some((pattern) => { - if (typeof pattern === 'string') { - return path === pattern || path.startsWith(pattern); - } - return pattern.test(path); - }); -} - /** * Web Decoy detection info attached to requests */ @@ -235,19 +220,12 @@ async function webdecoyPluginImpl( // Fastify lets us await it here, because plugin registration is already an // async boot phase — so unlike Express there is no window where early requests // are served without the link. - const honeytokenEnabled = (options.honeytoken ?? true) && Boolean(options.apiKey); - let token: SiteHoneytoken | null = null; - if (honeytokenEnabled) { - try { - token = await siteHoneytoken({ secret: options.apiKey as string }); - // Arm the path we are about to advertise. Without this the link is bait - // with no trap behind it — a crawler follows it and nothing happens. - sdk.addRule(tripwire({ paths: token.activePaths, includeDefaults: false })); - } catch { - // Deriving the token is not worth a failed boot. No token, no injection. - token = null; - } - } + // The awaited variant, because plugin registration is already an async boot + // phase. Same derive-and-arm as every other adapter; only the timing differs. + const token: SiteHoneytoken | null = await deriveAndArm(sdk, { + apiKey: options.apiKey, + enabled: options.honeytoken, + }); // Add decorator for webdecoy property fastify.decorateRequest('webdecoy', null); @@ -305,29 +283,15 @@ async function webdecoyPluginImpl( return; } - // Handle rule engine results for specific HTTP responses - if (!result.allowed && result.ruleResult) { - const rr = result.ruleResult; - - if (rr.action === 'THROTTLE') { - const retryAfter = rr.metadata?.retryAfter ?? 60; - reply.header('Retry-After', String(retryAfter)); - reply.status(429).send({ - error: 'Too Many Requests', - message: rr.reason || 'Rate limit exceeded', - retry_after: retryAfter, - }); - return; - } - - if (rr.action === 'DENY') { - reply.status(403).send({ - error: 'Forbidden', - message: rr.reason || 'Access denied by rule', - rule: rr.rule, - }); - return; + // A rule refusal answers with the shape every adapter uses; only the + // writing of it is Fastify's business. + const block = ruleBlockResponse(result); + if (block) { + for (const [name, value] of Object.entries(block.headers)) { + reply.header(name, value); } + reply.status(block.status).send(block.body); + return; } // Handle the result diff --git a/packages/nextjs/src/middleware.ts b/packages/nextjs/src/middleware.ts index 10fefad..c9c91e5 100644 --- a/packages/nextjs/src/middleware.ts +++ b/packages/nextjs/src/middleware.ts @@ -10,6 +10,8 @@ import { ProtectOptions, resolveClientIp, normalizeIp, + shouldSkipPath, + ruleBlockResponse, } from '@webdecoy/node'; import type { TrustedProxies, ProtectResult, SDKDetectionResponse } from '@webdecoy/node'; @@ -128,22 +130,6 @@ function defaultOnError(req: NextRequest, error: Error): NextResponse | null { return null; } -/** - * Check if path should be skipped - */ -function shouldSkipPath(path: string, skipPaths?: string[] | RegExp[]): boolean { - if (!skipPaths || skipPaths.length === 0) { - return false; - } - - return skipPaths.some((pattern) => { - if (typeof pattern === 'string') { - return path === pattern || path.startsWith(pattern); - } - return pattern.test(path); - }); -} - /** * Create Next.js middleware for Web Decoy protection * @@ -224,35 +210,14 @@ export function withWebDecoy( return NextResponse.next({ request: { headers: monitorHeaders } }); } - // Handle rule engine results for specific HTTP responses - if (!result.allowed && result.ruleResult) { - const rr = result.ruleResult; - - if (rr.action === 'THROTTLE') { - const retryAfter = rr.metadata?.retryAfter ?? 60; - return NextResponse.json( - { - error: 'Too Many Requests', - message: rr.reason || 'Rate limit exceeded', - retry_after: retryAfter, - }, - { - status: 429, - headers: { 'Retry-After': String(retryAfter) }, - } - ); - } - - if (rr.action === 'DENY') { - return NextResponse.json( - { - error: 'Forbidden', - message: rr.reason || 'Access denied by rule', - rule: rr.rule, - }, - { status: 403 } - ); - } + // A rule refusal answers with the shape every adapter uses; only the + // building of the NextResponse is this adapter's business. + const block = ruleBlockResponse(result); + if (block) { + return NextResponse.json(block.body, { + status: block.status, + headers: block.headers, + }); } // Handle the result @@ -359,17 +324,17 @@ export function withBotProtection any>( }); if (!result.allowed) { - // Handle rule engine specific responses - if (result.ruleResult?.action === 'THROTTLE') { - const retryAfter = result.ruleResult.metadata?.retryAfter ?? 60; - res.setHeader('Retry-After', String(retryAfter)); - return res.status(429).json({ - error: 'Too Many Requests', - message: result.ruleResult.reason || 'Rate limit exceeded', - retry_after: retryAfter, - }); + // Same shared refusal shape as the middleware and every other adapter. + // This wrapper was the fourth copy of it. + const block = ruleBlockResponse(result); + if (block) { + for (const [name, value] of Object.entries(block.headers)) { + res.setHeader(name, value); + } + return res.status(block.status).json(block.body); } + // A server-score block names no rule, so it keeps its own shape. return res.status(403).json({ error: 'Forbidden', message: 'Access denied by Web Decoy protection', diff --git a/packages/webdecoy/src/adapter-core.ts b/packages/webdecoy/src/adapter-core.ts new file mode 100644 index 0000000..68927b3 --- /dev/null +++ b/packages/webdecoy/src/adapter-core.ts @@ -0,0 +1,152 @@ +/** + * The parts of a middleware that are the same in every framework. + * + * WHY THIS EXISTS + * + * Express, Fastify, Next.js and the fetch guard each grew their own copy of the + * same decision tree: skip-path matching, monitor-versus-enforce, honeytoken + * arming, the 429 payload, the 403 payload. Four copies of one set of rules. + * + * That is not a tidiness complaint. The leftmost-`X-Forwarded-For` bug survived + * in two adapters after the same class of bug had already been fixed elsewhere, + * precisely because there was no one place to fix. Every copy is a place the + * next correction can fail to land, and each one reads perfectly sensibly on its + * own — which is why review does not catch it. + * + * WHAT STAYS IN THE ADAPTERS + * + * Everything that touches the framework: reading metadata off its request + * object, writing its response, and injecting the honeytoken link into whatever + * that framework calls a body. Response mechanics differ genuinely — Express + * intercepts `res.write`/`res.end`, Fastify uses an `onSend` hook, a fetch + * handler rebuilds a `Response` — and the detail in each is hard-won. This + * module holds the decisions, not the I/O. + */ + +import type { ProtectResult } from './decision'; +import type { WebDecoy } from './sdk'; +import { siteHoneytoken, tripwire, type SiteHoneytoken } from './rules'; + +/** Whether a request path is exempt from protection. */ +export function shouldSkipPath( + path: string, + patterns: readonly (string | RegExp)[] | undefined, +): boolean { + if (!patterns || patterns.length === 0) return false; + return patterns.some((pattern) => + typeof pattern === 'string' + ? path === pattern || path.startsWith(pattern) + : pattern.test(path), + ); +} + +/** A framework-agnostic description of the response a blocked request gets. */ +export interface BlockResponse { + status: number; + headers: Record; + body: Record; +} + +/** + * What to answer a request the rules refused. + * + * Returns null when the decision was not a rule refusal — a server-score block + * has no rule to name, and each adapter's `onBlocked` handles that case with its + * own default. + */ +export function ruleBlockResponse(decision: ProtectResult): BlockResponse | null { + const rr = decision.ruleResult; + if (decision.allowed || !rr) return null; + + if (rr.action === 'THROTTLE') { + // Retry-After is not decoration: without it a client backs off by guessing, + // and the guess is usually "immediately". + const retryAfter = Number(rr.metadata?.retryAfter ?? 60); + return { + status: 429, + headers: { 'Retry-After': String(retryAfter) }, + body: { + error: 'Too Many Requests', + message: rr.reason || 'Rate limit exceeded', + retry_after: retryAfter, + }, + }; + } + + if (rr.action === 'DENY') { + return { + status: 403, + headers: {}, + body: { + error: 'Forbidden', + message: rr.reason || 'Access denied by rule', + rule: rr.rule, + }, + }; + } + + return null; +} + +export interface HoneytokenArmingOptions { + /** The site honeytoken is derived from this. No key, no token. */ + apiKey?: string; + /** Defaults to on when an apiKey is present. */ + enabled?: boolean; + /** Reported when derivation fails, so a silent absence is at least loggable. */ + onError?: (error: unknown) => void; +} + +/** + * Derive the site honeytoken and arm the tripwire it points at. + * + * Returns a getter rather than a promise: derivation is async (WebCrypto HMAC, + * so this still runs on edge runtimes) and requests served before it settles + * simply carry no link. That is a few milliseconds at boot against the + * alternative of blocking startup on crypto. + * + * The token is derived from the API key so every replica computes the same path + * without coordinating. A random per-process token would advertise a link whose + * tripwire only one replica had armed — bait with no trap behind it. + */ +export function armSiteHoneytoken( + sdk: WebDecoy, + options: HoneytokenArmingOptions, +): () => SiteHoneytoken | null { + let token: SiteHoneytoken | null = null; + + void deriveAndArm(sdk, options).then((t) => { + token = t; + }); + + return () => token; +} + +/** + * Derive and arm, awaited. + * + * For a framework whose registration is already an async boot phase — Fastify's + * plugin hook — where waiting costs nothing and removes the window in which + * early requests are served without the link. Same logic as + * {@link armSiteHoneytoken}; only the timing differs, which is why it is a + * second entry point rather than a second implementation. + */ +export async function deriveAndArm( + sdk: WebDecoy, + options: HoneytokenArmingOptions, +): Promise { + const enabled = (options.enabled ?? true) && Boolean(options.apiKey); + if (!enabled) return null; + + try { + const token = await siteHoneytoken({ secret: options.apiKey as string }); + // Arm the path before advertising it. Without this the link is bait with + // no trap: a crawler follows it and nothing happens. + sdk.addRule(tripwire({ paths: token.activePaths, includeDefaults: false })); + return token; + } catch (error) { + // Deriving the token is not worth a failed boot. No token, no injection. + options.onError?.(error); + return null; + } +} diff --git a/packages/webdecoy/src/fetch-guard.ts b/packages/webdecoy/src/fetch-guard.ts index f6d3e1a..84476f5 100644 --- a/packages/webdecoy/src/fetch-guard.ts +++ b/packages/webdecoy/src/fetch-guard.ts @@ -24,13 +24,8 @@ import type { WebDecoyConfig, RequestMetadata, ProtectOptions } from './types'; import type { Decision } from './decision'; import { resolveClientIp, normalizeIp } from './client-ip'; import type { TrustedProxies } from './client-ip'; -import { - siteHoneytoken, - injectHoneytokenLink, - isInjectableHtml, - tripwire, - type SiteHoneytoken, -} from './rules'; +import { injectHoneytokenLink, isInjectableHtml } from './rules'; +import { shouldSkipPath, ruleBlockResponse, armSiteHoneytoken } from './adapter-core'; export interface FetchGuardOptions extends WebDecoyConfig, ProtectOptions { /** @@ -89,42 +84,19 @@ export interface FetchGuard { } function defaultBlocked(_request: Request, decision: Decision): Response { - const throttled = decision.ruleResult?.action === 'THROTTLE'; - const retryAfter = Number(decision.ruleResult?.metadata?.retryAfter ?? 60); - - if (throttled) { - return new Response( - JSON.stringify({ - error: 'Too Many Requests', - message: decision.reason ?? 'Rate limit exceeded', - retry_after: retryAfter, - detection_id: decision.id, - }), - { - status: 429, - headers: { - 'content-type': 'application/json', - 'retry-after': String(retryAfter), - }, - }, - ); - } - - return new Response( - JSON.stringify({ - error: 'Forbidden', - message: 'Access denied by Web Decoy protection', - detection_id: decision.id, - }), - { status: 403, headers: { 'content-type': 'application/json' } }, - ); -} + // The rule refusal shape is shared with every other adapter; only the + // detection id is added here, because a fetch handler has nowhere else to + // surface it. + const block = ruleBlockResponse(decision) ?? { + status: 403, + headers: {}, + body: { error: 'Forbidden', message: 'Access denied by Web Decoy protection' }, + }; -function matches(pathname: string, patterns: (string | RegExp)[] | undefined): boolean { - if (!patterns || patterns.length === 0) return false; - return patterns.some((p) => - typeof p === 'string' ? pathname === p || pathname.startsWith(p) : p.test(pathname), - ); + return new Response(JSON.stringify({ ...block.body, detection_id: decision.id }), { + status: block.status, + headers: { 'content-type': 'application/json', ...block.headers }, + }); } function headerRecord(headers: Headers): Record { @@ -159,19 +131,10 @@ export function createFetchGuard(options: FetchGuardOptions = {}): FetchGuard { // coordinating — a random per-process token would advertise a link whose // tripwire only one replica had armed. Requests served before the async HMAC // settles simply carry no link. - let token: SiteHoneytoken | null = null; - if ((options.honeytoken ?? true) && options.apiKey) { - void siteHoneytoken({ secret: options.apiKey }) - .then((t) => { - token = t; - // Arm the path before advertising it: a link with no trap behind it is - // bait a crawler follows for nothing. - sdk.addRule(tripwire({ paths: t.activePaths, includeDefaults: false })); - }) - .catch(() => { - // Deriving the token is not worth a failed boot. - }); - } + const getToken = armSiteHoneytoken(sdk, { + apiKey: options.apiKey, + enabled: options.honeytoken, + }); function resolveIP(request: Request, peer?: string): string { if (options.getIP) return options.getIP(request); @@ -190,7 +153,7 @@ export function createFetchGuard(options: FetchGuardOptions = {}): FetchGuard { sdk, skips(pathname: string): boolean { - return matches(pathname, options.skipPaths); + return shouldSkipPath(pathname, options.skipPaths); }, async check(request: Request, peer?: string): Promise { @@ -220,7 +183,7 @@ export function createFetchGuard(options: FetchGuardOptions = {}): FetchGuard { }, async decorate(response: Response): Promise { - const current = token; + const current = getToken(); if (!current) return response; if (!isInjectableHtml(response.headers.get('content-type'))) return response; // A body already consumed by the application cannot be read again, and diff --git a/packages/webdecoy/src/index.ts b/packages/webdecoy/src/index.ts index 4568b0a..d782046 100644 --- a/packages/webdecoy/src/index.ts +++ b/packages/webdecoy/src/index.ts @@ -103,6 +103,12 @@ export type { MemoryClientSignalStoreOptions, } from './client-signals'; +// The decisions every framework middleware shares. Exported so an adapter this +// package does not ship — a custom one, or a framework added later — reaches +// for the same answers rather than reimplementing them. +export { shouldSkipPath, ruleBlockResponse, armSiteHoneytoken, deriveAndArm } from './adapter-core'; +export type { BlockResponse, HoneytokenArmingOptions } from './adapter-core'; + export { createFetchGuard } from './fetch-guard'; export type { FetchGuard, FetchGuardOptions, GuardOutcome } from './fetch-guard'; diff --git a/packages/webdecoy/src/invariants.test.ts b/packages/webdecoy/src/invariants.test.ts index 425d2fe..3e8768b 100644 --- a/packages/webdecoy/src/invariants.test.ts +++ b/packages/webdecoy/src/invariants.test.ts @@ -131,6 +131,66 @@ describe('one answer to "is this rule running"', () => { }); }); +describe('one answer to "what does a refused request get"', () => { + it('builds the rule-refusal response only in adapter-core.ts', () => { + // Four adapters each had their own copy of the 429 and 403 payloads, the + // skip-path matcher and the honeytoken arming. Every copy is a place the + // next correction can fail to land — which is exactly how the + // leftmost-X-Forwarded-For bug outlived its own fix in two of them. + const core = join(PACKAGES, 'webdecoy', 'src', 'adapter-core.ts'); + const offenders = sourceFiles() + .filter((f) => f !== core) + .flatMap((f) => hits(f, /'Too Many Requests'|"Too Many Requests"/)); + + if (offenders.length > 0) { + throw new Error( + 'These build a rule-refusal response themselves instead of calling\n' + + 'ruleBlockResponse(). One shape, one place — a second copy is a second\n' + + 'thing to keep in step, and it will not be kept in step.\n\n ' + + offenders.join('\n '), + ); + } + }); + + it('matches skip paths only in adapter-core.ts', () => { + const core = join(PACKAGES, 'webdecoy', 'src', 'adapter-core.ts'); + const offenders = sourceFiles() + .filter((f) => f !== core) + .flatMap((f) => hits(f, /function shouldSkipPath|function matches\(pathname/)); + + if (offenders.length > 0) { + throw new Error( + 'These reimplement skip-path matching instead of importing\n' + + 'shouldSkipPath() from the shared core.\n\n ' + offenders.join('\n '), + ); + } + }); + + it('derives the site honeytoken only in adapter-core.ts', () => { + // Deriving without arming the tripwire it points at advertises bait with no + // trap behind it. Keeping the pair in one place is what stops the two + // drifting apart in a copy. + const core = join(PACKAGES, 'webdecoy', 'src', 'adapter-core.ts'); + // nextjs/honeytoken.ts derives without arming, on purpose: it is the + // render-side helper an App Router layout calls to print the link, and the + // developer arms the tripwire in their middleware with the same + // activePaths. Both sides derive the same HMAC from the API key, which is + // what makes them agree. It is a second caller, not a second answer. + const renderSide = join(PACKAGES, 'nextjs', 'src', 'honeytoken.ts'); + const offenders = sourceFiles() + .filter((f) => f !== core && f !== renderSide) + .flatMap((f) => hits(f, /siteHoneytoken\(\{/)); + + if (offenders.length > 0) { + throw new Error( + 'These derive the site honeytoken themselves. Use armSiteHoneytoken()\n' + + 'or deriveAndArm(), which arm the tripwire in the same step.\n\n ' + + offenders.join('\n '), + ); + } + }); +}); + describe('the edge build stays edge-compatible', () => { it('no node: import reaches a package that ships to Workers', () => { // check:edge catches this at build time, but only for the entry points it