mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-21 04:36:18 +00:00
fingerprint: use device info for rate limit too (#3372)
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user