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
86 changes: 22 additions & 64 deletions packages/express/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@
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 {
Expand Down Expand Up @@ -169,22 +169,6 @@
// 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
*
Expand Down Expand Up @@ -218,27 +202,13 @@
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;

Expand Down Expand Up @@ -282,8 +252,10 @@
// - 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[] = [];
Expand Down Expand Up @@ -312,13 +284,13 @@
return intercepting;
};

(res as any).write = function (chunk: any, ...rest: any[]): boolean {

Check warning on line 287 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 287 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 287 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 287 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 287 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 287 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
if (!shouldIntercept()) return originalWrite(chunk, ...rest);
if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return true;
};

(res as any).end = function (chunk: any, ...rest: any[]): any {

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
try {
if (!shouldIntercept()) return originalEnd(chunk, ...rest);
if (chunk && typeof chunk !== 'function') {
Expand Down Expand Up @@ -353,34 +325,20 @@
// name in every adapter, which `webdecoy` cannot.
req.webdecoy = result.detection;
req.webdecoyDecision = result;
(req as any).webdecoyEdge = result.edge;

Check warning on line 328 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 328 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
(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;

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
Expand Down
70 changes: 17 additions & 53 deletions packages/fastify/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import {
WebDecoyConfig,
RequestMetadata,
ProtectOptions,
siteHoneytoken,
injectHoneytokenLink,
isInjectableHtml,
tripwire,
resolveClientIp,
normalizeIp,
shouldSkipPath,
ruleBlockResponse,
deriveAndArm,
} from '@webdecoy/node';
import type {
EdgeVerdict,
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
73 changes: 19 additions & 54 deletions packages/nextjs/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
ProtectOptions,
resolveClientIp,
normalizeIp,
shouldSkipPath,
ruleBlockResponse,
} from '@webdecoy/node';
import type { TrustedProxies, ProtectResult, SDKDetectionResponse } from '@webdecoy/node';

Expand Down Expand Up @@ -128,22 +130,6 @@
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
*
Expand Down Expand Up @@ -224,35 +210,14 @@
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
Expand Down Expand Up @@ -320,7 +285,7 @@
* });
* ```
*/
export function withBotProtection<T extends (...args: any[]) => any>(

Check warning on line 288 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 288 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 288 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 288 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
handler: T,
config: WebDecoyConfig & WithBotProtectionOptions
): T {
Expand Down Expand Up @@ -359,17 +324,17 @@
});

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',
Expand Down
Loading