Skip to content

Commit 8d6db2d

Browse files
committed
refactor: one adapter core, so a fifth copy cannot drift
Express, Fastify, Next.js (twice — the middleware and the Pages wrapper) and the fetch guard each carried their own copy of the same decisions: skip-path matching, the 429 payload, the 403 payload, honeytoken arming. Five copies of one set of rules. Not a tidiness complaint. The leftmost-X-Forwarded-For bug survived in two adapters after the same class had 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 reads sensibly on its own, which is why review does not catch it. adapter-core.ts holds the decisions. The adapters keep the I/O, which differs 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. Fastify keeps its awaited arming. It derives during plugin registration, which is already an async boot phase, so it has no window where early requests are served without the link -- forcing it onto the fire-and-forget getter would have REGRESSED it. deriveAndArm() is the same logic with the other timing, not a second implementation. Every existing honeytoken-injection test passes unchanged, which was the acceptance criterion: the response mechanics are untouched. Three invariants stop the sixth copy, and writing them found a false positive worth keeping: nextjs/honeytoken.ts derives a token without arming, deliberately -- it is the render-side helper a layout calls, and the developer arms in middleware from the same HMAC. A second caller, not a second answer. Excluded with that reason rather than by loosening the rule. Closes WebDecoy/app#739
1 parent ff61975 commit 8d6db2d

7 files changed

Lines changed: 296 additions & 228 deletions

File tree

packages/express/src/middleware.ts

Lines changed: 22 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,18 @@ import { Request, Response, NextFunction } from 'express';
66
import { WebDecoy, WebDecoyConfig, RequestMetadata, ProtectOptions } from '@webdecoy/node';
77
import type {
88
EdgeVerdict,
9-
SiteHoneytoken,
109
TrustedProxies,
1110
ProtectResult,
1211
SDKDetectionResponse,
1312
} from '@webdecoy/node';
1413
import {
15-
siteHoneytoken,
1614
injectHoneytokenLink,
1715
isInjectableHtml,
18-
tripwire,
1916
resolveClientIp,
2017
normalizeIp,
18+
shouldSkipPath,
19+
ruleBlockResponse,
20+
armSiteHoneytoken,
2121
} from '@webdecoy/node';
2222

