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
8 changes: 8 additions & 0 deletions packages/express/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
};
160 changes: 160 additions & 0 deletions packages/express/src/honeytoken-injection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import express from 'express';
import http from 'node:http';
import { AddressInfo } from 'node:net';
import { webdecoy } from './middleware';

/**
* Honeytoken injection through a real Express app (#482).
*
* The unit tests cover the injection function. These cover the part that can
* corrupt a customer's response: intercepting res.write/res.end. Each case here
* is a way this could go wrong in production, and a corrupted response is a far
* worse failure than a missed detection.
*/
function serve(app: express.Express): Promise<{ url: string; close: () => void }> {
return new Promise((resolve) => {
const server = http.createServer(app);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as AddressInfo;
resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() });
});
});
}

async function get(url: string): Promise<{ status: number; body: string; length?: string }> {
const res = await fetch(url);
return {
status: res.status,
body: await res.text(),
length: res.headers.get('content-length') ?? undefined,
};
}

/** Give the async HMAC derivation a tick to settle before asserting on it. */
const settle = () => new Promise((r) => setTimeout(r, 50));

function appWith(opts: Record<string, unknown> = {}) {
const app = express();
app.use(webdecoy({ apiKey: 'sk_live_test_secret', skipLocalAnalysis: true, ...opts }));
app.get('/', (_req, res) => res.type('html').send('<html><body><h1>hi</h1></body></html>'));
app.get('/api', (_req, res) => res.json({ ok: true, nested: { a: 1 } }));
app.get('/text', (_req, res) => res.type('text').send('plain body'));
return app;
}

describe('honeytoken injection', () => {
it('injects the hidden link into an HTML page', async () => {
const { url, close } = await serve(appWith());
await settle();
const res = await get(`${url}/`);
close();

expect(res.status).toBe(200);
expect(res.body).toContain('<h1>hi</h1>');
expect(res.body).toMatch(/<a href="\/__wd\/[0-9a-f]{12}"/);
// Before </body>, not appended after the document.
expect(res.body.indexOf('/__wd/')).toBeLessThan(res.body.indexOf('</body>'));
});

it('leaves JSON completely alone', async () => {
const { url, close } = await serve(appWith());
await settle();
const res = await get(`${url}/api`);
close();

// Corrupting an API response is worse than missing a detection.
expect(() => JSON.parse(res.body)).not.toThrow();
expect(JSON.parse(res.body)).toEqual({ ok: true, nested: { a: 1 } });
expect(res.body).not.toContain('__wd');
});

it('leaves plain text alone', async () => {
const { url, close } = await serve(appWith());
await settle();
const res = await get(`${url}/text`);
close();
expect(res.body).toBe('plain body');
});

it('corrects Content-Length, or the client truncates the body', async () => {
const { url, close } = await serve(appWith());
await settle();
const res = await get(`${url}/`);
close();

// A stale length would cut the document short — the injected link makes the
// body longer than what express computed.
expect(res.length).toBe(String(Buffer.byteLength(res.body, 'utf8')));
expect(res.body).toContain('</html>');
});

it('honeytoken: false opts out entirely', async () => {
const { url, close } = await serve(appWith({ honeytoken: false }));
await settle();
const res = await get(`${url}/`);
close();
expect(res.body).not.toContain('__wd');
expect(res.body).toBe('<html><body><h1>hi</h1></body></html>');
});

it('does nothing without an apiKey, since the token is derived from it', async () => {
const app = express();
app.use(webdecoy({ skipLocalAnalysis: true }));
app.get('/', (_req, res) => res.type('html').send('<html><body>x</body></html>'));
const { url, close } = await serve(app);
await settle();
const res = await get(`${url}/`);
close();
expect(res.body).not.toContain('__wd');
});

it('serves the same path on every request, so the tripwire matches', async () => {
const { url, close } = await serve(appWith());
await settle();
const a = await get(`${url}/`);
const b = await get(`${url}/`);
close();

const pathOf = (b2: string) => b2.match(/\/__wd\/[0-9a-f]{12}/)?.[0];
expect(pathOf(a.body)).toBeDefined();
expect(pathOf(a.body)).toBe(pathOf(b.body));
});
});

