Skip to content

Commit 184ef07

Browse files
authored
feat(hono): one fetch adapter, and Hono on top of it (#31)
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
1 parent 60b03a5 commit 184ef07

12 files changed

Lines changed: 639 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **`@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')`.
13+
14+
- **`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.
15+
1216
- **`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.
1317

1418
- **`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.

‎README.md‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,45 @@ app.use(
9090
);
9191
```
9292

93-
Fastify (`@webdecoy/fastify`) and Next.js (`@webdecoy/nextjs`) expose the same rule-based middleware.
93+
Fastify (`@webdecoy/fastify`), Next.js (`@webdecoy/nextjs`) and Hono (`@webdecoy/hono`) expose the same rule-based middleware.
94+
95+
### Hono — Workers, Bun, Deno
96+
97+
```bash
98+
npm install @webdecoy/hono
99+
```
100+
101+
```typescript
102+
import { Hono } from 'hono';
103+
import { webdecoy } from '@webdecoy/hono';
104+
import { tripwire } from '@webdecoy/node';
105+
106+
const app = new Hono();
107+
app.use('*', webdecoy({ rules: [tripwire()], skipPaths: ['/health'] }));
108+
```
109+
110+
`c.get('webdecoy')` carries the decision — in monitor mode, which is the default,
111+
that is the only place the verdict surfaces.
112+
113+
### Any other fetch runtime — no package needed
114+
115+
Bun, Deno, Astro, Nitro, SvelteKit and Remix all hand you a WHATWG `Request` and
116+
want a `Response`. `createFetchGuard()` is the same implementation the Hono
117+
adapter wraps:
118+
119+
```typescript
120+
import { createFetchGuard, tripwire } from '@webdecoy/node';
121+
122+
const guard = createFetchGuard({ mode: 'enforce', rules: [tripwire()] });
123+
124+
export default {
125+
async fetch(request: Request): Promise<Response> {
126+
const { response } = await guard.check(request);
127+
if (response) return response; // denied
128+
return guard.decorate(await handle(request)); // injects the honeytoken link
129+
},
130+
};
131+
```
94132

95133
## More local rules
96134

@@ -203,6 +241,7 @@ if (!result.allowed) {
203241
| [@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 |
204242
| [@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 |
205243
| [@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 |
244+
| [@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) |
206245
| [@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 |
207246

208247
## One bot policy, published and enforced

‎package-lock.json‎

Lines changed: 37 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"test": "turbo run test",
2121
"clean": "turbo run clean && rm -rf node_modules",
2222
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
23-
"check:edge": "npm run check:edge -w @webdecoy/node -w @webdecoy/nextjs"
23+
"check:edge": "npm run check:edge -w @webdecoy/node -w @webdecoy/nextjs -w @webdecoy/hono"
2424
},
2525
"devDependencies": {
2626
"@changesets/cli": "^2.27.1",

‎packages/hono/jest.config.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} */
2+
module.exports = {
3+
preset: 'ts-jest',
4+
testEnvironment: 'node',
5+
roots: ['<rootDir>/src'],
6+
testMatch: ['**/*.test.ts'],
7+
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
8+
};

‎packages/hono/package.json‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"name": "@webdecoy/hono",
3+
"version": "0.12.0",
4+
"description": "Web Decoy middleware for Hono — Cloudflare Workers, Bun, Deno, Node",
5+
"main": "./dist/index.js",
6+
"types": "./dist/index.d.ts",
7+
"exports": {
8+
".": {
9+
"require": "./dist/index.js",
10+
"import": "./dist/index.mjs",
11+
"types": "./dist/index.d.ts"
12+
}
13+
},
14+
"files": [
15+
"dist"
16+
],
17+
"scripts": {
18+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
19+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
20+
"test": "jest",
21+
"lint": "eslint src --max-warnings 0",
22+
"clean": "rm -rf dist",
23+
"check:edge": "node ../../scripts/check-edge.mjs src/index.ts"
24+
},
25+
"keywords": [
26+
"web-decoy",
27+
"hono",
28+
"middleware",
29+
"bot-detection",
30+
"cloudflare-workers",
31+
"bun",
32+
"deno",
33+
"security"
34+
],
35+
"author": "Web Decoy",
36+
"license": "MIT",
37+
"homepage": "https://github.com/WebDecoy/node#readme",
38+
"repository": {
39+
"type": "git",
40+
"url": "git+https://github.com/WebDecoy/node.git",
41+
"directory": "packages/hono"
42+
},
43+
"bugs": {
44+
"url": "https://github.com/WebDecoy/node/issues"
45+
},
46+
"dependencies": {
47+
"@webdecoy/node": "^0.12.0"
48+
},
49+
"peerDependencies": {
50+
"hono": "^4.0.0"
51+
},
52+
"devDependencies": {
53+
"@types/jest": "^29.5.11",
54+
"@types/node": "^20.11.0",
55+
"jest": "^29.7.0",
56+
"ts-jest": "^29.1.1",
57+
"tsup": "^8.0.1",
58+
"typescript": "^5.3.3",
59+
"hono": "^4.6.0"
60+
},
61+
"engines": {
62+
"node": ">=18.0.0"
63+
}
64+
}

‎packages/hono/src/index.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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

Comments
 (0)