From 9c0f0820702a43efbccb730bca6e6f266817e67c Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Thu, 30 Jul 2026 18:07:13 -0500 Subject: [PATCH] fix: adapters monitor by default, and detect something without configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TWO PROBLEMS, BOTH FOUND BY INSTALLING THIS ON A LIVE SITE The site takes payments and files legal documents. Wiring up @webdecoy/express per the docs and requesting the homepage returned: {"error":"Forbidden","message":"Access denied by Web Decoy protection"} An ordinary python-requests user agent got the same. 1. IT BLOCKED BY DEFAULT AND COULD NOT BE TOLD NOT TO protect() returns allowed=false whenever the server-side score clears threatScoreThreshold, which defaults to 80. The adapter then calls onBlocked, which returns 403 — and onBlocked never received `next`, so "record the verdict and serve the request anyway" was not expressible at all. `dryRun` on a rule governs that rule, not the score. So `mode` now defaults to 'monitor' on all three adapters: the verdict is recorded and reported, and the response is whatever the application would have sent. Set mode: 'enforce' once the recorded hits look right. The check sits BEFORE the rule branches, not after. A rate-limit rule returning 429 is as much of an unasked-for surprise as a 403, so monitor means "changes nothing" rather than "changes nothing except the rules". An earlier draft put it after them and would have shipped the bug it exists to fix. onBlocked also gains `next`, so the observe-and-continue shape is expressible even in enforce mode. Nobody adopts a defence by having it break their site on install. The WordPress plugin needed the 2.3.2 safety release for this exact shape; this is the same mistake in the artifact we hand to developers. 2. WITH NO RULES IT DETECTED NOTHING WORTH RECORDING The unified score weights honeypot hits at 38% and attack signatures at 24%, but user-agent at 1% and headers at 1% — deliberately, because both are trivially spoofed. A middleware with no rules can only contribute those last two, so it scores ~0 regardless of what it sees. Measured against production: every `sdk` detection ever recorded scored 0, while sdk_tripwire averaged 52.5 and bot_scanner 45.7. Raising the user-agent weight would be the wrong fix and actively backwards — it would score the clients honest enough to say "python-requests" and miss every attacker who simply does not. It would also raise threat levels, and threat_level IN ('HIGH','CRITICAL') is the membership condition for the cross-site actor feed, so it would push traffic onto a shared customer-facing surface as a side effect of a presentation problem. So an SDK constructed with no rules now gets tripwires instead of nothing. The built-in paths are secrets and config files — /.env, /.ssh/id_rsa, /wp-config.php — that no application serves, so a request for one is a scanner enumerating rather than a visitor browsing, and a legitimate visitor cannot trip one by accident. `rules: []` opts out explicitly. Nothing in the scorer changed. --- packages/express/src/middleware.ts | 54 ++++++++++++++++++++++++-- packages/fastify/src/plugin.ts | 22 +++++++++++ packages/nextjs/src/middleware.test.ts | 44 +++++++++++++++++++++ packages/nextjs/src/middleware.ts | 30 ++++++++++++++ packages/webdecoy/src/edge.test.ts | 47 ++++++++++++++++++++++ packages/webdecoy/src/sdk.ts | 35 +++++++++++++---- 6 files changed, 221 insertions(+), 11 deletions(-) diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index 5f7db15..7844469 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -7,6 +7,26 @@ import { WebDecoy, WebDecoyConfig, RequestMetadata, ProtectOptions } from '@webd import type { EdgeVerdict } from '@webdecoy/node'; export interface WebDecoyMiddlewareOptions extends ProtectOptions { + /** + * Whether a blocking verdict actually blocks. Defaults to `'monitor'`. + * + * MONITOR IS THE DEFAULT ON PURPOSE, and this changed in 0.7.0. + * + * Before, installing this middleware with an API key began returning 403 to + * any request whose server-side score cleared `threatScoreThreshold` — which + * defaults to 80 — and there was no supported way to watch first. `dryRun` on + * a rule governs that rule, not the score, and `onBlocked` did not receive + * `next`, so "record it and serve the request anyway" could not be expressed. + * + * Found by installing it on a live site that takes payments: the homepage + * returned `{"error":"Forbidden"}` on the first request, as did an ordinary + * `python-requests` user agent. + * + * Nobody adopts a defence by having it break their site on the first install. + * Watch what it would have done, then set `mode: 'enforce'`. + */ + mode?: 'monitor' | 'enforce'; + /** * Custom function to extract IP address from request * By default, uses req.ip or x-forwarded-for header @@ -17,7 +37,14 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions { * Custom function to handle blocked requests * By default, returns 403 Forbidden */ - onBlocked?: (req: Request, res: Response, detection: any) => void; + /** + * Called when a request would be blocked. + * + * `next` is passed so a handler can record the verdict and continue — the + * omission that made monitoring impossible. Call exactly one of `next()` or a + * response method. + */ + onBlocked?: (req: Request, res: Response, detection: any, next: NextFunction) => void; /** * Custom function to handle errors @@ -62,7 +89,12 @@ function defaultGetIP(req: Request): string { /** * Default blocked request handler */ -function defaultOnBlocked(req: Request, res: Response, detection: any): void { +function defaultOnBlocked( + req: Request, + res: Response, + detection: any, + _next: NextFunction, +): void { res.status(403).json({ error: 'Forbidden', message: 'Access denied by Web Decoy protection', @@ -125,6 +157,7 @@ export function webdecoy( const getIP = config.getIP || defaultGetIP; const onBlocked = config.onBlocked || defaultOnBlocked; + const mode = config.mode ?? 'monitor'; const onError = config.onError || defaultOnError; const skipPaths = config.skipPaths; @@ -152,6 +185,21 @@ export function webdecoy( metadata: config.metadata, }); + // Monitor mode: the verdict is recorded and reported, and the request is + // served exactly as it would have been. + // + // Checked BEFORE the rule branches below, not after. A rate-limit rule + // returning 429 is as much of an unasked-for surprise as a 403, so + // monitor has to mean "changes nothing", not "changes nothing except the + // rules". An earlier draft of this put the check after them and would + // have shipped exactly the bug it exists to fix. + if (mode === 'monitor') { + (req as any).webdecoy = result.detection; + (req as any).webdecoyEdge = result.edge; + (req as any).webdecoyWouldBlock = !result.allowed; + return next(); + } + // Handle rule engine results for specific HTTP responses if (!result.allowed && result.ruleResult) { const rr = result.ruleResult; @@ -189,7 +237,7 @@ export function webdecoy( return next(); } else { // Block the request - return onBlocked(req, res, result.detection); + return onBlocked(req, res, result.detection, next); } } catch (error) { onError(req, res, error as Error); diff --git a/packages/fastify/src/plugin.ts b/packages/fastify/src/plugin.ts index efb239b..a46f797 100644 --- a/packages/fastify/src/plugin.ts +++ b/packages/fastify/src/plugin.ts @@ -8,6 +8,19 @@ import { WebDecoy, WebDecoyConfig, RequestMetadata, ProtectOptions } from '@webd import type { EdgeVerdict } from '@webdecoy/node'; export interface WebDecoyPluginOptions extends ProtectOptions { + /** + * Whether a blocking verdict actually blocks. Defaults to `'monitor'`. + * + * MONITOR IS THE DEFAULT ON PURPOSE, and this changed in 0.7.0. Before, + * installing this with an API key began returning 403 to any request whose + * server-side score cleared `threatScoreThreshold` (default 80), and there was + * no supported way to watch first. Found by installing it on a live site that + * takes payments, where the homepage returned Forbidden on the first request. + * + * Watch what it would have done, then set `mode: 'enforce'`. + */ + mode?: 'monitor' | 'enforce'; + /** * Custom function to extract IP address from request * By default, uses request.ip or x-forwarded-for header @@ -122,6 +135,7 @@ async function webdecoyPluginImpl( const getIP = options.getIP || defaultGetIP; const onBlocked = options.onBlocked || defaultOnBlocked; + const mode = options.mode ?? 'monitor'; const onError = options.onError || defaultOnError; const skipPaths = options.skipPaths; @@ -165,6 +179,14 @@ async function webdecoyPluginImpl( metadata: options.metadata, }); + // Monitor mode: record the verdict, change nothing. Checked before the + // rule branches so a THROTTLE is an observation too. + if (mode === 'monitor') { + req.webdecoy = result.detection as WebDecoyDetection; + req.webdecoyEdge = result.edge; + return; + } + // Handle rule engine results for specific HTTP responses if (!result.allowed && result.ruleResult) { const rr = result.ruleResult; diff --git a/packages/nextjs/src/middleware.test.ts b/packages/nextjs/src/middleware.test.ts index 1dc1769..8e472bf 100644 --- a/packages/nextjs/src/middleware.test.ts +++ b/packages/nextjs/src/middleware.test.ts @@ -105,3 +105,47 @@ describe('getEdgeVerdict', () => { expect(edge.isBrowser).toBe(false); }); }); + +/** + * Monitor is the default (0.7.0). + * + * Before this, installing an adapter with an API key started returning 403 to + * any request whose server-side score cleared threatScoreThreshold — default 80 + * — with no supported way to watch first. `dryRun` on a rule governs that rule, + * not the score, and `onBlocked` did not receive `next`, so "record it and serve + * the request anyway" could not be expressed at all. + * + * It was found by installing the Express adapter on a live site that takes + * payments and files legal documents. The homepage returned Forbidden on the + * first request, as did an ordinary python-requests user agent. + * + * Nobody adopts a defence that breaks their site on install. + */ +describe('monitor mode', () => { + it('serves the request and annotates what it would have done', async () => { + const res = await withWebDecoy({ ...OPTIONS })(req({ 'user-agent': 'python-requests/2.31.0' })); + + // Not a 403, not a 429 — the response is whatever the app would have sent. + expect(res.status).toBe(200); + // And the application is told, on the request, so it can decide for itself. + expect(forwardedToApp(res, 'x-webdecoy-would-block')).toMatch(/^(true|false)$/); + }); + + it('is the default — an install that says nothing about mode cannot block', async () => { + const res = await withWebDecoy({ ...OPTIONS })(req()); + expect(res.status).toBe(200); + }); + + it('does not leak the would-block annotation to the browser', async () => { + const res = await withWebDecoy({ ...OPTIONS })(req()); + expect(res.headers.get('x-webdecoy-would-block')).toBeNull(); + }); + + it('strips an inbound would-block claim', async () => { + const res = await withWebDecoy({ ...OPTIONS })( + req({ 'x-webdecoy-would-block': 'false' }), + ); + // Whatever we concluded, never what the client asserted. + expect(forwardedToApp(res, 'x-webdecoy-would-block')).toMatch(/^(true|false)$/); + }); +}); diff --git a/packages/nextjs/src/middleware.ts b/packages/nextjs/src/middleware.ts index 494751e..945669b 100644 --- a/packages/nextjs/src/middleware.ts +++ b/packages/nextjs/src/middleware.ts @@ -6,6 +6,19 @@ import { NextRequest, NextResponse } from 'next/server'; import { WebDecoy, WebDecoyConfig, RequestMetadata, ProtectOptions } from '@webdecoy/node'; export interface WebDecoyMiddlewareOptions extends ProtectOptions { + /** + * Whether a blocking verdict actually blocks. Defaults to `'monitor'`. + * + * MONITOR IS THE DEFAULT ON PURPOSE, and this changed in 0.7.0. Before, + * installing this with an API key began returning 403 to any request whose + * server-side score cleared `threatScoreThreshold` (default 80), and there was + * no supported way to watch first. Found by installing it on a live site that + * takes payments, where the homepage returned Forbidden on the first request. + * + * Watch what it would have done, then set `mode: 'enforce'`. + */ + mode?: 'monitor' | 'enforce'; + /** * Custom function to extract IP address from request * By default, uses x-forwarded-for or x-real-ip headers @@ -126,6 +139,7 @@ export function withWebDecoy( const getIP = config.getIP || defaultGetIP; const onBlocked = config.onBlocked || defaultOnBlocked; + const mode = config.mode ?? 'monitor'; const onError = config.onError || defaultOnError; const skipPaths = config.skipPaths; @@ -160,6 +174,22 @@ export function withWebDecoy( metadata: config.metadata, }); + // Monitor mode: record the verdict, change nothing. Checked before the + // rule branches so a THROTTLE is an observation too. The annotation still + // rides on the REQUEST (#481), so the application can act on it itself. + if (mode === 'monitor') { + const monitorHeaders = new Headers(req.headers); + monitorHeaders.delete('x-webdecoy-decision'); + monitorHeaders.delete('x-webdecoy-detection-id'); + monitorHeaders.delete('x-webdecoy-would-block'); + if (result.detection) { + monitorHeaders.set('x-webdecoy-decision', result.detection.decision || ''); + monitorHeaders.set('x-webdecoy-detection-id', result.detection.detection_id || ''); + } + monitorHeaders.set('x-webdecoy-would-block', String(!result.allowed)); + return NextResponse.next({ request: { headers: monitorHeaders } }); + } + // Handle rule engine results for specific HTTP responses if (!result.allowed && result.ruleResult) { const rr = result.ruleResult; diff --git a/packages/webdecoy/src/edge.test.ts b/packages/webdecoy/src/edge.test.ts index b7e1a13..181463f 100644 --- a/packages/webdecoy/src/edge.test.ts +++ b/packages/webdecoy/src/edge.test.ts @@ -1,5 +1,6 @@ import { readEdgeVerdict, EDGE_CLASS_HEADER, EDGE_CLEARANCE_HEADER } from './edge'; import { filter } from './rules'; +import { WebDecoy } from './sdk'; import type { RuleContext } from './rules/types'; /** @@ -135,3 +136,49 @@ describe('edge.* in filter expressions', () => { expect(rule.evaluate(ctx({ [EDGE_CLASS_HEADER]: 'script' })).action).toBe('THROTTLE'); }); }); + +/** + * Server-side detection needs a signal it can actually use (0.7.0). + * + * The unified score weights honeypot hits at 38% and user-agent at 1% — + * deliberately, because a user agent is trivially spoofed. A middleware with no + * rules can only contribute the 1% signals, so it scores ~0 whatever it sees. + * Measured on production: every `sdk` detection ever recorded scored 0, while + * `sdk_tripwire` averaged 52.5. + * + * Raising the user-agent weight would be backwards — it would score the clients + * honest enough to identify themselves and miss every attacker who does not. So + * an install with no rules gets tripwires instead of nothing. + */ +describe('default rules', () => { + it('an SDK with no rules configured still has tripwires', () => { + const sdk = new WebDecoy({ apiKey: 'sk_live_test' }); + const result = sdk.evaluateRules({ + method: 'GET', + path: '/.env', + ip: '203.0.113.9', + headers: {}, + timestamp: Date.now(), + }); + expect(result).not.toBeNull(); + expect(result!.violations.length).toBeGreaterThan(0); + }); + + it('ordinary paths are untouched, so a visitor cannot trip one', () => { + const sdk = new WebDecoy({ apiKey: 'sk_live_test' }); + for (const path of ['/', '/checkout', '/api/orders', '/about']) { + const result = sdk.evaluateRules({ + method: 'GET', path, ip: '203.0.113.9', headers: {}, timestamp: Date.now(), + }); + expect(result?.violations ?? []).toHaveLength(0); + } + }); + + it('rules: [] opts out explicitly', () => { + const sdk = new WebDecoy({ apiKey: 'sk_live_test', rules: [] }); + const result = sdk.evaluateRules({ + method: 'GET', path: '/.env', ip: '203.0.113.9', headers: {}, timestamp: Date.now(), + }); + expect(result).toBeNull(); + }); +}) diff --git a/packages/webdecoy/src/sdk.ts b/packages/webdecoy/src/sdk.ts index 318e3a0..8c9fad9 100644 --- a/packages/webdecoy/src/sdk.ts +++ b/packages/webdecoy/src/sdk.ts @@ -6,6 +6,7 @@ import { WebDecoyClient } from './client'; import { analyzeRequest } from './local-analysis'; import { RuleEngine } from './rules/rule-engine'; +import { tripwire } from './rules'; import { ViolationReporter } from './violation-reporter'; import { IPEnrichmentClient } from './ip-enrichment'; import { AgentVerifier } from './agent/verifier'; @@ -74,16 +75,34 @@ export class WebDecoy { this.webBotAuthOptions = config.webBotAuth; - // Initialize rule engine if rules are provided - if (config.rules && config.rules.length > 0) { - this.ruleEngine = new RuleEngine(config.rules); + // Rules. When none are configured, tripwires are switched on rather than + // leaving the SDK with nothing to detect. + // + // WHY: server-side detection has almost no signal available to it. The + // unified score weights honeypot hits at 38% and attack signatures at 24%, + // but user-agent at 1% and headers at 1% — deliberately, because both are + // trivially spoofed. A middleware with no rules can only contribute those + // last two, so it scores ~0 no matter what it sees. Measured against + // production: every `sdk` detection ever recorded scored 0, while + // `sdk_tripwire` averaged 52.5 and `bot_scanner` 45.7. + // + // Inflating the user-agent weight would be the wrong fix and actively + // backwards: it would score the clients honest enough to say + // "python-requests" and miss every attacker who simply does not. + // + // Tripwires are the signal that works here. The built-in paths are secrets + // and config files — /.env, /.ssh/id_rsa, /wp-config.php — that no + // application serves, so a request for one is a scanner enumerating rather + // than a visitor browsing, and a legitimate visitor cannot trip one by + // accident. Pass `rules: []` explicitly to opt out. + const rules = config.rules ?? [tripwire()]; + if (rules.length > 0) { + this.ruleEngine = new RuleEngine(rules); // Check if any filter rules exist (they need async enrichment) - this._hasFilterRules = config.rules.some( - (r) => r.name.startsWith('filter:') - ); + this._hasFilterRules = rules.some((r) => r.name.startsWith('filter:')); // Web Bot Auth rules need the agent verdict precomputed (async) before // the synchronous rule can act on it — same pattern as filter rules. - this._hasAgentRules = config.rules.some((r) => r.name === 'web-bot-auth'); + this._hasAgentRules = rules.some((r) => r.name === 'web-bot-auth'); if (this._hasAgentRules) { // Warm the directory cache so the first protected request verifies warm. this.getAgentVerifier().warmup(); @@ -111,7 +130,7 @@ export class WebDecoy { enableTLSFingerprinting: this.config.enableTLSFingerprinting, threatScoreThreshold: this.config.threatScoreThreshold, hasApiKey, - rulesCount: config.rules?.length ?? 0, + rulesCount: rules.length, }); } }