From 95f1957f868f1565bafeb5b936bf24b318bed1c3 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Thu, 30 Jul 2026 18:37:51 -0500 Subject: [PATCH] feat: adapters inject the honeytoken link themselves (#482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK generated a honeytoken and asked the developer to embed it. Almost nobody did: sdk_tripwire has FOUR rows in production, ever, against bot_scanner's 3,897. The WordPress plugin injects the link itself and has real coverage, which is the whole difference. WHY THIS IS THE SIGNAL WORTH HAVING A trap hit needs no score, no JavaScript, no fingerprint and no IP — the client asked for a path that exists nowhere and is linked only from an element no human can see. It is also the only signal that scores: honeypot hits are weighted 38% against user-agent's 1%, because a user agent is trivially spoofed. Measured against production, every rule-less `sdk` detection ever recorded came out at 0 while sdk_tripwire averaged 52.5. 0.7.0 armed the tripwire PATHS by default. This adds the bait that leads anything to them. THE TOKEN IS DERIVED, NOT RANDOM honeytoken() mints a random path per call, which is fine when a developer holds the object and wires both halves. It is useless for automatic injection: the adapter would advertise a different path per request, and a second replica would arm a path the first never served. A crawler following the bait would trip nothing. So siteHoneytoken() derives it by HMAC of a label under the API key, exactly as the WordPress plugin derives it from a per-site secret. Every process computes the same path without coordinating, and nothing extra is stored. Rotation with a one-day grace window is available and off by default — a CDN-cached page can outlive the window, at which point the trap silently stops catching anything. EXPRESS INJECTS; NEXT CANNOT, AND SAYS SO Express wraps res.write/res.end and rewrites the finished document. Every guard there is a way this could corrupt a customer's response, which is a far worse failure than a missed detection: non-HTML is untouched so an anchor never lands in JSON, a committed response is left alone, Content-Length is corrected or the client truncates the body, and anything thrown falls back to the original write. Next middleware runs before the route handler and the App Router streams the RSC payload straight to the client — there is no complete document to rewrite, and buffering one would defeat streaming. Partial support would be worse than none: a trap that works in development and silently stops working under the rendering mode most production apps use is exactly the failure this issue is about. Next gets an honest one-liner for the root layout instead, rendered from linkProps as ordinary JSX rather than dangerouslySetInnerHTML. ACCESSIBILITY AND ROBOTS ARE THE POINT, NOT DECORATION nofollow/noindex and off-screen positioning keep robots-honouring crawlers away: a trap that catches Googlebot files the customer's own search traffic as an attack. aria-hidden and tabindex=-1 keep it out of the accessibility tree and tab order, without which this feature punishes people for using assistive technology. Both are asserted. The link text is escaped. It is our own config today, and that is how a value ends up templated from a database field two refactors later. --- packages/express/jest.config.js | 8 + .../express/src/honeytoken-injection.test.ts | 160 +++++++++++++ packages/express/src/middleware.ts | 97 +++++++- packages/nextjs/src/honeytoken.ts | 88 +++++++ packages/nextjs/src/index.ts | 5 + packages/webdecoy/src/index.ts | 7 + .../src/rules/honeytoken-site.test.ts | 153 ++++++++++++ .../webdecoy/src/rules/honeytoken-site.ts | 222 ++++++++++++++++++ packages/webdecoy/src/rules/index.ts | 11 + packages/webdecoy/src/rules/rule-engine.ts | 11 + packages/webdecoy/src/sdk.ts | 18 ++ 11 files changed, 779 insertions(+), 1 deletion(-) create mode 100644 packages/express/jest.config.js create mode 100644 packages/express/src/honeytoken-injection.test.ts create mode 100644 packages/nextjs/src/honeytoken.ts create mode 100644 packages/webdecoy/src/rules/honeytoken-site.test.ts create mode 100644 packages/webdecoy/src/rules/honeytoken-site.ts 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('