|
| 1 | +/** |
| 2 | + * Web Decoy middleware for Hono. |
| 3 | + * |
| 4 | + * Hono is the default on Cloudflare Workers, Bun and Deno — the runtimes where |
| 5 | + * the rest of our stack already sits. The Cloudflare edge sensor fronts these |
| 6 | + * deployments and tags every request it forwards, and `readEdgeVerdict()` exists |
| 7 | + * so the origin can act on that tag. Until now there was no origin middleware |
| 8 | + * there to do it. |
| 9 | + * |
| 10 | + * @example |
| 11 | + * ```ts |
| 12 | + * import { Hono } from 'hono'; |
| 13 | + * import { webdecoy } from '@webdecoy/hono'; |
| 14 | + * import { tripwire, rateLimit } from '@webdecoy/node'; |
| 15 | + * |
| 16 | + * const app = new Hono(); |
| 17 | + * |
| 18 | + * app.use('*', webdecoy({ |
| 19 | + * rules: [tripwire(), rateLimit({ max: 100, window: 60 })], |
| 20 | + * skipPaths: ['/health'], |
| 21 | + * })); |
| 22 | + * ``` |
| 23 | + */ |
| 24 | + |
| 25 | +import type { Context, MiddlewareHandler, Next } from 'hono'; |
| 26 | +import { createFetchGuard } from '@webdecoy/node'; |
| 27 | +import type { FetchGuardOptions, Decision } from '@webdecoy/node'; |
| 28 | + |
| 29 | +export interface WebDecoyHonoOptions extends Omit<FetchGuardOptions, 'onBlocked'> { |
| 30 | + /** |
| 31 | + * Build the blocking response. Defaults to 403, or 429 with a `Retry-After` |
| 32 | + * for a throttle. |
| 33 | + */ |
| 34 | + onBlocked?: (c: Context, decision: Decision) => Response | Promise<Response>; |
| 35 | +} |
| 36 | + |
| 37 | +/** |
| 38 | + * Where the decision is stashed on the Hono context. |
| 39 | + * |
| 40 | + * Read it with `c.get('webdecoy')` — in monitor mode this is the only place the |
| 41 | + * verdict surfaces, and monitor is the default. |
| 42 | + */ |
| 43 | +export const WEBDECOY_CONTEXT_KEY = 'webdecoy'; |
| 44 | + |
| 45 | +declare module 'hono' { |
| 46 | + interface ContextVariableMap { |
| 47 | + webdecoy?: Decision; |
| 48 | + } |
| 49 | +} |
| 50 | + |
| 51 | +export function webdecoy(options: WebDecoyHonoOptions = {}): MiddlewareHandler { |
| 52 | + const { onBlocked, ...guardOptions } = options; |
| 53 | + const guard = createFetchGuard(guardOptions); |
| 54 | + |
| 55 | + return async (c: Context, next: Next): Promise<Response | void> => { |
| 56 | + if (guard.skips(new URL(c.req.url).pathname)) { |
| 57 | + await next(); |
| 58 | + return; |
| 59 | + } |
| 60 | + |
| 61 | + // Workers expose the peer address as a header rather than a socket, and |
| 62 | + // there is no portable accessor across Hono's runtimes — so the guard's |
| 63 | + // trusted-hops default over X-Forwarded-For is what resolves the client, |
| 64 | + // and `trustProxy: 'cloudflare'` is the stronger choice behind Cloudflare. |
| 65 | + const { decision, response } = await guard.check(c.req.raw); |
| 66 | + c.set(WEBDECOY_CONTEXT_KEY, decision); |
| 67 | + |
| 68 | + if (response) { |
| 69 | + return onBlocked ? await onBlocked(c, decision) : response; |
| 70 | + } |
| 71 | + |
| 72 | + await next(); |
| 73 | + |
| 74 | + // Honeytoken injection. Hono has already built the response, so this reads |
| 75 | + // and rewrites it — full HTML documents only, and never one whose body the |
| 76 | + // application has already consumed. |
| 77 | + if (c.res) { |
| 78 | + c.res = await guard.decorate(c.res); |
| 79 | + } |
| 80 | + }; |
| 81 | +} |
| 82 | + |
| 83 | +export type { FetchGuardOptions, Decision } from '@webdecoy/node'; |
| 84 | +export type { WebDecoyConfig, RequestMetadata, ProtectResult } from '@webdecoy/node'; |
0 commit comments