@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.
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createOxyRateLimit = createOxyRateLimit;
7
+ const node_crypto_1 = require("node:crypto");
8
+ const node_net_1 = require("node:net");
7
9
  const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
8
10
  /**
9
11
  * Built-in exemptions. A media app's cover-art/avatar fan-out and HLS
@@ -21,9 +23,98 @@ function isBuiltInExempt(req) {
21
23
  path === '/health' ||
22
24
  path.endsWith('/health'));
23
25
  }
24
- /** IPv6-safe IP key generator (replaces colons to avoid Redis namespace issues). */
25
- function ipKeyGenerator(ip) {
26
- return ip.replace(/:/g, '_');
26
+ /**
27
+ * Anonymous rate-limit keys must be PRIVACY-PRESERVING: the raw client IP must
28
+ * never reach a store at rest (in-memory or Redis). We therefore HMAC-hash the
29
+ * IP into a short, transient-only bucket key. Two IPv6-specific concerns shape
30
+ * the pre-hash normalization:
31
+ *
32
+ * - IPv6 hosts are typically handed an entire /64 (often a /56), so a single
33
+ * host can rotate through an enormous address space and evade a per-address
34
+ * limit. We bucket IPv6 to its /56 prefix BEFORE hashing.
35
+ * - express-rate-limit only exposes an `ipKeyGenerator` /56 helper from v8
36
+ * onwards; `@oxyhq/core` pins v7 (peer `^7.0.0`), so the masking is
37
+ * implemented here rather than pulling a major-version bump of a
38
+ * security-critical dependency (and its rate-limit-redis compatibility) into
39
+ * an unrelated privacy change. This mirrors `packages/api/src/utils/ipKey.ts`.
40
+ */
41
+ const IPV6_SUBNET_BITS = 56;
42
+ /** Expand an IPv6 literal (handling `::` and embedded IPv4) to 8 numeric hextets, or null if unparseable. */
43
+ function ipv6Hextets(ip) {
44
+ let addr = ip;
45
+ const zone = addr.indexOf('%');
46
+ if (zone !== -1) {
47
+ addr = addr.slice(0, zone);
48
+ }
49
+ // Embedded IPv4 tail (e.g. `::ffff:203.0.113.7`) → fold the dotted quad into two hextets.
50
+ const lastColon = addr.lastIndexOf(':');
51
+ if (lastColon !== -1 && addr.slice(lastColon + 1).includes('.')) {
52
+ const v4 = addr.slice(lastColon + 1);
53
+ if (!(0, node_net_1.isIPv4)(v4)) {
54
+ return null;
55
+ }
56
+ const octets = v4.split('.').map((part) => Number.parseInt(part, 10));
57
+ const high = ((octets[0] << 8) | octets[1]).toString(16);
58
+ const low = ((octets[2] << 8) | octets[3]).toString(16);
59
+ addr = `${addr.slice(0, lastColon + 1)}${high}:${low}`;
60
+ }
61
+ const halves = addr.split('::');
62
+ if (halves.length > 2) {
63
+ return null;
64
+ }
65
+ const head = halves[0] ? halves[0].split(':') : [];
66
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
67
+ let groups;
68
+ if (halves.length === 1) {
69
+ groups = head;
70
+ }
71
+ else {
72
+ const missing = 8 - (head.length + tail.length);
73
+ if (missing < 0) {
74
+ return null;
75
+ }
76
+ groups = [...head, ...new Array(missing).fill('0'), ...tail];
77
+ }
78
+ if (groups.length !== 8) {
79
+ return null;
80
+ }
81
+ const hextets = groups.map((group) => Number.parseInt(group || '0', 16));
82
+ if (hextets.some((value) => Number.isNaN(value) || value < 0 || value > 0xffff)) {
83
+ return null;
84
+ }
85
+ return hextets;
86
+ }
87
+ /** Mask an IPv6 address to its /{bits} prefix, returned as a canonical hex string. */
88
+ function maskIPv6(ip, bits) {
89
+ const hextets = ipv6Hextets(ip);
90
+ if (!hextets) {
91
+ return ip;
92
+ }
93
+ const masked = hextets.map((hextet, index) => {
94
+ const groupStart = index * 16;
95
+ if (groupStart >= bits) {
96
+ return 0;
97
+ }
98
+ const keepBits = Math.min(16, bits - groupStart);
99
+ const mask = keepBits >= 16 ? 0xffff : (0xffff << (16 - keepBits)) & 0xffff;
100
+ return hextet & mask;
101
+ });
102
+ return `${masked.map((hextet) => hextet.toString(16)).join(':')}/${bits}`;
103
+ }
104
+ /**
105
+ * Hash a client IP into a privacy-preserving bucket key. IPv6 is bucketed to its
106
+ * /56 prefix first (so a single v6 host can't rotate through its allocation to
107
+ * mint fresh keys), then HMAC'd with the server-side salt. The salt is resolved
108
+ * at CALL time (`IP_HASH_SALT`, else `DEVICE_ID_SALT`, else empty) — an empty
109
+ * salt still hashes, which beats storing a raw IP; backends SHOULD set one of
110
+ * those envs. The `rl|` namespace ensures a rate-limit key can never collide
111
+ * with, or be correlated against, a deviceId derivation that reuses the same
112
+ * salt. The result is a short hex digest with no colons, so it is Redis-safe.
113
+ */
114
+ function hashAnonymousIp(ip) {
115
+ const normalized = (0, node_net_1.isIPv6)(ip) && !ip.startsWith('::ffff:') ? maskIPv6(ip, IPV6_SUBNET_BITS) : ip;
116
+ const salt = process.env.IP_HASH_SALT || process.env.DEVICE_ID_SALT || '';
117
+ return (0, node_crypto_1.createHmac)('sha256', salt).update(`rl|${normalized}`).digest('hex').slice(0, 24);
27
118
  }
28
119
  /**
29
120
  * Resolve the trusted authenticated rate-limit key.
@@ -49,14 +140,17 @@ function resolveTrustedAuthenticatedKey(req) {
49
140
  }
50
141
  return null;
51
142
  }
52
- /** Resolve the rate-limit key: per trusted authenticated identity, else per (IPv6-safe) IP. */
143
+ /** Resolve the rate-limit key: per trusted authenticated identity, else per hashed (IPv6-bucketed) IP. */
53
144
  function resolveKey(req) {
54
145
  const authenticatedKey = resolveTrustedAuthenticatedKey(req);
55
146
  if (authenticatedKey) {
56
147
  return authenticatedKey;
57
148
  }
58
- const ip = req.ip || req.socket.remoteAddress || 'unknown';
59
- return ipKeyGenerator(ip);
149
+ const ip = req.ip || req.socket.remoteAddress;
150
+ if (!ip) {
151
+ return 'unknown';
152
+ }
153
+ return hashAnonymousIp(ip);
60
154
  }
61
155
  /**
62
156
  * Build the composed Oxy rate-limit middleware. See module docs for rationale.