@wopr-network/platform-core 1.42.0 → 1.42.1
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/api/middleware/get-client-ip.d.ts +11 -2
- package/dist/api/middleware/get-client-ip.js +27 -3
- package/dist/api/middleware/rate-limit.d.ts +1 -1
- package/dist/api/middleware/rate-limit.js +7 -3
- package/dist/middleware/get-client-ip.d.ts +11 -2
- package/dist/middleware/get-client-ip.js +27 -3
- package/dist/middleware/get-client-ip.test.js +43 -1
- package/dist/middleware/rate-limit.d.ts +1 -1
- package/dist/middleware/rate-limit.js +1 -1
- package/dist/middleware/rate-limit.test.js +8 -2
- package/package.json +1 -1
- package/src/api/middleware/get-client-ip.ts +23 -3
- package/src/api/middleware/rate-limit.ts +7 -3
- package/src/middleware/get-client-ip.test.ts +53 -1
- package/src/middleware/get-client-ip.ts +23 -3
- package/src/middleware/rate-limit.test.ts +9 -2
- package/src/middleware/rate-limit.ts +1 -1
|
@@ -4,11 +4,20 @@ import type { Context } from "hono";
|
|
|
4
4
|
* Returns an empty set if the value is undefined or empty.
|
|
5
5
|
*/
|
|
6
6
|
export declare function parseTrustedProxies(envValue: string | undefined): Set<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Check whether an IPv4 address is in an RFC 1918 private range or loopback.
|
|
9
|
+
* These are always behind a proxy in production (Docker, k8s, cloud VPCs)
|
|
10
|
+
* so trusting X-Forwarded-For from them is safe and expected.
|
|
11
|
+
*
|
|
12
|
+
* Ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8
|
|
13
|
+
*/
|
|
14
|
+
export declare function isPrivateIp(ip: string): boolean;
|
|
7
15
|
/**
|
|
8
16
|
* Determine the real client IP.
|
|
9
17
|
*
|
|
10
|
-
* - If `socketAddr` matches a trusted proxy
|
|
11
|
-
*
|
|
18
|
+
* - If `socketAddr` matches a trusted proxy (explicit list OR RFC 1918
|
|
19
|
+
* private IP), use the **first** (leftmost) value from
|
|
20
|
+
* `X-Forwarded-For` (the original client IP).
|
|
12
21
|
* - Otherwise, use `socketAddr` directly (XFF is untrusted).
|
|
13
22
|
* - Falls back to `"unknown"` if neither is available.
|
|
14
23
|
*/
|
|
@@ -14,19 +14,43 @@ export function parseTrustedProxies(envValue) {
|
|
|
14
14
|
function normalizeIp(ip) {
|
|
15
15
|
return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Check whether an IPv4 address is in an RFC 1918 private range or loopback.
|
|
19
|
+
* These are always behind a proxy in production (Docker, k8s, cloud VPCs)
|
|
20
|
+
* so trusting X-Forwarded-For from them is safe and expected.
|
|
21
|
+
*
|
|
22
|
+
* Ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8
|
|
23
|
+
*/
|
|
24
|
+
export function isPrivateIp(ip) {
|
|
25
|
+
const parts = ip.split(".");
|
|
26
|
+
if (parts.length !== 4)
|
|
27
|
+
return false;
|
|
28
|
+
const a = Number.parseInt(parts[0], 10);
|
|
29
|
+
const b = Number.parseInt(parts[1], 10);
|
|
30
|
+
if (a === 10)
|
|
31
|
+
return true;
|
|
32
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
33
|
+
return true;
|
|
34
|
+
if (a === 192 && b === 168)
|
|
35
|
+
return true;
|
|
36
|
+
if (a === 127)
|
|
37
|
+
return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
17
40
|
// Parsed once at module load — no per-request overhead.
|
|
18
41
|
const trustedProxies = parseTrustedProxies(process.env.TRUSTED_PROXY_IPS);
|
|
19
42
|
/**
|
|
20
43
|
* Determine the real client IP.
|
|
21
44
|
*
|
|
22
|
-
* - If `socketAddr` matches a trusted proxy
|
|
23
|
-
*
|
|
45
|
+
* - If `socketAddr` matches a trusted proxy (explicit list OR RFC 1918
|
|
46
|
+
* private IP), use the **first** (leftmost) value from
|
|
47
|
+
* `X-Forwarded-For` (the original client IP).
|
|
24
48
|
* - Otherwise, use `socketAddr` directly (XFF is untrusted).
|
|
25
49
|
* - Falls back to `"unknown"` if neither is available.
|
|
26
50
|
*/
|
|
27
51
|
export function getClientIp(xffHeader, socketAddr, trusted = trustedProxies) {
|
|
28
52
|
const normalizedSocket = socketAddr ? normalizeIp(socketAddr) : undefined;
|
|
29
|
-
if (xffHeader && normalizedSocket && trusted.has(normalizedSocket)) {
|
|
53
|
+
if (xffHeader && normalizedSocket && (trusted.has(normalizedSocket) || isPrivateIp(normalizedSocket))) {
|
|
30
54
|
const parts = xffHeader.split(",");
|
|
31
55
|
const first = parts[0]?.trim();
|
|
32
56
|
if (first)
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { Context, MiddlewareHandler } from "hono";
|
|
12
12
|
import type { IRateLimitRepository } from "../rate-limit-repository.js";
|
|
13
|
-
export { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
13
|
+
export { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
14
14
|
export interface RateLimitConfig {
|
|
15
15
|
/** Maximum number of requests per window. */
|
|
16
16
|
max: number;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* State is persisted via IRateLimitRepository (DB-backed in production).
|
|
10
10
|
*/
|
|
11
11
|
import { getClientIpFromContext } from "./get-client-ip.js";
|
|
12
|
-
export { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
12
|
+
export { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
// Helpers
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
@@ -132,8 +132,12 @@ const CHANNEL_VALIDATE_LIMIT = { max: 10 };
|
|
|
132
132
|
const FLEET_CREATE_LIMIT = { max: 30 };
|
|
133
133
|
/** Fleet read operations (GET /fleet/*): 120 req/min */
|
|
134
134
|
const FLEET_READ_LIMIT = { max: 120 };
|
|
135
|
-
/** Default for everything else:
|
|
136
|
-
|
|
135
|
+
/** Default for everything else: 200 req/min.
|
|
136
|
+
* A single Next.js page load triggers ~60-70 REST requests (sidebar, credits,
|
|
137
|
+
* session validation, marketplace, etc.). 60 was too low and caused users to
|
|
138
|
+
* hit rate limits on their first page load. Sensitive endpoints (auth, billing,
|
|
139
|
+
* signup) have their own stricter limits below. */
|
|
140
|
+
const DEFAULT_LIMIT = { max: 200 };
|
|
137
141
|
/** Auth login: 5 failed attempts per 15 minutes (WOP-839) */
|
|
138
142
|
const AUTH_LOGIN_LIMIT = {
|
|
139
143
|
max: 5,
|
|
@@ -4,11 +4,20 @@ import type { Context } from "hono";
|
|
|
4
4
|
* Returns an empty set if the value is undefined or empty.
|
|
5
5
|
*/
|
|
6
6
|
export declare function parseTrustedProxies(envValue: string | undefined): Set<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Check whether an IPv4 address is in an RFC 1918 private range or loopback.
|
|
9
|
+
* These are always behind a proxy in production (Docker, k8s, cloud VPCs)
|
|
10
|
+
* so trusting X-Forwarded-For from them is safe and expected.
|
|
11
|
+
*
|
|
12
|
+
* Ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8
|
|
13
|
+
*/
|
|
14
|
+
export declare function isPrivateIp(ip: string): boolean;
|
|
7
15
|
/**
|
|
8
16
|
* Determine the real client IP.
|
|
9
17
|
*
|
|
10
|
-
* - If `socketAddr` matches a trusted proxy
|
|
11
|
-
*
|
|
18
|
+
* - If `socketAddr` matches a trusted proxy (explicit list OR RFC 1918
|
|
19
|
+
* private IP), use the **last** (rightmost) value from
|
|
20
|
+
* `X-Forwarded-For` (closest hop to the trusted proxy).
|
|
12
21
|
* - Otherwise, use `socketAddr` directly (XFF is untrusted).
|
|
13
22
|
* - Falls back to `"unknown"` if neither is available.
|
|
14
23
|
*/
|
|
@@ -14,19 +14,43 @@ export function parseTrustedProxies(envValue) {
|
|
|
14
14
|
function normalizeIp(ip) {
|
|
15
15
|
return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Check whether an IPv4 address is in an RFC 1918 private range or loopback.
|
|
19
|
+
* These are always behind a proxy in production (Docker, k8s, cloud VPCs)
|
|
20
|
+
* so trusting X-Forwarded-For from them is safe and expected.
|
|
21
|
+
*
|
|
22
|
+
* Ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8
|
|
23
|
+
*/
|
|
24
|
+
export function isPrivateIp(ip) {
|
|
25
|
+
const parts = ip.split(".");
|
|
26
|
+
if (parts.length !== 4)
|
|
27
|
+
return false;
|
|
28
|
+
const a = Number.parseInt(parts[0], 10);
|
|
29
|
+
const b = Number.parseInt(parts[1], 10);
|
|
30
|
+
if (a === 10)
|
|
31
|
+
return true;
|
|
32
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
33
|
+
return true;
|
|
34
|
+
if (a === 192 && b === 168)
|
|
35
|
+
return true;
|
|
36
|
+
if (a === 127)
|
|
37
|
+
return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
17
40
|
// Parsed once at module load — no per-request overhead.
|
|
18
41
|
const trustedProxies = parseTrustedProxies(process.env.TRUSTED_PROXY_IPS);
|
|
19
42
|
/**
|
|
20
43
|
* Determine the real client IP.
|
|
21
44
|
*
|
|
22
|
-
* - If `socketAddr` matches a trusted proxy
|
|
23
|
-
*
|
|
45
|
+
* - If `socketAddr` matches a trusted proxy (explicit list OR RFC 1918
|
|
46
|
+
* private IP), use the **last** (rightmost) value from
|
|
47
|
+
* `X-Forwarded-For` (closest hop to the trusted proxy).
|
|
24
48
|
* - Otherwise, use `socketAddr` directly (XFF is untrusted).
|
|
25
49
|
* - Falls back to `"unknown"` if neither is available.
|
|
26
50
|
*/
|
|
27
51
|
export function getClientIp(xffHeader, socketAddr, trusted = trustedProxies) {
|
|
28
52
|
const normalizedSocket = socketAddr ? normalizeIp(socketAddr) : undefined;
|
|
29
|
-
if (xffHeader && normalizedSocket && trusted.has(normalizedSocket)) {
|
|
53
|
+
if (xffHeader && normalizedSocket && (trusted.has(normalizedSocket) || isPrivateIp(normalizedSocket))) {
|
|
30
54
|
// Trust XFF — take the rightmost (last) value
|
|
31
55
|
const parts = xffHeader.split(",");
|
|
32
56
|
const last = parts[parts.length - 1]?.trim();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
2
|
+
import { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
3
3
|
describe("parseTrustedProxies", () => {
|
|
4
4
|
it("returns empty set for undefined", () => {
|
|
5
5
|
expect(parseTrustedProxies(undefined).size).toBe(0);
|
|
@@ -14,6 +14,37 @@ describe("parseTrustedProxies", () => {
|
|
|
14
14
|
expect(result.size).toBe(2);
|
|
15
15
|
});
|
|
16
16
|
});
|
|
17
|
+
describe("isPrivateIp", () => {
|
|
18
|
+
it("identifies 10.x.x.x as private", () => {
|
|
19
|
+
expect(isPrivateIp("10.0.0.1")).toBe(true);
|
|
20
|
+
expect(isPrivateIp("10.255.255.255")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
it("identifies 172.16-31.x.x as private", () => {
|
|
23
|
+
expect(isPrivateIp("172.16.0.1")).toBe(true);
|
|
24
|
+
expect(isPrivateIp("172.18.0.5")).toBe(true);
|
|
25
|
+
expect(isPrivateIp("172.31.255.255")).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
it("rejects 172.15.x.x and 172.32.x.x as non-private", () => {
|
|
28
|
+
expect(isPrivateIp("172.15.0.1")).toBe(false);
|
|
29
|
+
expect(isPrivateIp("172.32.0.1")).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
it("identifies 192.168.x.x as private", () => {
|
|
32
|
+
expect(isPrivateIp("192.168.0.1")).toBe(true);
|
|
33
|
+
expect(isPrivateIp("192.168.1.100")).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it("identifies 127.x.x.x as private (loopback)", () => {
|
|
36
|
+
expect(isPrivateIp("127.0.0.1")).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it("rejects public IPs", () => {
|
|
39
|
+
expect(isPrivateIp("8.8.8.8")).toBe(false);
|
|
40
|
+
expect(isPrivateIp("1.2.3.4")).toBe(false);
|
|
41
|
+
expect(isPrivateIp("203.0.113.1")).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
it("rejects non-IPv4 strings", () => {
|
|
44
|
+
expect(isPrivateIp("::1")).toBe(false);
|
|
45
|
+
expect(isPrivateIp("not-an-ip")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
17
48
|
describe("getClientIp", () => {
|
|
18
49
|
it("returns socket address when no trusted proxies configured", () => {
|
|
19
50
|
expect(getClientIp("attacker-spoofed", "1.2.3.4", new Set())).toBe("1.2.3.4");
|
|
@@ -37,4 +68,15 @@ describe("getClientIp", () => {
|
|
|
37
68
|
it("returns 'unknown' when no socket and no XFF", () => {
|
|
38
69
|
expect(getClientIp(undefined, undefined, new Set())).toBe("unknown");
|
|
39
70
|
});
|
|
71
|
+
it("auto-trusts private IPs even without explicit trusted proxy set", () => {
|
|
72
|
+
expect(getClientIp("real-client", "172.18.0.5", new Set())).toBe("real-client");
|
|
73
|
+
expect(getClientIp("real-client", "192.168.1.1", new Set())).toBe("real-client");
|
|
74
|
+
expect(getClientIp("real-client", "10.0.0.1", new Set())).toBe("real-client");
|
|
75
|
+
});
|
|
76
|
+
it("auto-trusts IPv6-mapped private IPs", () => {
|
|
77
|
+
expect(getClientIp("real-client", "::ffff:172.18.0.5", new Set())).toBe("real-client");
|
|
78
|
+
});
|
|
79
|
+
it("does NOT auto-trust public IPs without explicit proxy config", () => {
|
|
80
|
+
expect(getClientIp("spoofed", "8.8.8.8", new Set())).toBe("8.8.8.8");
|
|
81
|
+
});
|
|
40
82
|
});
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { Context, MiddlewareHandler } from "hono";
|
|
12
12
|
import type { IRateLimitRepository } from "./rate-limit-repository.js";
|
|
13
|
-
export { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
13
|
+
export { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
14
14
|
export interface RateLimitConfig {
|
|
15
15
|
/** Maximum number of requests per window. */
|
|
16
16
|
max: number;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* State is persisted via IRateLimitRepository (DB-backed in production).
|
|
10
10
|
*/
|
|
11
11
|
import { getClientIpFromContext } from "./get-client-ip.js";
|
|
12
|
-
export { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
12
|
+
export { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
// Helpers
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
@@ -196,11 +196,17 @@ describe("trusted proxy validation", () => {
|
|
|
196
196
|
afterEach(() => {
|
|
197
197
|
vi.useRealTimers();
|
|
198
198
|
});
|
|
199
|
-
it("
|
|
199
|
+
it("auto-trusts private IPs even when TRUSTED_PROXY_IPS is not set", () => {
|
|
200
200
|
const trusted = parseTrustedProxies(undefined);
|
|
201
201
|
expect(trusted.size).toBe(0);
|
|
202
|
+
// Private IPs are auto-trusted — XFF is used
|
|
202
203
|
const ip = getClientIp("spoofed-ip", "192.168.1.100", trusted);
|
|
203
|
-
expect(ip).toBe("
|
|
204
|
+
expect(ip).toBe("spoofed-ip");
|
|
205
|
+
});
|
|
206
|
+
it("ignores X-Forwarded-For from public IPs when TRUSTED_PROXY_IPS is not set", () => {
|
|
207
|
+
const trusted = parseTrustedProxies(undefined);
|
|
208
|
+
const ip = getClientIp("spoofed-ip", "203.0.113.1", trusted);
|
|
209
|
+
expect(ip).toBe("203.0.113.1");
|
|
204
210
|
});
|
|
205
211
|
it("trusts X-Forwarded-For when socket address is in TRUSTED_PROXY_IPS", () => {
|
|
206
212
|
const trusted = parseTrustedProxies("172.18.0.5,10.0.0.1");
|
package/package.json
CHANGED
|
@@ -19,14 +19,34 @@ function normalizeIp(ip: string): string {
|
|
|
19
19
|
return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Check whether an IPv4 address is in an RFC 1918 private range or loopback.
|
|
24
|
+
* These are always behind a proxy in production (Docker, k8s, cloud VPCs)
|
|
25
|
+
* so trusting X-Forwarded-For from them is safe and expected.
|
|
26
|
+
*
|
|
27
|
+
* Ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8
|
|
28
|
+
*/
|
|
29
|
+
export function isPrivateIp(ip: string): boolean {
|
|
30
|
+
const parts = ip.split(".");
|
|
31
|
+
if (parts.length !== 4) return false;
|
|
32
|
+
const a = Number.parseInt(parts[0], 10);
|
|
33
|
+
const b = Number.parseInt(parts[1], 10);
|
|
34
|
+
if (a === 10) return true;
|
|
35
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
36
|
+
if (a === 192 && b === 168) return true;
|
|
37
|
+
if (a === 127) return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
// Parsed once at module load — no per-request overhead.
|
|
23
42
|
const trustedProxies = parseTrustedProxies(process.env.TRUSTED_PROXY_IPS);
|
|
24
43
|
|
|
25
44
|
/**
|
|
26
45
|
* Determine the real client IP.
|
|
27
46
|
*
|
|
28
|
-
* - If `socketAddr` matches a trusted proxy
|
|
29
|
-
*
|
|
47
|
+
* - If `socketAddr` matches a trusted proxy (explicit list OR RFC 1918
|
|
48
|
+
* private IP), use the **first** (leftmost) value from
|
|
49
|
+
* `X-Forwarded-For` (the original client IP).
|
|
30
50
|
* - Otherwise, use `socketAddr` directly (XFF is untrusted).
|
|
31
51
|
* - Falls back to `"unknown"` if neither is available.
|
|
32
52
|
*/
|
|
@@ -37,7 +57,7 @@ export function getClientIp(
|
|
|
37
57
|
): string {
|
|
38
58
|
const normalizedSocket = socketAddr ? normalizeIp(socketAddr) : undefined;
|
|
39
59
|
|
|
40
|
-
if (xffHeader && normalizedSocket && trusted.has(normalizedSocket)) {
|
|
60
|
+
if (xffHeader && normalizedSocket && (trusted.has(normalizedSocket) || isPrivateIp(normalizedSocket))) {
|
|
41
61
|
const parts = xffHeader.split(",");
|
|
42
62
|
const first = parts[0]?.trim();
|
|
43
63
|
if (first) return first;
|
|
@@ -13,7 +13,7 @@ import type { Context, MiddlewareHandler, Next } from "hono";
|
|
|
13
13
|
import type { IRateLimitRepository } from "../rate-limit-repository.js";
|
|
14
14
|
import { getClientIpFromContext } from "./get-client-ip.js";
|
|
15
15
|
|
|
16
|
-
export { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
16
|
+
export { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
17
17
|
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
// Types
|
|
@@ -202,8 +202,12 @@ const FLEET_CREATE_LIMIT: Omit<RateLimitConfig, "repo" | "scope"> = { max: 30 };
|
|
|
202
202
|
/** Fleet read operations (GET /fleet/*): 120 req/min */
|
|
203
203
|
const FLEET_READ_LIMIT: Omit<RateLimitConfig, "repo" | "scope"> = { max: 120 };
|
|
204
204
|
|
|
205
|
-
/** Default for everything else:
|
|
206
|
-
|
|
205
|
+
/** Default for everything else: 200 req/min.
|
|
206
|
+
* A single Next.js page load triggers ~60-70 REST requests (sidebar, credits,
|
|
207
|
+
* session validation, marketplace, etc.). 60 was too low and caused users to
|
|
208
|
+
* hit rate limits on their first page load. Sensitive endpoints (auth, billing,
|
|
209
|
+
* signup) have their own stricter limits below. */
|
|
210
|
+
const DEFAULT_LIMIT: Omit<RateLimitConfig, "repo" | "scope"> = { max: 200 };
|
|
207
211
|
|
|
208
212
|
/** Auth login: 5 failed attempts per 15 minutes (WOP-839) */
|
|
209
213
|
const AUTH_LOGIN_LIMIT: Omit<RateLimitConfig, "repo" | "scope"> = {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
2
|
+
import { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
3
3
|
|
|
4
4
|
describe("parseTrustedProxies", () => {
|
|
5
5
|
it("returns empty set for undefined", () => {
|
|
@@ -18,6 +18,44 @@ describe("parseTrustedProxies", () => {
|
|
|
18
18
|
});
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
+
describe("isPrivateIp", () => {
|
|
22
|
+
it("identifies 10.x.x.x as private", () => {
|
|
23
|
+
expect(isPrivateIp("10.0.0.1")).toBe(true);
|
|
24
|
+
expect(isPrivateIp("10.255.255.255")).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("identifies 172.16-31.x.x as private", () => {
|
|
28
|
+
expect(isPrivateIp("172.16.0.1")).toBe(true);
|
|
29
|
+
expect(isPrivateIp("172.18.0.5")).toBe(true);
|
|
30
|
+
expect(isPrivateIp("172.31.255.255")).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rejects 172.15.x.x and 172.32.x.x as non-private", () => {
|
|
34
|
+
expect(isPrivateIp("172.15.0.1")).toBe(false);
|
|
35
|
+
expect(isPrivateIp("172.32.0.1")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("identifies 192.168.x.x as private", () => {
|
|
39
|
+
expect(isPrivateIp("192.168.0.1")).toBe(true);
|
|
40
|
+
expect(isPrivateIp("192.168.1.100")).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("identifies 127.x.x.x as private (loopback)", () => {
|
|
44
|
+
expect(isPrivateIp("127.0.0.1")).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects public IPs", () => {
|
|
48
|
+
expect(isPrivateIp("8.8.8.8")).toBe(false);
|
|
49
|
+
expect(isPrivateIp("1.2.3.4")).toBe(false);
|
|
50
|
+
expect(isPrivateIp("203.0.113.1")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects non-IPv4 strings", () => {
|
|
54
|
+
expect(isPrivateIp("::1")).toBe(false);
|
|
55
|
+
expect(isPrivateIp("not-an-ip")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
21
59
|
describe("getClientIp", () => {
|
|
22
60
|
it("returns socket address when no trusted proxies configured", () => {
|
|
23
61
|
expect(getClientIp("attacker-spoofed", "1.2.3.4", new Set())).toBe("1.2.3.4");
|
|
@@ -46,4 +84,18 @@ describe("getClientIp", () => {
|
|
|
46
84
|
it("returns 'unknown' when no socket and no XFF", () => {
|
|
47
85
|
expect(getClientIp(undefined, undefined, new Set())).toBe("unknown");
|
|
48
86
|
});
|
|
87
|
+
|
|
88
|
+
it("auto-trusts private IPs even without explicit trusted proxy set", () => {
|
|
89
|
+
expect(getClientIp("real-client", "172.18.0.5", new Set())).toBe("real-client");
|
|
90
|
+
expect(getClientIp("real-client", "192.168.1.1", new Set())).toBe("real-client");
|
|
91
|
+
expect(getClientIp("real-client", "10.0.0.1", new Set())).toBe("real-client");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("auto-trusts IPv6-mapped private IPs", () => {
|
|
95
|
+
expect(getClientIp("real-client", "::ffff:172.18.0.5", new Set())).toBe("real-client");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does NOT auto-trust public IPs without explicit proxy config", () => {
|
|
99
|
+
expect(getClientIp("spoofed", "8.8.8.8", new Set())).toBe("8.8.8.8");
|
|
100
|
+
});
|
|
49
101
|
});
|
|
@@ -19,14 +19,34 @@ function normalizeIp(ip: string): string {
|
|
|
19
19
|
return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Check whether an IPv4 address is in an RFC 1918 private range or loopback.
|
|
24
|
+
* These are always behind a proxy in production (Docker, k8s, cloud VPCs)
|
|
25
|
+
* so trusting X-Forwarded-For from them is safe and expected.
|
|
26
|
+
*
|
|
27
|
+
* Ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8
|
|
28
|
+
*/
|
|
29
|
+
export function isPrivateIp(ip: string): boolean {
|
|
30
|
+
const parts = ip.split(".");
|
|
31
|
+
if (parts.length !== 4) return false;
|
|
32
|
+
const a = Number.parseInt(parts[0], 10);
|
|
33
|
+
const b = Number.parseInt(parts[1], 10);
|
|
34
|
+
if (a === 10) return true;
|
|
35
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
36
|
+
if (a === 192 && b === 168) return true;
|
|
37
|
+
if (a === 127) return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
// Parsed once at module load — no per-request overhead.
|
|
23
42
|
const trustedProxies = parseTrustedProxies(process.env.TRUSTED_PROXY_IPS);
|
|
24
43
|
|
|
25
44
|
/**
|
|
26
45
|
* Determine the real client IP.
|
|
27
46
|
*
|
|
28
|
-
* - If `socketAddr` matches a trusted proxy
|
|
29
|
-
*
|
|
47
|
+
* - If `socketAddr` matches a trusted proxy (explicit list OR RFC 1918
|
|
48
|
+
* private IP), use the **last** (rightmost) value from
|
|
49
|
+
* `X-Forwarded-For` (closest hop to the trusted proxy).
|
|
30
50
|
* - Otherwise, use `socketAddr` directly (XFF is untrusted).
|
|
31
51
|
* - Falls back to `"unknown"` if neither is available.
|
|
32
52
|
*/
|
|
@@ -37,7 +57,7 @@ export function getClientIp(
|
|
|
37
57
|
): string {
|
|
38
58
|
const normalizedSocket = socketAddr ? normalizeIp(socketAddr) : undefined;
|
|
39
59
|
|
|
40
|
-
if (xffHeader && normalizedSocket && trusted.has(normalizedSocket)) {
|
|
60
|
+
if (xffHeader && normalizedSocket && (trusted.has(normalizedSocket) || isPrivateIp(normalizedSocket))) {
|
|
41
61
|
// Trust XFF — take the rightmost (last) value
|
|
42
62
|
const parts = xffHeader.split(",");
|
|
43
63
|
const last = parts[parts.length - 1]?.trim();
|
|
@@ -272,12 +272,19 @@ describe("trusted proxy validation", () => {
|
|
|
272
272
|
vi.useRealTimers();
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
-
it("
|
|
275
|
+
it("auto-trusts private IPs even when TRUSTED_PROXY_IPS is not set", () => {
|
|
276
276
|
const trusted = parseTrustedProxies(undefined);
|
|
277
277
|
expect(trusted.size).toBe(0);
|
|
278
278
|
|
|
279
|
+
// Private IPs are auto-trusted — XFF is used
|
|
279
280
|
const ip = getClientIp("spoofed-ip", "192.168.1.100", trusted);
|
|
280
|
-
expect(ip).toBe("
|
|
281
|
+
expect(ip).toBe("spoofed-ip");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("ignores X-Forwarded-For from public IPs when TRUSTED_PROXY_IPS is not set", () => {
|
|
285
|
+
const trusted = parseTrustedProxies(undefined);
|
|
286
|
+
const ip = getClientIp("spoofed-ip", "203.0.113.1", trusted);
|
|
287
|
+
expect(ip).toBe("203.0.113.1");
|
|
281
288
|
});
|
|
282
289
|
|
|
283
290
|
it("trusts X-Forwarded-For when socket address is in TRUSTED_PROXY_IPS", () => {
|
|
@@ -13,7 +13,7 @@ import type { Context, MiddlewareHandler, Next } from "hono";
|
|
|
13
13
|
import { getClientIpFromContext } from "./get-client-ip.js";
|
|
14
14
|
import type { IRateLimitRepository } from "./rate-limit-repository.js";
|
|
15
15
|
|
|
16
|
-
export { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
16
|
+
export { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
|
|
17
17
|
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
// Types
|