@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.
@@ -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, use the **first** (leftmost)
11
- * value from `X-Forwarded-For` (the original client IP).
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, use the **first** (leftmost)
23
- * value from `X-Forwarded-For` (the original client IP).
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: 60 req/min */
136
- const DEFAULT_LIMIT = { max: 60 };
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, use the **last** (rightmost)
11
- * value from `X-Forwarded-For` (closest hop to the trusted proxy).
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, use the **last** (rightmost)
23
- * value from `X-Forwarded-For` (closest hop to the trusted proxy).
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("ignores X-Forwarded-For when TRUSTED_PROXY_IPS is not set", () => {
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("192.168.1.100");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.42.0",
3
+ "version": "1.42.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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, use the **first** (leftmost)
29
- * value from `X-Forwarded-For` (the original client IP).
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: 60 req/min */
206
- const DEFAULT_LIMIT: Omit<RateLimitConfig, "repo" | "scope"> = { max: 60 };
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, use the **last** (rightmost)
29
- * value from `X-Forwarded-For` (closest hop to the trusted proxy).
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("ignores X-Forwarded-For when TRUSTED_PROXY_IPS is not set", () => {
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("192.168.1.100");
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