diff --git a/packages/express/jest.config.js b/packages/express/jest.config.js new file mode 100644 index 0000000..57b4da5 --- /dev/null +++ b/packages/express/jest.config.js @@ -0,0 +1,8 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.ts'], + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'], +}; diff --git a/packages/express/src/honeytoken-injection.test.ts b/packages/express/src/honeytoken-injection.test.ts new file mode 100644 index 0000000..0aa4323 --- /dev/null +++ b/packages/express/src/honeytoken-injection.test.ts @@ -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 = {}) { + const app = express(); + app.use(webdecoy({ apiKey: 'sk_live_test_secret', skipLocalAnalysis: true, ...opts })); + app.get('/', (_req, res) => res.type('html').send('

hi

')); + 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('

hi

'); + expect(res.body).toMatch(/, not appended after the document. + expect(res.body.indexOf('/__wd/')).toBeLessThan(res.body.indexOf('')); + }); + + 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(''); + }); + + 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('

hi

'); + }); + + 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('x')); + 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('x')); + // 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); + }); +}); diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index 7844469..5843691 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -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 { /** @@ -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 @@ -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; @@ -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. // diff --git a/packages/nextjs/src/honeytoken.ts b/packages/nextjs/src/honeytoken.ts new file mode 100644 index 0000000..5a43306 --- /dev/null +++ b/packages/nextjs/src/honeytoken.ts @@ -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 ( + * + * + * {children} + *
{text} + * + * + * ); + * } + * ``` + * + * 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>(); + +export function honeytokenLink( + apiKey: string, + options: { rotate?: boolean; text?: string } = {}, +): Promise { + 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'; diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 02a521e..da238ce 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -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'; diff --git a/packages/webdecoy/src/index.ts b/packages/webdecoy/src/index.ts index 22ffecc..7b53763 100644 --- a/packages/webdecoy/src/index.ts +++ b/packages/webdecoy/src/index.ts @@ -57,6 +57,10 @@ export { TripwireRule, WebBotAuthRule, DEFAULT_TRIPWIRE_PATHS, + siteHoneytoken, + injectHoneytokenLink, + isInjectableHtml, + HONEYTOKEN_BASE_PATH, } from './rules'; export type { @@ -70,6 +74,9 @@ export type { WebBotAuthConfig, HoneytokenOptions, Honeytoken, + SiteHoneytoken, + SiteHoneytokenOptions, + HoneytokenLinkProps, ViolationEvent, IPEnrichmentData, } from './rules'; diff --git a/packages/webdecoy/src/rules/honeytoken-site.test.ts b/packages/webdecoy/src/rules/honeytoken-site.test.ts new file mode 100644 index 0000000..7f70f46 --- /dev/null +++ b/packages/webdecoy/src/rules/honeytoken-site.test.ts @@ -0,0 +1,153 @@ +import { + siteHoneytoken, + injectHoneytokenLink, + isInjectableHtml, + HONEYTOKEN_BASE_PATH, +} from './honeytoken-site'; + +/** + * The site honeytoken (#482). + * + * `sdk_tripwire` had FOUR rows in production, ever, because the SDK generated a + * honeytoken and asked the developer to embed it. This is the half that lets an + * adapter do it — which needs the path to be DERIVED rather than random, so + * every replica computes the same one. + */ +describe('siteHoneytoken', () => { + it('derives the same path from the same secret, in any process', async () => { + const a = await siteHoneytoken({ secret: 'sk_live_example' }); + const b = await siteHoneytoken({ secret: 'sk_live_example' }); + expect(a.primaryPath).toBe(b.primaryPath); + // The whole reason this is not honeytoken(): a random path would mean the + // replica that served the link and the replica that armed the tripwire + // disagree, and a crawler following the bait trips nothing. + expect(a.primaryPath).toMatch(new RegExp(`^${HONEYTOKEN_BASE_PATH}/[0-9a-f]{12}$`)); + }); + + it('a different secret is a different trap', async () => { + const a = await siteHoneytoken({ secret: 'site-one' }); + const b = await siteHoneytoken({ secret: 'site-two' }); + expect(a.primaryPath).not.toBe(b.primaryPath); + }); + + it('arms exactly the path it advertises', async () => { + const t = await siteHoneytoken({ secret: 's' }); + expect(t.activePaths).toContain(t.primaryPath); + expect(t.linkHtml).toContain(`href="${t.primaryPath}"`); + }); + + it('rotating keeps yesterday armed but advertises today', async () => { + const t = await siteHoneytoken({ secret: 's', rotate: true }); + expect(t.activePaths).toHaveLength(2); + expect(t.activePaths[0]).toBe(t.primaryPath); + // A crawler that read the page before midnight and follows the link after it + // must still trip, or rotation silently turns the trap off once a day. + expect(t.activePaths[1]).not.toBe(t.primaryPath); + }); + + /** + * Every attribute here is load-bearing. Their absence turns this feature into + * an SEO defect and an accessibility defect at the same time. + */ + it('is invisible to crawlers that honour robots and to assistive tech', async () => { + const { linkHtml } = await siteHoneytoken({ secret: 's' }); + // A trap that catches Googlebot files the customer's own search traffic as + // an attack. + expect(linkHtml).toContain('rel="nofollow noindex"'); + // Reachable by a screen reader or the tab key would punish the user for + // using assistive technology. + expect(linkHtml).toContain('aria-hidden="true"'); + expect(linkHtml).toContain('tabindex="-1"'); + expect(linkHtml).toContain('left:-9999px'); + }); +}); + +describe('injectHoneytokenLink', () => { + const link = '.'; + + it('injects before ', () => { + expect(injectHoneytokenLink('

hi

', link)).toBe( + `

hi

${link}`, + ); + }); + + it('falls back to when there is no body', () => { + expect(injectHoneytokenLink('

hi

', link)).toBe( + `

hi

${link}`, + ); + }); + + it('injects once, not once per render', () => { + const once = injectHoneytokenLink('x', link); + expect(injectHoneytokenLink(once, link)).toBe(once); + }); + + it('leaves a fragment alone rather than appending loose markup', () => { + // An anchor after is invalid, and some parsers relocate it — which + // could make the trap visible. A visible trap catches customers. + const fragment = '
partial
'; + expect(injectHoneytokenLink(fragment, link)).toBe(fragment); + }); + + it('handles an empty body', () => { + expect(injectHoneytokenLink('', link)).toBe(''); + }); + + it('is case-insensitive about the closing tag', () => { + expect(injectHoneytokenLink('x', link)).toContain(link); + }); +}); + +describe('isInjectableHtml', () => { + it('accepts full HTML documents', () => { + expect(isInjectableHtml('text/html')).toBe(true); + expect(isInjectableHtml('text/html; charset=utf-8')).toBe(true); + expect(isInjectableHtml('TEXT/HTML')).toBe(true); + }); + + it('refuses everything else', () => { + // Injecting an anchor into JSON corrupts a customer's API response, which is + // a far worse failure than a missed detection. + for (const ct of [ + 'application/json', + 'text/plain', + 'image/png', + 'application/xhtml+xml', + 'text/event-stream', + undefined, + '', + ]) { + expect(isInjectableHtml(ct)).toBe(false); + } + }); +}); + +/** + * `text` is caller config, but it flows straight into a page. "It's only our own + * config" is how a value ends up templated from a database field two refactors + * later, so it is escaped at the boundary. + */ +describe('link text is escaped', () => { + it('cannot inject markup through the text option', async () => { + const t = await siteHoneytoken({ + secret: 's', + text: '', + }); + expect(t.linkHtml).not.toContain('