Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
const { response } = await guard.check(request);
if (response) return response; // denied
return guard.decorate(await handle(request)); // injects the honeytoken link
},
};
```

## More local rules

Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions packages/hono/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
};
64 changes: 64 additions & 0 deletions packages/hono/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
84 changes: 84 additions & 0 deletions packages/hono/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<FetchGuardOptions, 'onBlocked'> {
/**
* Build the blocking response. Defaults to 403, or 429 with a `Retry-After`
* for a throttle.
*/
onBlocked?: (c: Context, decision: Decision) => Response | Promise<Response>;
}

/**
* 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<Response | void> => {
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';
Loading
Loading