/**
* The acceptance criterion: a fresh Express app produces a WORKING trap with no
* code beyond enabling honeytokens.
*
* Injecting the link is only half of it. If the path it advertises is not armed
* as a tripwire, the bait leads nowhere and a crawler that follows it trips
* nothing — which is precisely the state the SDK was already in, with the
* developer expected to wire both halves by hand.
*/
describe('the injected link is actually armed', () => {
it('following the bait trips the tripwire', async () => {
const app = express();
app.use(webdecoy({ apiKey: 'sk_live_test_secret', skipLocalAnalysis: true }));
app.get('/', (_req, res) => res.type('html').send('<html><body>x</body></html>'));
// Monitor mode serves everything, so the verdict is read off the request
// rather than inferred from a status code.
app.use((req, res) =>
res.json({ wouldBlock: (req as any).webdecoyWouldBlock === true }),
);

const { url, close } = await serve(app);
await settle();

const page = await get(`${url}/`);
const baitPath = page.body.match(/\/__wd\/[0-9a-f]{12}/)?.[0];
expect(baitPath).toBeDefined();

// A crawler follows the hidden link.
const trap = await get(`${url}${baitPath}`);
// An ordinary page, for contrast.
const normal = await get(`${url}/some-real-page`);
close();

expect(JSON.parse(trap.body).wouldBlock).toBe(true);
expect(JSON.parse(normal.body).wouldBlock).toBe(false);
});
});
97 changes: 96 additions & 1 deletion packages/express/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@

import { Request, Response, NextFunction } from 'express';
import { WebDecoy, WebDecoyConfig, RequestMetadata, ProtectOptions } from '@webdecoy/node';
import type { EdgeVerdict } from '@webdecoy/node';
import type { EdgeVerdict, SiteHoneytoken } from '@webdecoy/node';
import {
siteHoneytoken,
injectHoneytokenLink,
isInjectableHtml,
tripwire,
} from '@webdecoy/node';

