From fba9c9a056bc8005173aafb898f54a470f186eaa Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Fri, 21 Aug 2026 21:08:12 -0500 Subject: [PATCH] feat(sdk): rate-limit counters can live somewhere shared RateLimitRule hard-constructed an InMemoryRateLimiter -- a Map, with no seam to replace it. On any deployment with more than one process the limit was effectively max x instances, and on Vercel or Lambda it also reset on every cold start. rateLimit({ max: 100, window: 60 }) across eight replicas is an 800/min limit that an autoscaler can raise for you. That was inconsistent with the rest of the SDK: the detection stores and the captcha's challenge and token stores were already behind swappable interfaces. The one piece of state that actually has to be shared was the only one that could not be. evaluate() is synchronous and making it async would turn every rule evaluation into a promise for the sake of the one rule that needs it, so a store declares which it is. A sync store is consumed inline -- the default in-memory path, unchanged. An async store is consumed in a new optional Rule.prepare(), which protect() awaits, the same pre-fetch already used for IP enrichment and Web Bot Auth verdicts. A rule whose async store was never prepared reports NOT_RUN rather than allowing. A rate limiter that has quietly stopped limiting is indistinguishable from one that works, which is the worst of the options. The Upstash store lives in core and uses fetch, not @upstash/redis: two commands in a pipeline is not worth a dependency, a version to track, or a transitive node: import finding its way into an edge bundle. Fixed windows put the window id in the key so expiry is the only cleanup and two processes cannot disagree about which window they are in; sliding windows give each request a random member suffix, because two requests in the same millisecond would otherwise be one ZADD that overwrites rather than two that count. Closes WebDecoy/app#726 --- CHANGELOG.md | 6 + README.md | 36 +++- packages/webdecoy/src/index.ts | 8 + packages/webdecoy/src/rules/index.ts | 9 + .../webdecoy/src/rules/rate-limit-rule.ts | 65 ++++-- .../src/rules/rate-limit-store.test.ts | 201 ++++++++++++++++++ .../webdecoy/src/rules/rate-limit-store.ts | 92 ++++++++ packages/webdecoy/src/rules/types.ts | 26 ++- packages/webdecoy/src/rules/upstash-store.ts | 177 +++++++++++++++ packages/webdecoy/src/sdk.ts | 17 +- 10 files changed, 620 insertions(+), 17 deletions(-) create mode 100644 packages/webdecoy/src/rules/rate-limit-store.test.ts create mode 100644 packages/webdecoy/src/rules/rate-limit-store.ts create mode 100644 packages/webdecoy/src/rules/upstash-store.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cf90941..43bfb89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Rate-limit counters can be shared.** `RateLimitRule` hard-constructed an in-memory `Map` with no seam to replace it, so on any deployment with more than one process the limit was effectively `max × instances` — and on Vercel or Lambda it reset on every cold start. `rateLimit({ store })` now takes a `RateLimitStore`. + - `upstashRateLimitStore({ url, token })` ships in the core package. Upstash speaks Redis over HTTP, which is the only shape that works on Vercel Edge, Workers and Deno, where an ordinary client cannot open a socket. It calls the REST API with `fetch` rather than depending on `@upstash/redis`. + - Fails open by default when Redis is unreachable; `onError: 'closed'` denies instead. Either way the outcome is visible in `decision.results`. + - `Rule` gained an optional `prepare(context)` that `protect()` awaits before evaluation — the same pre-fetch already used for IP enrichment and Web Bot Auth. `evaluate()` stays synchronous, so the default in-memory path is unchanged and allocation-free. + - The synchronous `evaluateRules()` cannot consume a networked store and now reports `NOT_RUN` for such a rule, rather than allowing silently. A rate limiter that has quietly stopped limiting looks identical to one that is working. + - **`protect()` returns a typed decision.** It used to return `{ allowed, detection }`, and the adapters typed the value handed to `onBlocked` as `any`. - `conclusion: 'ALLOW' | 'DENY' | 'CHALLENGE' | 'ERROR'`, with `isAllowed()` / `isDenied()` / `isChallenged()` / `isErrored()` and `deniedBy(rule)`. `ERROR` is a distinct conclusion, so a caller can tell "allowed" from "never decided" — both still serve the request. - `results` — every configured rule in evaluation order with a `state` of `RUN`, `DRY_RUN`, `NOT_RUN` or `CACHED`. `NOT_RUN` is new information: a `filter()` rule with no IP enrichment, or a `webBotAuth()` rule on a request with no host, used to report ALLOW, which reads as "checked and fine" rather than "never checked". A dry-run rule that matched now reports `conclusion: 'DENY'` with `state: 'DRY_RUN'`, rather than the ALLOW its action said. diff --git a/README.md b/README.md index 09c3006..70c5f30 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ const wd = new WebDecoy({ }); ``` -- **`rateLimit({ max, window, algorithm?, action?, keyBy? })`** — fixed or sliding window, keyed by IP (or a custom function). No key. +- **`rateLimit({ max, window, algorithm?, action?, keyBy?, store? })`** — fixed or sliding window, keyed by IP (or a custom function). No key. See [shared rate limits](#rate-limits-across-more-than-one-process) before you run two replicas. - **`tripwire({ paths?, prefixes?, patterns?, includeDefaults? })`** — deterministic honeypot-path detection. No key. - **`webBotAuth({ onImpersonation?, onClaimed?, allowCategories? })`** — verify AI-agent signatures (Web Bot Auth / RFC 9421) locally; deny impersonators of known agents. No key. - **`filter({ expression, action? })`** — an expression language over IP reputation/geo (e.g. `ip.tor`, `ip.country in ["CN", "RU"]`). Requires an API key for enrichment. @@ -204,6 +204,40 @@ if (!result.allowed) { | [@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/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 | +## Rate limits across more than one process + +`rateLimit()` counts in this process's memory by default. That is correct for a +single process and wrong the moment you run two: the effective limit becomes +`max × instances`, and on Vercel or Lambda it also resets on every cold start. + +Point the rule at a shared store to make one limit one limit: + +```typescript +import { rateLimit, upstashRateLimitStore } from '@webdecoy/node'; + +const store = upstashRateLimitStore({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, +}); + +const wd = new WebDecoy({ + rules: [rateLimit({ max: 100, window: 60, store })], +}); +``` + +Upstash speaks Redis over HTTP, so this works on Vercel Edge, Cloudflare Workers +and Deno, where an ordinary Redis client cannot open a socket. It uses `fetch` +directly — no `@upstash/redis` dependency. + +If Redis is unreachable the store **fails open** and the request is allowed; pass +`onError: 'closed'` to deny instead. Either way the outcome appears in +`decision.results`, so it does not look like a normal evaluation. + +Any object implementing `RateLimitStore` works. A store that returns promises is +consumed during `protect()`'s async pre-fetch; a `sync` store is consumed inline. +The synchronous `evaluateRules()` cannot consume a networked store, and reports +`NOT_RUN` rather than allowing silently. + ## Client IP behind a proxy Rate limits, IP enrichment and every detection we record are keyed on the caller's address, so it matters that the address is real. `X-Forwarded-For` is written by the client for the first hop — the leftmost value in it is whatever the caller decided to send — so the middleware believes it only as far as you say it should, counted from the **right** of the chain, which is the end your own infrastructure wrote. diff --git a/packages/webdecoy/src/index.ts b/packages/webdecoy/src/index.ts index 4d64fe8..9621d97 100644 --- a/packages/webdecoy/src/index.ts +++ b/packages/webdecoy/src/index.ts @@ -99,6 +99,9 @@ export { injectHoneytokenLink, isInjectableHtml, HONEYTOKEN_BASE_PATH, + MemoryRateLimitStore, + upstashRateLimitStore, + UpstashRateLimitStore, } from './rules'; export type { @@ -118,6 +121,11 @@ export type { HoneytokenLinkProps, ViolationEvent, IPEnrichmentData, + RateLimitStore, + SyncRateLimitStore, + RateLimitOutcome, + RateLimitConsume, + UpstashStoreOptions, } from './rules'; // Local Web Bot Auth verification (RFC 9421, tag "web-bot-auth") diff --git a/packages/webdecoy/src/rules/index.ts b/packages/webdecoy/src/rules/index.ts index 1ed0d5f..ef57c11 100644 --- a/packages/webdecoy/src/rules/index.ts +++ b/packages/webdecoy/src/rules/index.ts @@ -10,6 +10,8 @@ export { BotRule } from './bot-rule'; export { WebBotAuthRule, webBotAuth } from './web-bot-auth-rule'; export { honeytoken } from './honeytoken'; export { InMemoryRateLimiter } from './rate-limiter'; +export { MemoryRateLimitStore } from './rate-limit-store'; +export { upstashRateLimitStore, UpstashRateLimitStore } from './upstash-store'; export type { Rule, @@ -25,6 +27,13 @@ export type { } from './types'; export type { WebBotAuthConfig } from './web-bot-auth-rule'; export type { HoneytokenOptions, Honeytoken } from './honeytoken'; +export type { + RateLimitStore, + SyncRateLimitStore, + RateLimitOutcome, + RateLimitConsume, +} from './rate-limit-store'; +export type { UpstashStoreOptions } from './upstash-store'; import { RateLimitRule } from './rate-limit-rule'; import { FilterRule } from './filter-rule'; diff --git a/packages/webdecoy/src/rules/rate-limit-rule.ts b/packages/webdecoy/src/rules/rate-limit-rule.ts index 58abba3..1fa4e3f 100644 --- a/packages/webdecoy/src/rules/rate-limit-rule.ts +++ b/packages/webdecoy/src/rules/rate-limit-rule.ts @@ -3,12 +3,13 @@ * Implements the Rule interface using InMemoryRateLimiter */ -import { InMemoryRateLimiter } from './rate-limiter'; import { Rule, RuleContext, RuleResult, RateLimitConfig } from './types'; +import { MemoryRateLimitStore } from './rate-limit-store'; +import type { RateLimitStore, RateLimitOutcome, RateLimitConsume } from './rate-limit-store'; export class RateLimitRule implements Rule { readonly name: string; - private limiter: InMemoryRateLimiter; + private store: RateLimitStore; private config: Required< Pick > & @@ -16,7 +17,7 @@ export class RateLimitRule implements Rule { constructor(config: RateLimitConfig) { this.name = `rate-limit:${config.max}/${config.window}s`; - this.limiter = new InMemoryRateLimiter(); + this.store = config.store ?? new MemoryRateLimitStore(); this.config = { max: config.max, window: config.window, @@ -27,17 +28,55 @@ export class RateLimitRule implements Rule { }; } + /** + * Which bucket this request counts against. + * + * Precedence: this rule's own keyBy, then the SDK-wide characteristics, then + * the IP. `context.key` is always populated, so the last fallback only matters + * for a context built by hand. + */ + private keyFor(context: RuleContext): string { + return this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip); + } + + private consumption(context: RuleContext): RateLimitConsume { + return { + key: this.keyFor(context), + max: this.config.max, + windowMs: this.config.window * 1000, + algorithm: this.config.algorithm, + }; + } + + /** Consume from a networked store before evaluation. No-op for a sync store. */ + async prepare(context: RuleContext): Promise { + if (this.store.sync) return; + const outcome = await this.store.consume(this.consumption(context)); + context.prepared ??= {}; + context.prepared[this.name] = outcome; + } + evaluate(context: RuleContext): RuleResult { - // Precedence: this rule's own keyBy, then the SDK-wide characteristics, - // then the IP. `context.key` is always populated, so the last fallback only - // matters for a context built by hand. - const key = this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip); - const windowMs = this.config.window * 1000; + let result: RateLimitOutcome; - const result = - this.config.algorithm === 'sliding' - ? this.limiter.checkSlidingWindow(key, this.config.max, windowMs) - : this.limiter.checkFixedWindow(key, this.config.max, windowMs); + if (this.store.sync) { + result = this.store.consume(this.consumption(context)) as RateLimitOutcome; + } else { + const prepared = context.prepared?.[this.name] as RateLimitOutcome | undefined; + if (!prepared) { + // A networked store that was never consumed. Saying so beats allowing + // silently: a rate limiter that has quietly stopped limiting looks + // identical to one that is working. + return { + action: 'ALLOW', + rule: this.name, + state: 'NOT_RUN', + reason: + 'Rate limit uses an async store and was not prepared — call protect() or evaluateRulesAsync()', + }; + } + result = prepared; + } if (!result.allowed) { const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000); @@ -68,6 +107,6 @@ export class RateLimitRule implements Rule { } destroy(): void { - this.limiter.destroy(); + void this.store.destroy?.(); } } diff --git a/packages/webdecoy/src/rules/rate-limit-store.test.ts b/packages/webdecoy/src/rules/rate-limit-store.test.ts new file mode 100644 index 0000000..b1a06fa --- /dev/null +++ b/packages/webdecoy/src/rules/rate-limit-store.test.ts @@ -0,0 +1,201 @@ +import { WebDecoy } from '../sdk'; +import { rateLimit } from './index'; +import { MemoryRateLimitStore, type RateLimitStore } from './rate-limit-store'; +import { UpstashRateLimitStore } from './upstash-store'; +import type { RequestMetadata } from '../types'; + +const req = (over: Partial = {}): RequestMetadata => ({ + method: 'GET', + path: '/', + ip: '203.0.113.9', + headers: {}, + timestamp: Date.now(), + ...over, +}); + +/** + * A shared async store, standing in for Redis. Two SDK instances pointed at one + * of these is the whole point of the feature: the limit has to be one limit. + */ +function sharedStore(): RateLimitStore & { calls: number } { + const counts = new Map(); + return { + sync: false, + calls: 0, + async consume({ key, max, windowMs }) { + this.calls++; + await Promise.resolve(); + const current = (counts.get(key) ?? 0) + 1; + counts.set(key, current); + return { allowed: current <= max, current, resetAt: Date.now() + windowMs }; + }, + }; +} + +describe('the default in-memory store', () => { + it('still limits synchronously, unchanged', async () => { + const wd = new WebDecoy({ rules: [rateLimit({ max: 2, window: 60, action: 'DENY' })] }); + const statuses = [ + (await wd.protect(req())).allowed, + (await wd.protect(req())).allowed, + (await wd.protect(req())).allowed, + ]; + expect(statuses).toEqual([true, true, false]); + }); + + it('is what you get when no store is configured', () => { + const store = new MemoryRateLimitStore(); + expect(store.sync).toBe(true); + const first = store.consume({ key: 'k', max: 1, windowMs: 1000, algorithm: 'fixed' }); + const second = store.consume({ key: 'k', max: 1, windowMs: 1000, algorithm: 'fixed' }); + expect(first.allowed).toBe(true); + expect(second.allowed).toBe(false); + }); +}); + +describe('a shared async store', () => { + it('enforces one limit across two SDK instances', async () => { + // This is the bug: before, each replica had its own Map, so `max: 2` across + // two processes was a limit of four. + const store = sharedStore(); + const a = new WebDecoy({ rules: [rateLimit({ max: 2, window: 60, action: 'DENY', store })] }); + const b = new WebDecoy({ rules: [rateLimit({ max: 2, window: 60, action: 'DENY', store })] }); + + expect((await a.protect(req())).allowed).toBe(true); + expect((await b.protect(req())).allowed).toBe(true); + expect((await a.protect(req())).allowed).toBe(false); + expect((await b.protect(req())).allowed).toBe(false); + }); + + it('consumes exactly once per request', async () => { + // prepare() consumes and evaluate() reads what it left. A second consume in + // evaluate would double-count every request and halve the effective limit. + const store = sharedStore(); + const wd = new WebDecoy({ rules: [rateLimit({ max: 10, window: 60, store })] }); + await wd.protect(req()); + await wd.protect(req()); + expect(store.calls).toBe(2); + }); + + it('honours characteristics through the shared store', async () => { + const store = sharedStore(); + const wd = new WebDecoy({ + characteristics: [(c) => c.headers['x-api-key']], + rules: [rateLimit({ max: 1, window: 60, action: 'DENY', store })], + }); + expect((await wd.protect(req({ headers: { 'x-api-key': 'a' } }))).allowed).toBe(true); + expect((await wd.protect(req({ headers: { 'x-api-key': 'b' } }))).allowed).toBe(true); + expect((await wd.protect(req({ headers: { 'x-api-key': 'a' } }))).allowed).toBe(false); + }); + + it('reports NOT_RUN rather than allowing silently when never prepared', () => { + // The synchronous entry point cannot consume a networked store. Allowing + // quietly would look identical to a limiter that is working. + const wd = new WebDecoy({ rules: [rateLimit({ max: 1, window: 60, store: sharedStore() })] }); + const result = wd.evaluateRules({ + method: 'GET', + path: '/', + ip: '203.0.113.9', + headers: {}, + timestamp: Date.now(), + }); + expect(result?.results[0].state).toBe('NOT_RUN'); + expect(result?.action).toBe('ALLOW'); + }); +}); + +describe('the Upstash store', () => { + const options = { url: 'https://example.upstash.io', token: 'tok' }; + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + function mockPipeline(results: unknown[], ok = true) { + const spy = jest.fn(async (_url: string, _init: RequestInit) => ({ + ok, + status: ok ? 200 : 500, + json: async () => results.map((result) => ({ result })), + })); + globalThis.fetch = spy as unknown as typeof fetch; + return spy; + } + + it('rejects construction without credentials', () => { + expect(() => new UpstashRateLimitStore({ url: '', token: 't' })).toThrow(/url/); + expect(() => new UpstashRateLimitStore({ url: 'u', token: '' })).toThrow(/token/); + }); + + it('increments a window-scoped key and sets its TTL only once', async () => { + const spy = mockPipeline([1, 1]); + const store = new UpstashRateLimitStore(options); + const out = await store.consume({ key: 'k', max: 5, windowMs: 60_000, algorithm: 'fixed' }); + + expect(out).toMatchObject({ allowed: true, current: 1 }); + const body = JSON.parse(spy.mock.calls[0][1].body as string); + expect(body[0][0]).toBe('INCR'); + // The window id is in the key, so expiry is the only cleanup needed and two + // processes cannot disagree about which window they are in. + expect(body[0][1]).toMatch(/^wd:rl:f:k:\d+$/); + // NX, or a long window gets extended into a sliding one by later requests. + expect(body[1]).toEqual(['EXPIRE', body[0][1], '60', 'NX']); + }); + + it('denies once the count passes max', async () => { + mockPipeline([6, 0]); + const store = new UpstashRateLimitStore(options); + const out = await store.consume({ key: 'k', max: 5, windowMs: 60_000, algorithm: 'fixed' }); + expect(out.allowed).toBe(false); + expect(out.current).toBe(6); + }); + + it('gives each sliding-window request a distinct member', async () => { + const spy = mockPipeline([0, 1, 1, 1]); + const store = new UpstashRateLimitStore(options); + await store.consume({ key: 'k', max: 5, windowMs: 1000, algorithm: 'sliding' }); + await store.consume({ key: 'k', max: 5, windowMs: 1000, algorithm: 'sliding' }); + + const member = (call: number) => + JSON.parse(spy.mock.calls[call][1].body as string)[1][3]; + // Two requests in the same millisecond must be two entries. One ZADD that + // overwrites the other undercounts exactly when the limit matters. + expect(member(0)).not.toBe(member(1)); + }); + + it('fails open by default when Redis is unreachable', async () => { + globalThis.fetch = (async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + const store = new UpstashRateLimitStore(options); + const out = await store.consume({ key: 'k', max: 1, windowMs: 1000, algorithm: 'fixed' }); + expect(out.allowed).toBe(true); + }); + + it('fails closed when asked to', async () => { + globalThis.fetch = (async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + const store = new UpstashRateLimitStore({ ...options, onError: 'closed' }); + const out = await store.consume({ key: 'k', max: 1, windowMs: 1000, algorithm: 'fixed' }); + expect(out.allowed).toBe(false); + }); + + it('treats a command-level error as a failure, not a zero count', async () => { + globalThis.fetch = (async () => ({ + ok: true, + status: 200, + json: async () => [{ error: 'WRONGTYPE' }], + })) as unknown as typeof fetch; + const store = new UpstashRateLimitStore({ ...options, onError: 'closed' }); + const out = await store.consume({ key: 'k', max: 1, windowMs: 1000, algorithm: 'fixed' }); + expect(out.allowed).toBe(false); + }); + + it('trims a trailing slash off the url', async () => { + const spy = mockPipeline([1, 1]); + const store = new UpstashRateLimitStore({ ...options, url: 'https://example.upstash.io/' }); + await store.consume({ key: 'k', max: 5, windowMs: 1000, algorithm: 'fixed' }); + expect(spy.mock.calls[0][0]).toBe('https://example.upstash.io/pipeline'); + }); +}); diff --git a/packages/webdecoy/src/rules/rate-limit-store.ts b/packages/webdecoy/src/rules/rate-limit-store.ts new file mode 100644 index 0000000..19a8e2e --- /dev/null +++ b/packages/webdecoy/src/rules/rate-limit-store.ts @@ -0,0 +1,92 @@ +/** + * Where rate-limit counters live. + * + * WHY THIS EXISTS + * + * `RateLimitRule` hard-constructed an `InMemoryRateLimiter` — a `Map`, with no + * seam to replace it. On any deployment with more than one process the limit was + * effectively `max × instances`, and on Vercel or Lambda it also reset on every + * cold start. A `rateLimit({ max: 100, window: 60 })` across eight replicas is a + * 800/min limit that an autoscaler can raise for you. + * + * That was inconsistent with the rest of the SDK: the detection stores and the + * captcha's challenge and token stores were already behind swappable interfaces + * with in-memory defaults. The one piece of state that actually has to be shared + * was the only one that could not be. + * + * SYNC AND ASYNC + * + * `Rule.evaluate()` is synchronous, and making it async would turn every rule + * evaluation into a promise for the sake of the one rule that might need it. So + * a store declares which it is: + * + * - A **sync** store is consumed inline during `evaluate()`. This is the default + * in-memory path, unchanged and allocation-free. + * - An **async** store is consumed in `prepare()`, which `protect()` awaits + * before evaluation — the same pre-fetch the SDK already does for IP + * enrichment and Web Bot Auth verdicts. + * + * A rule whose async store was never prepared reports `NOT_RUN` rather than + * silently allowing. A rate limiter that quietly stops limiting is worse than + * one that says it is not running. + */ + +/** What one consumption of the limit produced. */ +export interface RateLimitOutcome { + /** Whether this request is within the limit. */ + allowed: boolean; + /** Requests counted in the current window, including this one. */ + current: number; + /** When the window resets, as a Unix ms timestamp. */ + resetAt: number; +} + +export interface RateLimitConsume { + key: string; + max: number; + windowMs: number; + algorithm: 'fixed' | 'sliding'; +} + +/** + * A counter store. + * + * `sync: true` means `consume()` returns an outcome directly and may be called + * during rule evaluation. `sync: false` means it returns a promise and will be + * consumed during the async pre-fetch instead. + */ +export interface RateLimitStore { + readonly sync: boolean; + consume(input: RateLimitConsume): RateLimitOutcome | Promise; + destroy?(): void | Promise; +} + +/** A store whose `consume` is callable inline. */ +export interface SyncRateLimitStore extends RateLimitStore { + readonly sync: true; + consume(input: RateLimitConsume): RateLimitOutcome; +} + +import { InMemoryRateLimiter } from './rate-limiter'; + +/** + * The default: counters in this process's memory. + * + * Correct for a single process, and the reason the SDK works with no + * infrastructure at all. Wrong the moment you run two — see + * {@link upstashRateLimitStore} for the shared alternative. + */ +export class MemoryRateLimitStore implements SyncRateLimitStore { + readonly sync = true as const; + private limiter = new InMemoryRateLimiter(); + + consume({ key, max, windowMs, algorithm }: RateLimitConsume): RateLimitOutcome { + return algorithm === 'sliding' + ? this.limiter.checkSlidingWindow(key, max, windowMs) + : this.limiter.checkFixedWindow(key, max, windowMs); + } + + destroy(): void { + this.limiter.destroy(); + } +} diff --git a/packages/webdecoy/src/rules/types.ts b/packages/webdecoy/src/rules/types.ts index 7dd6d7a..60fd052 100644 --- a/packages/webdecoy/src/rules/types.ts +++ b/packages/webdecoy/src/rules/types.ts @@ -54,6 +54,11 @@ export interface RuleContext { * the IP, so a rule can read it unconditionally. */ key?: string; + /** + * Outcomes a rule resolved during the async pre-fetch, keyed by rule name. + * Populated by {@link Rule.prepare}; read synchronously by `evaluate`. + */ + prepared?: Record; } /** @@ -87,6 +92,16 @@ export interface Rule { name: string; /** Evaluate the rule against request context */ evaluate(context: RuleContext): RuleResult; + /** + * Resolve anything this rule needs from the network before `evaluate` runs, + * stashing it on `context.prepared`. + * + * `evaluate` is synchronous, and making it async would turn every rule + * evaluation into a promise for the sake of the one rule that needs it. This + * is the same pre-fetch the SDK already does for IP enrichment and Web Bot + * Auth verdicts. + */ + prepare?(context: RuleContext): Promise; /** Clean up resources (timers, etc.) */ destroy?(): void; } @@ -101,8 +116,17 @@ export interface RateLimitConfig { window: number; /** Algorithm: 'fixed' (default) or 'sliding' */ algorithm?: 'fixed' | 'sliding'; - /** Custom key derivation function. Default: by IP */ + /** Custom key derivation function. Default: the SDK's characteristics, then IP */ keyBy?: (context: RuleContext) => string; + /** + * Where the counters live. Defaults to this process's memory, which is + * correct for a single process and wrong the moment you run two — the limit + * becomes `max × instances` and resets on every cold start. + * + * Pass {@link upstashRateLimitStore} (or your own {@link RateLimitStore}) to + * share counters across replicas. + */ + store?: import('./rate-limit-store').RateLimitStore; /** Action when limit is exceeded: 'DENY' or 'THROTTLE' (default: 'THROTTLE') */ action?: 'DENY' | 'THROTTLE'; /** Dry run mode: log violations but don't block */ diff --git a/packages/webdecoy/src/rules/upstash-store.ts b/packages/webdecoy/src/rules/upstash-store.ts new file mode 100644 index 0000000..5ca17fb --- /dev/null +++ b/packages/webdecoy/src/rules/upstash-store.ts @@ -0,0 +1,177 @@ +/** + * Rate-limit counters in Upstash Redis. + * + * WHY UPSTASH, AND WHY NO DEPENDENCY + * + * Upstash speaks Redis over HTTP, which is the only shape that works everywhere + * this SDK runs: Vercel Edge, Cloudflare Workers and Deno have no `node:net`, so + * an ordinary Redis client cannot open a socket there. The core package already + * passes an edge-compatibility gate, and a store that only worked on Node would + * be unavailable in exactly the serverless deployments that need shared counters + * most. + * + * It talks to the REST API with `fetch` rather than pulling in `@upstash/redis`. + * Two commands in a pipeline is not worth a dependency, a version to track, or a + * transitive `node:` import finding its way into an edge bundle. + * + * ```ts + * rateLimit({ + * max: 100, + * window: 60, + * store: upstashRateLimitStore({ + * url: process.env.UPSTASH_REDIS_REST_URL!, + * token: process.env.UPSTASH_REDIS_REST_TOKEN!, + * }), + * }) + * ``` + */ + +import type { RateLimitStore, RateLimitConsume, RateLimitOutcome } from './rate-limit-store'; +import { randomHex } from '../webcrypto'; + +export interface UpstashStoreOptions { + /** REST endpoint, e.g. `https://eu1-xxx.upstash.io`. */ + url: string; + /** REST token. */ + token: string; + /** Prefix for every key written. @default 'wd:rl:' */ + prefix?: string; + /** Request timeout in milliseconds. @default 1000 */ + timeout?: number; + /** + * What to do when Redis is unreachable. + * + * `'open'` (default) allows the request: a rate limiter that takes the site + * down when its datastore has a bad minute has done more damage than the + * traffic it was shaping. `'closed'` denies, for a limit that is protecting + * something more expensive than availability. + * + * Either way the outcome is reported, so the decision says which happened + * rather than looking like a normal evaluation. + */ + onError?: 'open' | 'closed'; +} + +interface PipelineResult { + result?: unknown; + error?: string; +} + +export class UpstashRateLimitStore implements RateLimitStore { + readonly sync = false as const; + private readonly url: string; + private readonly token: string; + private readonly prefix: string; + private readonly timeout: number; + private readonly failOpen: boolean; + + constructor(options: UpstashStoreOptions) { + if (!options.url) throw new Error('upstashRateLimitStore: `url` is required'); + if (!options.token) throw new Error('upstashRateLimitStore: `token` is required'); + this.url = options.url.replace(/\/+$/, ''); + this.token = options.token; + this.prefix = options.prefix ?? 'wd:rl:'; + this.timeout = options.timeout ?? 1000; + this.failOpen = (options.onError ?? 'open') === 'open'; + } + + async consume(input: RateLimitConsume): Promise { + try { + return input.algorithm === 'sliding' + ? await this.sliding(input) + : await this.fixed(input); + } catch { + const now = Date.now(); + return { + allowed: this.failOpen, + current: 0, + resetAt: now + input.windowMs, + }; + } + } + + /** + * Fixed window: one counter per key per window. + * + * The window id is in the key rather than tracked separately, so expiry is the + * only cleanup needed and two processes incrementing concurrently cannot + * disagree about which window they are in. + */ + private async fixed({ key, max, windowMs }: RateLimitConsume): Promise { + const now = Date.now(); + const windowStart = Math.floor(now / windowMs) * windowMs; + const redisKey = `${this.prefix}f:${key}:${windowStart}`; + const ttlSeconds = Math.ceil(windowMs / 1000); + + // EXPIRE with NX only sets the TTL on the first increment, so a long-running + // window is not extended by later requests into a sliding one by accident. + const [incr] = await this.pipeline([ + ['INCR', redisKey], + ['EXPIRE', redisKey, String(ttlSeconds), 'NX'], + ]); + + const current = toCount(incr); + return { allowed: current <= max, current, resetAt: windowStart + windowMs }; + } + + /** + * Sliding window: a sorted set of request timestamps, trimmed on every read. + * + * The member is timestamp plus random bytes because two requests in the same + * millisecond would otherwise be one ZADD that overwrites rather than two that + * count — undercounting exactly when the limit matters. + */ + private async sliding({ key, max, windowMs }: RateLimitConsume): Promise { + const now = Date.now(); + const redisKey = `${this.prefix}s:${key}`; + const cutoff = now - windowMs; + const ttlSeconds = Math.ceil(windowMs / 1000) + 1; + + const results = await this.pipeline([ + ['ZREMRANGEBYSCORE', redisKey, '0', String(cutoff)], + ['ZADD', redisKey, String(now), `${now}-${randomHex(4)}`], + ['ZCARD', redisKey], + ['EXPIRE', redisKey, String(ttlSeconds)], + ]); + + const current = toCount(results[2]); + return { allowed: current <= max, current, resetAt: now + windowMs }; + } + + private async pipeline(commands: string[][]): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeout); + try { + const response = await fetch(`${this.url}/pipeline`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(commands), + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Upstash responded ${response.status}`); + } + const body = (await response.json()) as PipelineResult[]; + if (!Array.isArray(body)) throw new Error('Upstash returned a non-pipeline body'); + for (const entry of body) { + if (entry?.error) throw new Error(`Upstash command failed: ${entry.error}`); + } + return body.map((entry) => entry?.result); + } finally { + clearTimeout(timer); + } + } +} + +function toCount(value: unknown): number { + const n = typeof value === 'string' ? Number(value) : value; + return typeof n === 'number' && Number.isFinite(n) ? n : 0; +} + +/** Rate-limit counters shared through Upstash Redis. See {@link UpstashStoreOptions}. */ +export function upstashRateLimitStore(options: UpstashStoreOptions): RateLimitStore { + return new UpstashRateLimitStore(options); +} diff --git a/packages/webdecoy/src/sdk.ts b/packages/webdecoy/src/sdk.ts index cc1a7db..7717b44 100644 --- a/packages/webdecoy/src/sdk.ts +++ b/packages/webdecoy/src/sdk.ts @@ -18,7 +18,7 @@ import { deriveKey, DEFAULT_CHARACTERISTICS } from './characteristics'; import { DecisionCache } from './decision-cache'; import { classifyUserAgent } from './bots'; import { isTestTriggerUserAgent } from './test-trigger'; -import type { RuleContext, RuleEngineResult, ViolationEvent } from './rules/types'; +import type { Rule, RuleContext, RuleEngineResult, ViolationEvent } from './rules/types'; import { WebDecoyConfig, RequestMetadata, @@ -40,6 +40,7 @@ export class WebDecoy { private ipEnrichmentClient: IPEnrichmentClient | null = null; private _hasFilterRules = false; private _hasAgentRules = false; + private _preparingRules: Rule[] = []; private agentVerifier: AgentVerifier | null = null; private readonly webBotAuthOptions?: WebDecoyConfig['webBotAuth']; private readonly characteristics: readonly import('./characteristics').Characteristic[]; @@ -116,6 +117,9 @@ export class WebDecoy { // Web Bot Auth rules need the agent verdict precomputed (async) before // the synchronous rule can act on it — same pattern as filter rules. this._hasAgentRules = rules.some((r) => r.name === 'web-bot-auth'); + // A rule with a networked store consumes it before evaluation, so the + // synchronous evaluate() can stay synchronous. + this._preparingRules = rules.filter((r) => typeof r.prepare === 'function'); if (this._hasAgentRules) { // Warm the directory cache so the first protected request verifies warm. this.getAgentVerifier().warmup(); @@ -184,6 +188,7 @@ export class WebDecoy { } if (rule.name.startsWith('filter:')) this._hasFilterRules = true; if (rule.name === 'web-bot-auth') this._hasAgentRules = true; + if (typeof rule.prepare === 'function') this._preparingRules.push(rule); } /** Build the base (synchronous) rule context from request metadata. */ @@ -231,6 +236,13 @@ export class WebDecoy { context.agent = await this.computeAgentVerdict(metadata); } + // Let rules resolve their own networked signals. Run together rather than + // in sequence: two rate limits against the same store are two round trips + // whether or not we wait for the first, and the request is waiting on both. + if (this._preparingRules.length > 0) { + await Promise.all(this._preparingRules.map((rule) => rule.prepare!(context))); + } + return context; } @@ -354,7 +366,8 @@ export class WebDecoy { // One context for the whole decision. Async only when a rule needs a // pre-fetched signal — IP enrichment (filter rules) or Web Bot Auth // verification (webBotAuth rules). - const needsAsync = this._hasFilterRules || this._hasAgentRules; + const needsAsync = + this._hasFilterRules || this._hasAgentRules || this._preparingRules.length > 0; const context = needsAsync ? await this.buildAsyncContext(metadata) : this.buildContext(metadata);