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 @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **`withBotProtection` (`@webdecoy/nextjs`) honours `mode` and monitors by default.** It ignored `mode` and returned 403 for any request `protect()` did not allow, unlike `withWebDecoy` and every other adapter. It now runs your handler in monitor mode and records the verdict on `req.webdecoyDecision`; set `mode: 'enforce'` to refuse requests. **If you relied on it blocking, add `mode: 'enforce'`.**

## [0.18.1] - 2026-09-29

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions openwiki/integrations/framework-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ This page is about the integration contract. For the meaning of conclusions, rul
| Express on Node | `@webdecoy/express`: `webdecoy()` | Express middleware | `req.webdecoyDecision` |
| Fastify on Node | `@webdecoy/fastify`: default export or `webdecoyPlugin` | Registered plugin with a `preHandler` hook | `request.webdecoyDecision` |
| Next.js Edge middleware | `@webdecoy/nextjs`: `withWebDecoy()` | `middleware.ts` wrapper returning `NextResponse` | Request headers forwarded to the Next application |
| Next.js Pages API route | `@webdecoy/nextjs`: `withBotProtection()` | Per-handler higher-order wrapper | `req.webdecoyDecision` on an allowed request |
| Next.js Pages API route | `@webdecoy/nextjs`: `withBotProtection()` | Per-handler higher-order wrapper | `req.webdecoyDecision` on every request it lets through (always, in monitor mode) |
| Hono on Workers, Bun, Deno, or Node | `@webdecoy/hono`: `webdecoy()` | Hono middleware | `c.get('webdecoyDecision')` or `c.get('webdecoy')` |
| Any WHATWG fetch handler | `@webdecoy/node`: `createFetchGuard()` | Explicit `check()` and optional `decorate()` calls | Returned `GuardOutcome.decision` |

Expand Down Expand Up @@ -151,7 +151,7 @@ For a Next Edge application, read annotations from the incoming request in a rou

`withWebDecoy()` is for Edge middleware. Scope it with Next's module-level middleware configuration, for example `export const config = { matcher: [...] }`. Although `WebDecoyMiddlewareOptions` declares a `matcher` field, the wrapper implementation does not consume that option; do not rely on `withWebDecoy({ matcher: ... })` to limit execution. Use Next's exported matcher and `skipPaths` for the distinct scoping mechanisms they are.

`withBotProtection()` is the Pages API-route compatibility wrapper, not the Edge middleware in another form. It runs on Node and therefore has a socket peer available for its safe proxy default. It protects the wrapped handler with `blockThreshold` (default 80), blocks immediately when the result is not allowed, attaches `req.webdecoy` and `req.webdecoyDecision` only on the allowed path, and fails open on an exception. It does not implement the Edge wrapper's monitor mode, `skipPaths`, or `onBlocked` callback contract. Use `withWebDecoy()` for an Edge middleware boundary and the Pages wrapper only where a Pages API handler is the actual integration boundary.
`withBotProtection()` is the Pages API-route compatibility wrapper, not the Edge middleware in another form. It runs on Node and therefore has a socket peer available for its safe proxy default. It protects the wrapped handler with `blockThreshold` (default 80) and honours `mode`: in monitor mode (the default) it runs the handler and attaches `req.webdecoy` and `req.webdecoyDecision`, whose `allowed` says what enforce would have done; with `mode: 'enforce'` it refuses a request that is not allowed. It fails open on an exception. It does not implement the Edge wrapper's `skipPaths` or `onBlocked` callback contract. Use `withWebDecoy()` for an Edge middleware boundary and the Pages wrapper only where a Pages API handler is the actual integration boundary.

## Client IP and proxy defaults

Expand Down
8 changes: 7 additions & 1 deletion packages/nextjs/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,9 @@ export interface WithBotProtectionOptions extends WebDecoyMiddlewareOptions {
* export default withBotProtection(handler, {
* apiKey: process.env.WEBDECOY_API_KEY!,
* blockThreshold: 70,
* // Monitor is the default: the handler always runs and
* // req.webdecoyDecision.allowed says what enforce would have done.
* mode: 'enforce',
* });
* ```
*/
Expand All @@ -293,6 +296,9 @@ export function withBotProtection<T extends (...args: any[]) => any>(
): T {
const sdk = new WebDecoy(config);
const threshold = config.blockThreshold ?? 80;
// Monitor by default, like withWebDecoy and every other adapter: a wrapper
// that says nothing about mode must never refuse a request.
const mode = config.mode ?? 'monitor';

return (async (...args: Parameters<T>) => {
const [req, res] = args;
Expand Down Expand Up @@ -325,7 +331,7 @@ export function withBotProtection<T extends (...args: any[]) => any>(
metadata: config.metadata,
});

if (!result.allowed) {
if (!result.allowed && mode === 'enforce') {
// Same shared refusal shape as the middleware and every other adapter.
// This wrapper was the fourth copy of it.
const block = ruleBlockResponse(result);
Expand Down
66 changes: 66 additions & 0 deletions packages/nextjs/src/with-bot-protection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { WebDecoy } from '@webdecoy/node';
import { withBotProtection } from './middleware';

/**
* withBotProtection ignored `mode` and refused any request protect() did not
* allow, while every other adapter (and withWebDecoy beside it) monitors by
* default. A Pages API route wrapped with no mode was blocking on install.
*/

function pagesReq() {
return {
method: 'GET',
url: '/api/protected?x=1',
headers: { 'user-agent': 'python-requests/2.31.0' },
socket: { remoteAddress: '203.0.113.7' },
} as any;
}

function pagesRes() {
const res: any = { statusCode: 200, body: undefined, headers: {} as Record<string, string> };
res.status = (code: number) => {
res.statusCode = code;
return res;
};
res.json = (body: unknown) => {
res.body = body;
return res;
};
res.setHeader = (name: string, value: string) => {
res.headers[name] = value;
};
return res;
}

const refused = { allowed: false, detection: { detection_id: 'det_1' } } as any;

describe('withBotProtection honours mode', () => {
let protect: jest.SpyInstance;

beforeEach(() => {
protect = jest.spyOn(WebDecoy.prototype, 'protect').mockResolvedValue(refused);
});
afterEach(() => protect.mockRestore());

it('monitors by default: the handler runs and sees what enforce would have done', async () => {
const handler = jest.fn((_req: any, res: any) => res.status(200).json({ ok: true }));
const req = pagesReq();
const res = pagesRes();

await withBotProtection(handler, { skipLocalAnalysis: true } as any)(req, res);

expect(handler).toHaveBeenCalledTimes(1);
expect(res.statusCode).toBe(200);
expect(req.webdecoyDecision.allowed).toBe(false);
});

it('refuses only when mode is enforce', async () => {
const handler = jest.fn();
const res = pagesRes();

await withBotProtection(handler, { skipLocalAnalysis: true, mode: 'enforce' } as any)(pagesReq(), res);

expect(handler).not.toHaveBeenCalled();
expect(res.statusCode).toBe(403);
});
});
Loading