export interface WebDecoyMiddlewareOptions extends ProtectOptions {
/**
Expand All @@ -27,6 +33,23 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions {
*/
mode?: 'monitor' | 'enforce';

/**
* Inject a hidden honeytoken link into HTML responses, and arm the tripwire it
* points at. Defaults to **on** when an apiKey is present (#482).
*
* The SDK used to generate a honeytoken and ask the developer to embed it.
* Almost nobody did: `sdk_tripwire` had FOUR rows in production, ever, while
* the WordPress plugin — which injects the link itself — has real coverage.
*
* A trap hit is the only detection here that needs no score, no JavaScript, no
* fingerprint and no IP. It is also the only one that scores: honeypot signals
* are weighted 38% against user-agent's 1%, which is why every rule-less `sdk`
* detection ever recorded came out at 0.
*
* Set `false` to opt out, or place the link yourself for apps that stream.
*/
honeytoken?: boolean;

/**
* Custom function to extract IP address from request
* By default, uses req.ip or x-forwarded-for header
Expand Down Expand Up @@ -158,6 +181,28 @@ export function webdecoy(
const getIP = config.getIP || defaultGetIP;
const onBlocked = config.onBlocked || defaultOnBlocked;
const mode = config.mode ?? 'monitor';

// Honeytoken (#482). 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.
});
}
const onError = config.onError || defaultOnError;
const skipPaths = config.skipPaths;

Expand Down Expand Up @@ -185,6 +230,56 @@ export function webdecoy(
metadata: config.metadata,
});

// Honeytoken injection (#482).
//
// Buffers the body only for full HTML documents and rewrites it once. The
// guards below are not defensive padding — each one is a way this could
// corrupt a customer's response, which is a far worse failure than a
// missed detection:
//
// - non-HTML is left untouched, so an anchor never lands in JSON
// - 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;
const originalWrite = res.write.bind(res);
const originalEnd = res.end.bind(res);
const chunks: Buffer[] = [];
let intercepting: boolean | null = null;

const shouldIntercept = (): boolean => {
if (intercepting === null) {
intercepting =
!res.headersSent && isInjectableHtml(res.getHeader('content-type') as string);
}
return intercepting;
};

(res as any).write = function (chunk: any, ...rest: any[]): boolean {
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 {
try {
if (!shouldIntercept()) return originalEnd(chunk, ...rest);
if (chunk && typeof chunk !== 'function') {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const body = injectHoneytokenLink(Buffer.concat(chunks).toString('utf8'), ht.linkHtml);
const out = Buffer.from(body, 'utf8');
// The body grew; a stale Content-Length truncates it at the client.
if (!res.headersSent) res.setHeader('Content-Length', String(out.length));
return originalEnd(out);
} catch {
// Never let injection cost the response.
return originalEnd(chunk, ...rest);
}
};
}

// Monitor mode: the verdict is recorded and reported, and the request is
// served exactly as it would have been.
//
Expand Down
88 changes: 88 additions & 0 deletions packages/nextjs/src/honeytoken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Honeytoken for Next.js (#482).
*
* WHY THIS IS A HELPER AND NOT AUTOMATIC
*
* The Express adapter injects the hidden link by wrapping `res.write`/`res.end`
* and rewriting the finished HTML. Next middleware cannot do that. It runs
* BEFORE the route handler and returns a `NextResponse`; the App Router then
* streams the RSC payload directly to the client. There is no complete document
* for middleware to rewrite, and buffering a stream to synthesise one would
* defeat streaming — trading the framework's headline feature for a hidden link.
*
* Partial support would be worse than none. Injecting only into the
* non-streamed cases would give a trap that works in development and silently
* stops working under the rendering mode most production apps use, which is the
* failure mode this whole issue is about: a feature that looks installed and
* detects nothing.
*
* So Next gets an honest one-liner instead. It is one line in the root layout,
* and it is the same derived path the tripwire is armed on.
*
* Rendered from `linkProps` as ordinary JSX rather than through
* `dangerouslySetInnerHTML`: the markup is a single anchor, so there is no
* reason to hand React a raw HTML string and every reason not to teach that
* habit in a snippet people will paste.
*
* @example
* ```tsx
* // app/layout.tsx
* import { honeytokenLink } from '@webdecoy/nextjs';
*
* export default async function RootLayout({ children }: { children: React.ReactNode }) {
* const { linkProps } = await honeytokenLink(process.env.WEBDECOY_API_KEY!);
* const { text, ...anchor } = linkProps;
* return (
* <html>
* <body>
* {children}
* <a {...anchor}>{text}</a>
* </body>
* </html>
* );
* }
* ```
*
* Then arm it in middleware:
*
* ```typescript
* import { withWebDecoy } from '@webdecoy/nextjs';
* import { tripwire } from '@webdecoy/node';
*
* const bait = await honeytokenLink(process.env.WEBDECOY_API_KEY!);
* export default withWebDecoy({
* apiKey: process.env.WEBDECOY_API_KEY!,
* rules: [tripwire({ paths: bait.activePaths, includeDefaults: false })],
* });
* ```
*/

import { siteHoneytoken, type SiteHoneytoken } from '@webdecoy/node';

/**
* Derive this site's honeytoken.
*
* Pass the API key: the token is an HMAC of it, so every replica and every
* render computes the same path without coordinating. A random path would mean
* the process that served the link and the process that armed the tripwire
* disagree, and a crawler following the bait would trip nothing.
*
* Cached per secret, because a layout renders on every request and the
* derivation is a crypto call.
*/
const cache = new Map<string, Promise<SiteHoneytoken>>();

export function honeytokenLink(
apiKey: string,
options: { rotate?: boolean; text?: string } = {},
): Promise<SiteHoneytoken> {
const key = `${apiKey}|${options.rotate ? 'rot' : 'stable'}|${options.text ?? '.'}`;
let existing = cache.get(key);
if (!existing) {
existing = siteHoneytoken({ secret: apiKey, ...options });
cache.set(key, existing);
}
return existing;
}

export type { SiteHoneytoken } from '@webdecoy/node';
5 changes: 5 additions & 0 deletions packages/nextjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export type { WebDecoyMiddlewareOptions, WithBotProtectionOptions } from './midd
export { getEdgeVerdict } from './edge-verdict';
export type { EdgeVerdict, EdgeClass } from './edge-verdict';

// Honeytoken (#482). A helper rather than automatic injection — see the module
// for why Next middleware cannot rewrite a streamed RSC response.
export { honeytokenLink } from './honeytoken';
export type { SiteHoneytoken } from './honeytoken';

// Self-hosted captcha route handlers (PoW + detection + tokens)
export { createCaptchaHandler } from './captcha';
export type { NextCaptchaHandlers } from './captcha';
Expand Down
Loading