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);