fingerprint: use device info for rate limit too (#3372)
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled

This commit is contained in:
Daniel Salazar
2026-07-09 20:22:56 -07:00
committed by GitHub
parent 0a46096c41
commit cf90e775bf
4 changed files with 84 additions and 11 deletions
@@ -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).
+17 -5
View File
@@ -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 ------------------------------------------------
@@ -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:
+4 -2
View File
@@ -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.