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
54 changes: 51 additions & 3 deletions packages/express/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions packages/fastify/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
44 changes: 44 additions & 0 deletions packages/nextjs/src/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)$/);
});
});
30 changes: 30 additions & 0 deletions packages/nextjs/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions packages/webdecoy/src/edge.test.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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();
});
})
35 changes: 27 additions & 8 deletions packages/webdecoy/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -111,7 +130,7 @@ export class WebDecoy {
enableTLSFingerprinting: this.config.enableTLSFingerprinting,
threatScoreThreshold: this.config.threatScoreThreshold,
hasApiKey,
rulesCount: config.rules?.length ?? 0,
rulesCount: rules.length,
});
}
}
Expand Down