diff --git a/CHANGELOG.md b/CHANGELOG.md index 643ec09..dc55ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/openwiki/integrations/framework-adapters.md b/openwiki/integrations/framework-adapters.md index 04bee56..511961f 100644 --- a/openwiki/integrations/framework-adapters.md +++ b/openwiki/integrations/framework-adapters.md @@ -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` | @@ -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 diff --git a/packages/nextjs/src/middleware.ts b/packages/nextjs/src/middleware.ts index 66ed0a9..5d72cb8 100644 --- a/packages/nextjs/src/middleware.ts +++ b/packages/nextjs/src/middleware.ts @@ -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', * }); * ``` */ @@ -293,6 +296,9 @@ export function withBotProtection 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) => { const [req, res] = args; @@ -325,7 +331,7 @@ export function withBotProtection 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); diff --git a/packages/nextjs/src/with-bot-protection.test.ts b/packages/nextjs/src/with-bot-protection.test.ts new file mode 100644 index 0000000..d05fc82 --- /dev/null +++ b/packages/nextjs/src/with-bot-protection.test.ts @@ -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 }; + 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); + }); +});