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, }); } }