From cf90e775bf828c17e5f10a4c327ce60f71af5c4c Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Thu, 9 Jul 2026 23:22:56 -0400 Subject: [PATCH] fingerprint: use device info for rate limit too (#3372) --- .../core/http/middleware/fingerprint.ts | 10 ++-- src/backend/core/http/middleware/rateLimit.js | 22 +++++-- .../core/http/middleware/rateLimit.test.js | 57 +++++++++++++++++++ src/backend/core/http/types.ts | 6 +- 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/backend/core/http/middleware/fingerprint.ts b/src/backend/core/http/middleware/fingerprint.ts index b7cb3fd46..a940dacd4 100644 --- a/src/backend/core/http/middleware/fingerprint.ts +++ b/src/backend/core/http/middleware/fingerprint.ts @@ -55,13 +55,15 @@ function readDeviceFingerprint(req: { * Stamp request-scoped fingerprints onto `req` so any downstream gate, handler, * or service (via the ALS `Context.get('req')`) can read them without recomputing: * - * - `req.networkFingerprint` — always set; a coarse IP+headers hash (the same - * value the rate limiter keys on). Server-derived, so it can't be forged - * away, but it's coarse (shared behind NAT/VPN, rotates with UA). + * - `req.networkFingerprint` — always set; a coarse IP+headers hash (the + * anchor of the rate limiter's default key). Server-derived, so it can't be + * forged away, but it's coarse (shared behind NAT/VPN, rotates with UA). * - `req.deviceFingerprint` — set only when the client supplied a well-shaped * device fingerprint (ThumbmarkJS hash) in the body or the * `x-puter-device-fingerprint` header; `undefined` otherwise. Client-supplied - * and spoofable, but stable per real device across IP rotation. + * and spoofable, but stable per real device across IP rotation. The rate + * limiter's 'fingerprint' strategy appends it to the network hash so each + * device behind a shared network gets its own bucket. * * Install AFTER the body parsers (so the body fingerprint is readable) and * before `requestContext` (so the snapshot into ALS already carries them). diff --git a/src/backend/core/http/middleware/rateLimit.js b/src/backend/core/http/middleware/rateLimit.js index 2a7b10ca1..a6cfb6ec7 100644 --- a/src/backend/core/http/middleware/rateLimit.js +++ b/src/backend/core/http/middleware/rateLimit.js @@ -351,9 +351,10 @@ function resolveBackend(name) { * Build a rate-limit key from the request. * * Strategies: - * 'fingerprint' — IP + User-Agent hash (default). Good for - * unauthenticated endpoints where the same IP may - * serve many users (offices, VPNs). + * 'fingerprint' — network hash (IP + headers), refined by the client's + * device fingerprint when one was supplied (default). + * Good for unauthenticated endpoints where the same + * IP may serve many users (offices, VPNs). * 'ip' — bare IP. Simpler but coarser. * 'user' — actor UUID. Use for authenticated endpoints where * you want per-account limits regardless of IP. @@ -395,7 +396,7 @@ function ip(req) { /** * A coarse network fingerprint for a request: a short hash of the (proxy-aware) * IP plus the headers a client can't trivially vary per-request without also - * changing how the request looks. Used as the default rate-limit key here, and + * changing how the request looks. Anchors the default rate-limit key here, and * exported so the global fingerprint middleware can stamp the identical value * on `req.networkFingerprint` (one key space shared by both). */ @@ -413,8 +414,19 @@ export function computeNetworkFingerprint(req) { .slice(0, 16); } +/** + * The device fingerprint (validated and stamped by the fingerprint middleware) + * refines the bucket so devices behind one NAT don't crowd each other's limit. + * It stays anchored to the network hash because the value is client-supplied: + * alone it could be spoofed to drain another device's bucket, and rotating it + * to mint fresh buckets is caught by the same stacked 'ip' backstop that + * catches User-Agent rotation. + */ function fingerprint(req) { - return computeNetworkFingerprint(req); + const network = req.networkFingerprint ?? computeNetworkFingerprint(req); + return req.deviceFingerprint + ? `${network}:${req.deviceFingerprint}` + : network; } // -- Route middleware ------------------------------------------------ diff --git a/src/backend/core/http/middleware/rateLimit.test.js b/src/backend/core/http/middleware/rateLimit.test.js index 7220e12b4..70f2c7d1d 100644 --- a/src/backend/core/http/middleware/rateLimit.test.js +++ b/src/backend/core/http/middleware/rateLimit.test.js @@ -67,6 +67,7 @@ describe('rateLimitGate — memory backend (default)', () => { actor: init.actor, route: init.route, socket: init.socket ?? { remoteAddress: '1.2.3.4' }, + deviceFingerprint: init.deviceFingerprint, }); it('admits up to `limit` hits and rejects the next one with 429', async () => { @@ -140,6 +141,62 @@ describe('rateLimitGate — memory backend (default)', () => { expect(isHttpError(re)).toBe(true); }); + it("'fingerprint' (default) gives each device behind one network its own bucket", async () => { + // Same IP and identical headers (a NAT'd office of look-alike + // machines) but distinct device fingerprints (stamped by the + // fingerprint middleware) → independent buckets, so one device + // hitting its limit doesn't block the whole network. + const opts = { limit: 1, window: 60_000, scope: 'mem-fp-device' }; + const shared = { + ip: '5.6.7.8', + headers: { 'user-agent': 'shared-browser' }, + }; + expect( + await runGate( + opts, + makeReq({ ...shared, deviceFingerprint: 'device-alice-01' }), + ), + ).toBeUndefined(); + expect( + await runGate( + opts, + makeReq({ ...shared, deviceFingerprint: 'device-bob-02' }), + ), + ).toBeUndefined(); + // Same device again → its own bucket is full. + const re = await runGate( + opts, + makeReq({ ...shared, deviceFingerprint: 'device-alice-01' }), + ); + expect(isHttpError(re)).toBe(true); + }); + + it('device fingerprint stays anchored to the network — spoofing a value from another IP cannot drain its bucket', async () => { + const opts = { limit: 1, window: 60_000, scope: 'mem-fp-anchor' }; + const fp = 'device-victim-01'; + // Victim exhausts their bucket from their own network. + expect( + await runGate( + opts, + makeReq({ ip: '10.0.0.1', deviceFingerprint: fp }), + ), + ).toBeUndefined(); + const re = await runGate( + opts, + makeReq({ ip: '10.0.0.1', deviceFingerprint: fp }), + ); + expect(isHttpError(re)).toBe(true); + // Attacker replays the same device fingerprint from elsewhere: + // different network hash → different bucket, and the victim's + // bucket is untouched. + expect( + await runGate( + opts, + makeReq({ ip: '99.99.99.99', deviceFingerprint: fp }), + ), + ).toBeUndefined(); + }); + // Stacked gates, mirroring the materializer's handling of a // `rateLimit: [...]` array (each entry is its own gate; a rejection // short-circuits the chain). This is the credential-endpoint pattern: diff --git a/src/backend/core/http/types.ts b/src/backend/core/http/types.ts index 99a38aad7..1826f84d6 100644 --- a/src/backend/core/http/types.ts +++ b/src/backend/core/http/types.ts @@ -210,8 +210,10 @@ export interface RouteOptions { * request identity. * * `key` controls how requests are bucketed: - * - `'fingerprint'` (default) — IP + User-Agent hash. Safe for - * shared IPs (offices, VPNs). + * - `'fingerprint'` (default) — network hash (IP + headers), + * refined by the client's device fingerprint when one was + * supplied. Safe for shared IPs (offices, VPNs): each device + * gets its own bucket instead of the whole network sharing one. * - `'ip'` — bare IP address. * - `'user'` — actor's user ID. Use for authenticated routes * where you want per-account limits.