2323
export interface WebDecoyMiddlewareOptions extends ProtectOptions {
@@ -169,22 +169,6 @@ function defaultOnError(req: Request, res: Response, error: Error): void {
169169
// Fail open - allow the request to continue
170170
}
171171

172-
/**
173-
* Check if path should be skipped
174-
*/
175-
function shouldSkipPath(path: string, skipPaths?: string[] | RegExp[]): boolean {
176-
if (!skipPaths || skipPaths.length === 0) {
177-
return false;
178-
}
179-
180-
return skipPaths.some((pattern) => {
181-
if (typeof pattern === 'string') {
182-
return path === pattern || path.startsWith(pattern);
183-
}
184-
return pattern.test(path);
185-
});
186-
}
187-
188172
/**
189173
* Create Express middleware for Web Decoy protection
190174
*
@@ -218,27 +202,13 @@ export function webdecoy(
218202
const onBlocked = config.onBlocked || defaultOnBlocked;
219203
const mode = config.mode ?? 'monitor';
220204

221-
// Honeytoken. Derived from the API key so every replica computes the
222-
// same path without coordinating — a random per-process token would advertise
223-
// a link whose tripwire only one replica had armed.
224-
//
225-
// Resolution is async (WebCrypto HMAC, so this still runs on edge runtimes),
226-
// and requests served before it settles simply carry no link. That is a few
227-
// milliseconds at boot against the alternative of blocking startup on crypto.
228-
const honeytokenEnabled = (config.honeytoken ?? true) && Boolean(config.apiKey);
229-
let token: SiteHoneytoken | null = null;
230-
if (honeytokenEnabled) {
231-
void siteHoneytoken({ secret: config.apiKey as string })
232-
.then((t) => {
233-
token = t;
234-
// Arm the path we are about to advertise. Without this the link is bait
235-
// with no trap behind it — a crawler follows it and nothing happens.
236-
sdk.addRule(tripwire({ paths: t.activePaths, includeDefaults: false }));
237-
})
238-
.catch(() => {
239-
// Deriving the token is not worth a failed boot. No token, no injection.
240-
});
241-
}
205+
// Honeytoken arming lives in the shared core: every adapter derived the same
206+
// token the same way, and a fourth copy is a fourth place the next change can
207+
// fail to land.
208+
const getToken = armSiteHoneytoken(sdk, {
209+
apiKey: config.apiKey,
210+
enabled: config.honeytoken,
211+
});
242212
const onError = config.onError || defaultOnError;
243213
const skipPaths = config.skipPaths;
244214

@@ -282,8 +252,10 @@ export function webdecoy(
282252
// - a committed response is left alone, because headers are already sent
283253
// - Content-Length is corrected, or the client truncates the body
284254
// - anything thrown falls back to the original write
285-
if (token) {
286-
const ht = token;
255+
// Read once: the getter can settle between calls, and an injected link
256+
// whose tripwire was armed a moment later is bait with no trap.
257+
const ht = getToken();
258+
if (ht) {
287259
const originalWrite = res.write.bind(res);
288260
const originalEnd = res.end.bind(res);
289261
const chunks: Buffer[] = [];
@@ -358,29 +330,15 @@ export function webdecoy(
358330
return next();
359331
}
360332

361-
// Handle rule engine results for specific HTTP responses
362-
if (!result.allowed && result.ruleResult) {
363-
const rr = result.ruleResult;
364-
365-
if (rr.action === 'THROTTLE') {
366-
const retryAfter = rr.metadata?.retryAfter ?? 60;
367-
res.setHeader('Retry-After', String(retryAfter));
368-
res.status(429).json({
369-
error: 'Too Many Requests',
370-
message: rr.reason || 'Rate limit exceeded',
371-
retry_after: retryAfter,
372-
});
373-
return;
374-
}
375-
376-
if (rr.action === 'DENY') {
377-
res.status(403).json({
378-
error: 'Forbidden',
379-
message: rr.reason || 'Access denied by rule',
380-
rule: rr.rule,
381-
});
382-
return;
333+
// A rule refusal answers with the shape every adapter uses; only the
334+
// writing of it is Express's business.
335+
const block = ruleBlockResponse(result);
336+
if (block) {
337+
for (const [name, value] of Object.entries(block.headers)) {
338+
res.setHeader(name, value);
383339
}
340+
res.status(block.status).json(block.body);
341+
return;
384342
}
385343

386344
// Handle the result

packages/fastify/src/plugin.ts

Lines changed: 17 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import {
99
WebDecoyConfig,
1010
RequestMetadata,
1111
ProtectOptions,
12-
siteHoneytoken,
1312
injectHoneytokenLink,
1413
isInjectableHtml,
15-
tripwire,
1614
resolveClientIp,
1715
normalizeIp,
16+
shouldSkipPath,
17+
ruleBlockResponse,
18+
deriveAndArm,
1819
} from '@webdecoy/node';
1920
import type {
2021
EdgeVerdict,
@@ -153,22 +154,6 @@ function defaultOnError(req: FastifyRequest, reply: FastifyReply, error: Error):
153154
// Fail open - allow the request to continue
154155
}
155156

156-
/**
157-
* Check if path should be skipped
158-
*/
159-
function shouldSkipPath(path: string, skipPaths?: string[] | RegExp[]): boolean {
160-
if (!skipPaths || skipPaths.length === 0) {
161-
return false;
162-
}
163-
164-
return skipPaths.some((pattern) => {
165-
if (typeof pattern === 'string') {
166-
return path === pattern || path.startsWith(pattern);
167-
}
168-
return pattern.test(path);
169-
});
170-
}
171-
172157
/**
173158
* Web Decoy detection info attached to requests
174159
*/
@@ -235,19 +220,12 @@ async function webdecoyPluginImpl(
235220
// Fastify lets us await it here, because plugin registration is already an
236221
// async boot phase — so unlike Express there is no window where early requests
237222
// are served without the link.
238-
const honeytokenEnabled = (options.honeytoken ?? true) && Boolean(options.apiKey);
239-
let token: SiteHoneytoken | null = null;
240-
if (honeytokenEnabled) {
241-
try {
242-
token = await siteHoneytoken({ secret: options.apiKey as string });
243-
// Arm the path we are about to advertise. Without this the link is bait
244-
// with no trap behind it — a crawler follows it and nothing happens.
245-
sdk.addRule(tripwire({ paths: token.activePaths, includeDefaults: false }));
246-
} catch {
247-
// Deriving the token is not worth a failed boot. No token, no injection.
248-
token = null;
249-
}
250-
}
223+
// The awaited variant, because plugin registration is already an async boot
224+
// phase. Same derive-and-arm as every other adapter; only the timing differs.
225+
const token: SiteHoneytoken | null = await deriveAndArm(sdk, {
226+
apiKey: options.apiKey,
227+
enabled: options.honeytoken,
228+
});
251229

252230
// Add decorator for webdecoy property
253231
fastify.decorateRequest('webdecoy', null);
@@ -305,29 +283,15 @@ async function webdecoyPluginImpl(
305283
return;
306284
}
307285

308-
// Handle rule engine results for specific HTTP responses
309-
if (!result.allowed && result.ruleResult) {
310-
const rr = result.ruleResult;
311-
312-
if (rr.action === 'THROTTLE') {
313-
const retryAfter = rr.metadata?.retryAfter ?? 60;
314-
reply.header('Retry-After', String(retryAfter));
315-
reply.status(429).send({
316-
error: 'Too Many Requests',
317-
message: rr.reason || 'Rate limit exceeded',
318-
retry_after: retryAfter,
319-
});
320-
return;
321-
}
322-
323-
if (rr.action === 'DENY') {
324-
reply.status(403).send({
325-
error: 'Forbidden',
326-
message: rr.reason || 'Access denied by rule',
327-
rule: rr.rule,
328-
});
329-
return;
286+
// A rule refusal answers with the shape every adapter uses; only the
287+
// writing of it is Fastify's business.
288+
const block = ruleBlockResponse(result);
289+
if (block) {
290+
for (const [name, value] of Object.entries(block.headers)) {
291+
reply.header(name, value);
330292
}
293+
reply.status(block.status).send(block.body);
294+
return;
331295
}
332296

333297
// Handle the result

packages/nextjs/src/middleware.ts

Lines changed: 19 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
ProtectOptions,
1111
resolveClientIp,
1212
normalizeIp,
13+
shouldSkipPath,
14+
ruleBlockResponse,
1315
} from '@webdecoy/node';
1416
import type { TrustedProxies, ProtectResult, SDKDetectionResponse } from '@webdecoy/node';
1517

@@ -128,22 +130,6 @@ function defaultOnError(req: NextRequest, error: Error): NextResponse | null {
128130
return null;
129131
}
130132

131-
/**
132-
* Check if path should be skipped
133-
*/
134-
function shouldSkipPath(path: string, skipPaths?: string[] | RegExp[]): boolean {
135-
if (!skipPaths || skipPaths.length === 0) {
136-
return false;
137-
}
138-
139-
return skipPaths.some((pattern) => {
140-
if (typeof pattern === 'string') {
141-
return path === pattern || path.startsWith(pattern);
142-
}
143-
return pattern.test(path);
144-
});
145-
}
146-
147133
/**
148134
* Create Next.js middleware for Web Decoy protection
149135
*
@@ -224,35 +210,14 @@ export function withWebDecoy(
224210
return NextResponse.next({ request: { headers: monitorHeaders } });
225211
}
226212

227-
// Handle rule engine results for specific HTTP responses
228-
if (!result.allowed && result.ruleResult) {
229-
const rr = result.ruleResult;
230-
231-
if (rr.action === 'THROTTLE') {
232-
const retryAfter = rr.metadata?.retryAfter ?? 60;
233-
return NextResponse.json(
234-
{
235-
error: 'Too Many Requests',
236-
message: rr.reason || 'Rate limit exceeded',
237-
retry_after: retryAfter,
238-
},
239-
{
240-
status: 429,
241-
headers: { 'Retry-After': String(retryAfter) },
242-
}
243-
);
244-
}
245-
246-
if (rr.action === 'DENY') {
247-
return NextResponse.json(
248-
{
249-
error: 'Forbidden',
250-
message: rr.reason || 'Access denied by rule',
251-
rule: rr.rule,
252-
},
253-
{ status: 403 }
254-
);
255-
}
213+
// A rule refusal answers with the shape every adapter uses; only the
214+
// building of the NextResponse is this adapter's business.
215+
const block = ruleBlockResponse(result);
216+
if (block) {
217+
return NextResponse.json(block.body, {
218+
status: block.status,
219+
headers: block.headers,
220+
});
256221
}
257222

258223
// Handle the result
@@ -359,17 +324,17 @@ export function withBotProtection<T extends (...args: any[]) => any>(
359324
});
360325

361326
if (!result.allowed) {
362-
// Handle rule engine specific responses
363-
if (result.ruleResult?.action === 'THROTTLE') {
364-
const retryAfter = result.ruleResult.metadata?.retryAfter ?? 60;
365-
res.setHeader('Retry-After', String(retryAfter));
366-
return res.status(429).json({
367-
error: 'Too Many Requests',
368-
message: result.ruleResult.reason || 'Rate limit exceeded',
369-
retry_after: retryAfter,
370-
});
327+
// Same shared refusal shape as the middleware and every other adapter.
328+
// This wrapper was the fourth copy of it.
329+
const block = ruleBlockResponse(result);
330+
if (block) {
331+
for (const [name, value] of Object.entries(block.headers)) {
332+
res.setHeader(name, value);
333+
}
334+
return res.status(block.status).json(block.body);
371335
}
372336

337+
// A server-score block names no rule, so it keeps its own shape.
373338
return res.status(403).json({
374339
error: 'Forbidden',
375340
message: 'Access denied by Web Decoy protection',

0 commit comments

Comments
 (0)