@oxyhq/core 10.1.5 → 10.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/server/rateLimit.js +100 -6
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/server/rateLimit.js +100 -6
- package/dist/types/.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/server/__tests__/rateLimit.test.ts +98 -4
- package/src/server/rateLimit.ts +105 -6
package/package.json
CHANGED
|
@@ -26,6 +26,8 @@ interface RateLimitTestRequest extends Request {
|
|
|
26
26
|
observedKey?: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const HEX24 = /^[0-9a-f]{24}$/;
|
|
30
|
+
|
|
29
31
|
function makeOxy(authHandler: RequestHandler): OxyServices {
|
|
30
32
|
return {
|
|
31
33
|
auth: jest.fn(() => authHandler),
|
|
@@ -33,17 +35,37 @@ function makeOxy(authHandler: RequestHandler): OxyServices {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
function makeRequest(overrides: Partial<RateLimitTestRequest> = {}): RateLimitTestRequest {
|
|
38
|
+
const ip = overrides.ip ?? '203.0.113.9';
|
|
36
39
|
return {
|
|
37
40
|
method: 'GET',
|
|
38
41
|
path: '/api/test',
|
|
39
|
-
ip
|
|
40
|
-
socket: { remoteAddress:
|
|
42
|
+
ip,
|
|
43
|
+
socket: { remoteAddress: ip },
|
|
41
44
|
...overrides,
|
|
42
45
|
} as RateLimitTestRequest;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/** Run the anonymous limiter for a bare IP and return the store key it produced. */
|
|
49
|
+
function keyForIp(ip: string): string {
|
|
50
|
+
const oxy = makeOxy((_req: Request, _res: Response, next: NextFunction) => next());
|
|
51
|
+
const req = makeRequest({ ip });
|
|
52
|
+
createOxyRateLimit(oxy)(req, {} as Response, jest.fn());
|
|
53
|
+
if (typeof req.observedKey !== 'string') {
|
|
54
|
+
throw new Error('key generator did not run');
|
|
55
|
+
}
|
|
56
|
+
return req.observedKey;
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
describe('@oxyhq/core/server rate limiter', () => {
|
|
60
|
+
const originalEnv = {
|
|
61
|
+
IP_HASH_SALT: process.env.IP_HASH_SALT,
|
|
62
|
+
DEVICE_ID_SALT: process.env.DEVICE_ID_SALT,
|
|
63
|
+
};
|
|
64
|
+
|
|
46
65
|
beforeEach(() => {
|
|
66
|
+
// Isolate salt resolution from any ambient env so key assertions are deterministic.
|
|
67
|
+
delete process.env.IP_HASH_SALT;
|
|
68
|
+
delete process.env.DEVICE_ID_SALT;
|
|
47
69
|
rateLimitMock.mockImplementation((options: CapturedRateLimitOptions) => {
|
|
48
70
|
return (req: RateLimitTestRequest, _res: Response, next: NextFunction) => {
|
|
49
71
|
req.observedMax = options.max(req);
|
|
@@ -55,6 +77,10 @@ describe('@oxyhq/core/server rate limiter', () => {
|
|
|
55
77
|
|
|
56
78
|
afterEach(() => {
|
|
57
79
|
jest.clearAllMocks();
|
|
80
|
+
if (originalEnv.IP_HASH_SALT === undefined) delete process.env.IP_HASH_SALT;
|
|
81
|
+
else process.env.IP_HASH_SALT = originalEnv.IP_HASH_SALT;
|
|
82
|
+
if (originalEnv.DEVICE_ID_SALT === undefined) delete process.env.DEVICE_ID_SALT;
|
|
83
|
+
else process.env.DEVICE_ID_SALT = originalEnv.DEVICE_ID_SALT;
|
|
58
84
|
});
|
|
59
85
|
|
|
60
86
|
it('does not trust locally decoded non-session JWT identities for quota or bucket keys', () => {
|
|
@@ -73,7 +99,9 @@ describe('@oxyhq/core/server rate limiter', () => {
|
|
|
73
99
|
);
|
|
74
100
|
|
|
75
101
|
expect(req.observedMax).toBe(600);
|
|
76
|
-
|
|
102
|
+
// Anonymous callers are bucketed by a hashed key, NEVER the raw IP.
|
|
103
|
+
expect(req.observedKey).toMatch(HEX24);
|
|
104
|
+
expect(req.observedKey).not.toContain('203.0.113.9');
|
|
77
105
|
expect(next).toHaveBeenCalledTimes(1);
|
|
78
106
|
});
|
|
79
107
|
|
|
@@ -93,6 +121,7 @@ describe('@oxyhq/core/server rate limiter', () => {
|
|
|
93
121
|
);
|
|
94
122
|
|
|
95
123
|
expect(req.observedMax).toBe(5000);
|
|
124
|
+
// Authenticated identities are keyed by the user id verbatim — NOT hashed.
|
|
96
125
|
expect(req.observedKey).toBe('user:validated-user');
|
|
97
126
|
});
|
|
98
127
|
|
|
@@ -110,7 +139,72 @@ describe('@oxyhq/core/server rate limiter', () => {
|
|
|
110
139
|
);
|
|
111
140
|
|
|
112
141
|
expect(req.observedMax).toBe(600);
|
|
113
|
-
expect(req.observedKey).
|
|
142
|
+
expect(req.observedKey).toMatch(HEX24);
|
|
143
|
+
expect(req.observedKey).not.toContain('203.0.113.9');
|
|
114
144
|
expect(next).toHaveBeenCalledTimes(1);
|
|
115
145
|
});
|
|
146
|
+
|
|
147
|
+
describe('anonymous key hashing', () => {
|
|
148
|
+
it('produces a deterministic 24-hex key that never contains the raw IP', () => {
|
|
149
|
+
const first = keyForIp('203.0.113.9');
|
|
150
|
+
const second = keyForIp('203.0.113.9');
|
|
151
|
+
|
|
152
|
+
expect(first).toMatch(HEX24);
|
|
153
|
+
expect(first).toBe(second);
|
|
154
|
+
expect(first).not.toContain('203.0.113.9');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('produces different keys for different IPv4 addresses', () => {
|
|
158
|
+
expect(keyForIp('203.0.113.9')).not.toBe(keyForIp('198.51.100.7'));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('buckets IPv6 addresses in the same /56 to the same key', () => {
|
|
162
|
+
// 2001:db8:abcd:ee11 and 2001:db8:abcd:eeff share the /56 prefix (top byte of
|
|
163
|
+
// the 4th hextet is 0xee for both); the differing bits are host bits.
|
|
164
|
+
const a = keyForIp('2001:db8:abcd:ee11::1');
|
|
165
|
+
const b = keyForIp('2001:db8:abcd:eeff::9999');
|
|
166
|
+
|
|
167
|
+
expect(a).toMatch(HEX24);
|
|
168
|
+
expect(a).toBe(b);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('produces different keys for IPv6 addresses in different /56 prefixes', () => {
|
|
172
|
+
const sameFiftySix = keyForIp('2001:db8:abcd:ee11::1');
|
|
173
|
+
const otherFiftySix = keyForIp('2001:db8:abcd:ff11::1');
|
|
174
|
+
|
|
175
|
+
expect(sameFiftySix).not.toBe(otherFiftySix);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('salts the hash with IP_HASH_SALT so keys are not portable across salts', () => {
|
|
179
|
+
const unsalted = keyForIp('203.0.113.9');
|
|
180
|
+
|
|
181
|
+
process.env.IP_HASH_SALT = 'salt-a';
|
|
182
|
+
const saltedA = keyForIp('203.0.113.9');
|
|
183
|
+
|
|
184
|
+
process.env.IP_HASH_SALT = 'salt-b';
|
|
185
|
+
const saltedB = keyForIp('203.0.113.9');
|
|
186
|
+
|
|
187
|
+
expect(saltedA).toMatch(HEX24);
|
|
188
|
+
expect(saltedA).not.toBe(unsalted);
|
|
189
|
+
expect(saltedB).not.toBe(saltedA);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('prefers IP_HASH_SALT over DEVICE_ID_SALT', () => {
|
|
193
|
+
process.env.DEVICE_ID_SALT = 'device-salt';
|
|
194
|
+
const deviceOnly = keyForIp('203.0.113.9');
|
|
195
|
+
|
|
196
|
+
process.env.IP_HASH_SALT = 'ip-salt';
|
|
197
|
+
const ipPreferred = keyForIp('203.0.113.9');
|
|
198
|
+
|
|
199
|
+
expect(ipPreferred).not.toBe(deviceOnly);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('falls back to the literal "unknown" key when no IP is resolvable', () => {
|
|
203
|
+
const oxy = makeOxy((_req: Request, _res: Response, next: NextFunction) => next());
|
|
204
|
+
const req = makeRequest({ ip: undefined, socket: {} as Request['socket'] });
|
|
205
|
+
createOxyRateLimit(oxy)(req, {} as Response, jest.fn());
|
|
206
|
+
|
|
207
|
+
expect(req.observedKey).toBe('unknown');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
116
210
|
});
|
package/src/server/rateLimit.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { isIPv4, isIPv6 } from 'node:net';
|
|
1
3
|
import type { Request, RequestHandler } from 'express';
|
|
2
4
|
import rateLimit, { type Store } from 'express-rate-limit';
|
|
3
5
|
import type { OxyServices } from '../OxyServices';
|
|
@@ -99,9 +101,103 @@ function isBuiltInExempt(req: Request): boolean {
|
|
|
99
101
|
);
|
|
100
102
|
}
|
|
101
103
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Anonymous rate-limit keys must be PRIVACY-PRESERVING: the raw client IP must
|
|
106
|
+
* never reach a store at rest (in-memory or Redis). We therefore HMAC-hash the
|
|
107
|
+
* IP into a short, transient-only bucket key. Two IPv6-specific concerns shape
|
|
108
|
+
* the pre-hash normalization:
|
|
109
|
+
*
|
|
110
|
+
* - IPv6 hosts are typically handed an entire /64 (often a /56), so a single
|
|
111
|
+
* host can rotate through an enormous address space and evade a per-address
|
|
112
|
+
* limit. We bucket IPv6 to its /56 prefix BEFORE hashing.
|
|
113
|
+
* - express-rate-limit only exposes an `ipKeyGenerator` /56 helper from v8
|
|
114
|
+
* onwards; `@oxyhq/core` pins v7 (peer `^7.0.0`), so the masking is
|
|
115
|
+
* implemented here rather than pulling a major-version bump of a
|
|
116
|
+
* security-critical dependency (and its rate-limit-redis compatibility) into
|
|
117
|
+
* an unrelated privacy change. This mirrors `packages/api/src/utils/ipKey.ts`.
|
|
118
|
+
*/
|
|
119
|
+
const IPV6_SUBNET_BITS = 56;
|
|
120
|
+
|
|
121
|
+
/** Expand an IPv6 literal (handling `::` and embedded IPv4) to 8 numeric hextets, or null if unparseable. */
|
|
122
|
+
function ipv6Hextets(ip: string): number[] | null {
|
|
123
|
+
let addr = ip;
|
|
124
|
+
const zone = addr.indexOf('%');
|
|
125
|
+
if (zone !== -1) {
|
|
126
|
+
addr = addr.slice(0, zone);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Embedded IPv4 tail (e.g. `::ffff:203.0.113.7`) → fold the dotted quad into two hextets.
|
|
130
|
+
const lastColon = addr.lastIndexOf(':');
|
|
131
|
+
if (lastColon !== -1 && addr.slice(lastColon + 1).includes('.')) {
|
|
132
|
+
const v4 = addr.slice(lastColon + 1);
|
|
133
|
+
if (!isIPv4(v4)) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const octets = v4.split('.').map((part) => Number.parseInt(part, 10));
|
|
137
|
+
const high = ((octets[0] << 8) | octets[1]).toString(16);
|
|
138
|
+
const low = ((octets[2] << 8) | octets[3]).toString(16);
|
|
139
|
+
addr = `${addr.slice(0, lastColon + 1)}${high}:${low}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const halves = addr.split('::');
|
|
143
|
+
if (halves.length > 2) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const head = halves[0] ? halves[0].split(':') : [];
|
|
147
|
+
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
148
|
+
let groups: string[];
|
|
149
|
+
if (halves.length === 1) {
|
|
150
|
+
groups = head;
|
|
151
|
+
} else {
|
|
152
|
+
const missing = 8 - (head.length + tail.length);
|
|
153
|
+
if (missing < 0) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
groups = [...head, ...new Array(missing).fill('0'), ...tail];
|
|
157
|
+
}
|
|
158
|
+
if (groups.length !== 8) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const hextets = groups.map((group) => Number.parseInt(group || '0', 16));
|
|
162
|
+
if (hextets.some((value) => Number.isNaN(value) || value < 0 || value > 0xffff)) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
return hextets;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Mask an IPv6 address to its /{bits} prefix, returned as a canonical hex string. */
|
|
169
|
+
function maskIPv6(ip: string, bits: number): string {
|
|
170
|
+
const hextets = ipv6Hextets(ip);
|
|
171
|
+
if (!hextets) {
|
|
172
|
+
return ip;
|
|
173
|
+
}
|
|
174
|
+
const masked = hextets.map((hextet, index) => {
|
|
175
|
+
const groupStart = index * 16;
|
|
176
|
+
if (groupStart >= bits) {
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
const keepBits = Math.min(16, bits - groupStart);
|
|
180
|
+
const mask = keepBits >= 16 ? 0xffff : (0xffff << (16 - keepBits)) & 0xffff;
|
|
181
|
+
return hextet & mask;
|
|
182
|
+
});
|
|
183
|
+
return `${masked.map((hextet) => hextet.toString(16)).join(':')}/${bits}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Hash a client IP into a privacy-preserving bucket key. IPv6 is bucketed to its
|
|
188
|
+
* /56 prefix first (so a single v6 host can't rotate through its allocation to
|
|
189
|
+
* mint fresh keys), then HMAC'd with the server-side salt. The salt is resolved
|
|
190
|
+
* at CALL time (`IP_HASH_SALT`, else `DEVICE_ID_SALT`, else empty) — an empty
|
|
191
|
+
* salt still hashes, which beats storing a raw IP; backends SHOULD set one of
|
|
192
|
+
* those envs. The `rl|` namespace ensures a rate-limit key can never collide
|
|
193
|
+
* with, or be correlated against, a deviceId derivation that reuses the same
|
|
194
|
+
* salt. The result is a short hex digest with no colons, so it is Redis-safe.
|
|
195
|
+
*/
|
|
196
|
+
function hashAnonymousIp(ip: string): string {
|
|
197
|
+
const normalized =
|
|
198
|
+
isIPv6(ip) && !ip.startsWith('::ffff:') ? maskIPv6(ip, IPV6_SUBNET_BITS) : ip;
|
|
199
|
+
const salt = process.env.IP_HASH_SALT || process.env.DEVICE_ID_SALT || '';
|
|
200
|
+
return createHmac('sha256', salt).update(`rl|${normalized}`).digest('hex').slice(0, 24);
|
|
105
201
|
}
|
|
106
202
|
|
|
107
203
|
/**
|
|
@@ -132,14 +228,17 @@ function resolveTrustedAuthenticatedKey(req: OxyAuthedRequest): string | null {
|
|
|
132
228
|
return null;
|
|
133
229
|
}
|
|
134
230
|
|
|
135
|
-
/** Resolve the rate-limit key: per trusted authenticated identity, else per (IPv6-
|
|
231
|
+
/** Resolve the rate-limit key: per trusted authenticated identity, else per hashed (IPv6-bucketed) IP. */
|
|
136
232
|
function resolveKey(req: OxyAuthedRequest): string {
|
|
137
233
|
const authenticatedKey = resolveTrustedAuthenticatedKey(req);
|
|
138
234
|
if (authenticatedKey) {
|
|
139
235
|
return authenticatedKey;
|
|
140
236
|
}
|
|
141
|
-
const ip = req.ip || req.socket.remoteAddress
|
|
142
|
-
|
|
237
|
+
const ip = req.ip || req.socket.remoteAddress;
|
|
238
|
+
if (!ip) {
|
|
239
|
+
return 'unknown';
|
|
240
|
+
}
|
|
241
|
+
return hashAnonymousIp(ip);
|
|
143
242
|
}
|
|
144
243
|
|
|
145
244
|
/**
|