@wopr-network/platform-core 1.40.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/gateway/capability-rate-limit.js +2 -1
- package/dist/gateway/credit-gate.d.ts +1 -1
- package/dist/gateway/credit-gate.js +7 -1
- package/dist/gateway/protocol/anthropic.js +2 -1
- package/dist/gateway/protocol/openai.js +5 -3
- package/dist/gateway/service-key-auth.d.ts +1 -0
- package/dist/gateway/service-key-auth.js +2 -0
- package/dist/gateway/types.d.ts +2 -0
- 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/gateway/capability-rate-limit.ts +2 -1
- package/src/gateway/credit-gate.ts +9 -0
- package/src/gateway/protocol/anthropic.ts +11 -1
- package/src/gateway/protocol/openai.ts +32 -3
- package/src/gateway/service-key-auth.ts +3 -0
- package/src/gateway/types.ts +2 -0
- 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,
|
|
@@ -63,7 +63,8 @@ export function capabilityRateLimit(config, repo) {
|
|
|
63
63
|
return next();
|
|
64
64
|
}
|
|
65
65
|
const tenant = c.get("gatewayTenant");
|
|
66
|
-
const
|
|
66
|
+
const attributedTenantId = c.get("attributedTenantId");
|
|
67
|
+
const tenantId = attributedTenantId ?? tenant?.id ?? "unknown";
|
|
67
68
|
const max = limits[category];
|
|
68
69
|
const scope = `cap:${category}`;
|
|
69
70
|
const now = Date.now();
|
|
@@ -43,4 +43,4 @@ export declare function creditBalanceCheck(c: Context<GatewayAuthEnv>, deps: Cre
|
|
|
43
43
|
/**
|
|
44
44
|
* Post-call credit debit. Fire-and-forget — never fails the response.
|
|
45
45
|
*/
|
|
46
|
-
export declare function debitCredits(deps: CreditGateDeps, tenantId: string, costUsd: number, margin: number, capability: string, provider: string, attributedUserId?: string): Promise<void>;
|
|
46
|
+
export declare function debitCredits(deps: CreditGateDeps, tenantId: string, costUsd: number, margin: number, capability: string, provider: string, attributedUserId?: string, attributedTenantId?: string | null): Promise<void>;
|
|
@@ -21,6 +21,11 @@ export async function creditBalanceCheck(c, deps, estimatedCostCents = 0) {
|
|
|
21
21
|
if (!deps.creditLedger)
|
|
22
22
|
return null;
|
|
23
23
|
const tenant = c.get("gatewayTenant");
|
|
24
|
+
// Platform service accounts bypass the pre-call balance gate.
|
|
25
|
+
// The company never 402s itself. Debit still runs post-call for P&L tracking.
|
|
26
|
+
if (tenant.type === "platform_service") {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
24
29
|
const balance = await deps.creditLedger.balance(tenant.id);
|
|
25
30
|
const required = Math.max(0, estimatedCostCents);
|
|
26
31
|
const graceBuffer = deps.graceBufferCents ?? 50; // default -$0.50
|
|
@@ -55,7 +60,7 @@ export async function creditBalanceCheck(c, deps, estimatedCostCents = 0) {
|
|
|
55
60
|
/**
|
|
56
61
|
* Post-call credit debit. Fire-and-forget — never fails the response.
|
|
57
62
|
*/
|
|
58
|
-
export async function debitCredits(deps, tenantId, costUsd, margin, capability, provider, attributedUserId) {
|
|
63
|
+
export async function debitCredits(deps, tenantId, costUsd, margin, capability, provider, attributedUserId, attributedTenantId) {
|
|
59
64
|
if (!deps.creditLedger)
|
|
60
65
|
return;
|
|
61
66
|
const chargeCredit = withMargin(Credit.fromDollars(costUsd), margin);
|
|
@@ -75,6 +80,7 @@ export async function debitCredits(deps, tenantId, costUsd, margin, capability,
|
|
|
75
80
|
description: `Gateway ${capability} via ${provider}`,
|
|
76
81
|
allowNegative: true,
|
|
77
82
|
attributedUserId,
|
|
83
|
+
...(attributedTenantId ? { attributed_tenant_id: attributedTenantId } : {}),
|
|
78
84
|
});
|
|
79
85
|
// Only fire on first zero-crossing (balance was positive before, now ≤ 0)
|
|
80
86
|
if (deps.onBalanceExhausted) {
|
|
@@ -96,6 +96,7 @@ export function createAnthropicRoutes(deps) {
|
|
|
96
96
|
function messagesHandler(deps) {
|
|
97
97
|
return async (c) => {
|
|
98
98
|
const tenant = c.get("gatewayTenant");
|
|
99
|
+
const attributedTenantId = c.get("attributedTenantId");
|
|
99
100
|
// Budget check
|
|
100
101
|
const budgetResult = await deps.budgetChecker.check(tenant.id, tenant.spendLimits);
|
|
101
102
|
if (!budgetResult.allowed) {
|
|
@@ -174,7 +175,7 @@ function messagesHandler(deps) {
|
|
|
174
175
|
provider: "openrouter",
|
|
175
176
|
timestamp: Date.now(),
|
|
176
177
|
});
|
|
177
|
-
debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter");
|
|
178
|
+
debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter", undefined, attributedTenantId);
|
|
178
179
|
}
|
|
179
180
|
return new Response(res.body, {
|
|
180
181
|
status: res.status,
|
|
@@ -109,6 +109,7 @@ export function createOpenAIRoutes(deps) {
|
|
|
109
109
|
function chatCompletionsHandler(deps) {
|
|
110
110
|
return async (c) => {
|
|
111
111
|
const tenant = c.get("gatewayTenant");
|
|
112
|
+
const attributedTenantId = c.get("attributedTenantId");
|
|
112
113
|
// Budget check
|
|
113
114
|
const budgetResult = await deps.budgetChecker.check(tenant.id, tenant.spendLimits);
|
|
114
115
|
if (!budgetResult.allowed) {
|
|
@@ -187,7 +188,7 @@ function chatCompletionsHandler(deps) {
|
|
|
187
188
|
provider: "openrouter",
|
|
188
189
|
timestamp: Date.now(),
|
|
189
190
|
});
|
|
190
|
-
debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter");
|
|
191
|
+
debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter", undefined, attributedTenantId);
|
|
191
192
|
}
|
|
192
193
|
return new Response(res.body, {
|
|
193
194
|
status: res.status,
|
|
@@ -219,7 +220,7 @@ function chatCompletionsHandler(deps) {
|
|
|
219
220
|
provider: "openrouter",
|
|
220
221
|
timestamp: Date.now(),
|
|
221
222
|
});
|
|
222
|
-
debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter");
|
|
223
|
+
debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter", undefined, attributedTenantId);
|
|
223
224
|
}
|
|
224
225
|
return new Response(responseBody, {
|
|
225
226
|
status: res.status,
|
|
@@ -246,6 +247,7 @@ function chatCompletionsHandler(deps) {
|
|
|
246
247
|
function embeddingsHandler(deps) {
|
|
247
248
|
return async (c) => {
|
|
248
249
|
const tenant = c.get("gatewayTenant");
|
|
250
|
+
const attributedTenantId = c.get("attributedTenantId");
|
|
249
251
|
// Budget check
|
|
250
252
|
const budgetResult = await deps.budgetChecker.check(tenant.id, tenant.spendLimits);
|
|
251
253
|
if (!budgetResult.allowed) {
|
|
@@ -311,7 +313,7 @@ function embeddingsHandler(deps) {
|
|
|
311
313
|
provider: "openrouter",
|
|
312
314
|
timestamp: Date.now(),
|
|
313
315
|
});
|
|
314
|
-
debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "embeddings", "openrouter");
|
|
316
|
+
debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "embeddings", "openrouter", undefined, attributedTenantId);
|
|
315
317
|
}
|
|
316
318
|
return new Response(responseBody, {
|
|
317
319
|
status: res.status,
|
package/dist/gateway/types.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface GatewayEndpoint {
|
|
|
34
34
|
export interface GatewayTenant {
|
|
35
35
|
/** Tenant ID */
|
|
36
36
|
id: string;
|
|
37
|
+
/** Tenant type — platform_service accounts bypass the pre-call credit gate */
|
|
38
|
+
type?: "personal" | "org" | "platform_service";
|
|
37
39
|
/** Spend limits for budget checking */
|
|
38
40
|
spendLimits: SpendLimits;
|
|
39
41
|
/** Plan tier for rate limit lookup */
|
|
@@ -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"> = {
|
|
@@ -94,7 +94,8 @@ export function capabilityRateLimit(
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
const tenant = c.get("gatewayTenant") as GatewayTenant | undefined;
|
|
97
|
-
const
|
|
97
|
+
const attributedTenantId = c.get("attributedTenantId") as string | null | undefined;
|
|
98
|
+
const tenantId = attributedTenantId ?? tenant?.id ?? "unknown";
|
|
98
99
|
const max = limits[category];
|
|
99
100
|
const scope = `cap:${category}`;
|
|
100
101
|
const now = Date.now();
|
|
@@ -54,6 +54,13 @@ export async function creditBalanceCheck(
|
|
|
54
54
|
if (!deps.creditLedger) return null;
|
|
55
55
|
|
|
56
56
|
const tenant = c.get("gatewayTenant");
|
|
57
|
+
|
|
58
|
+
// Platform service accounts bypass the pre-call balance gate.
|
|
59
|
+
// The company never 402s itself. Debit still runs post-call for P&L tracking.
|
|
60
|
+
if (tenant.type === "platform_service") {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
const balance = await deps.creditLedger.balance(tenant.id);
|
|
58
65
|
const required = Math.max(0, estimatedCostCents);
|
|
59
66
|
const graceBuffer = deps.graceBufferCents ?? 50; // default -$0.50
|
|
@@ -100,6 +107,7 @@ export async function debitCredits(
|
|
|
100
107
|
capability: string,
|
|
101
108
|
provider: string,
|
|
102
109
|
attributedUserId?: string,
|
|
110
|
+
attributedTenantId?: string | null,
|
|
103
111
|
): Promise<void> {
|
|
104
112
|
if (!deps.creditLedger) return;
|
|
105
113
|
|
|
@@ -122,6 +130,7 @@ export async function debitCredits(
|
|
|
122
130
|
description: `Gateway ${capability} via ${provider}`,
|
|
123
131
|
allowNegative: true,
|
|
124
132
|
attributedUserId,
|
|
133
|
+
...(attributedTenantId ? { attributed_tenant_id: attributedTenantId } : {}),
|
|
125
134
|
});
|
|
126
135
|
|
|
127
136
|
// Only fire on first zero-crossing (balance was positive before, now ≤ 0)
|
|
@@ -136,6 +136,7 @@ export function createAnthropicRoutes(deps: ProtocolDeps): Hono<GatewayAuthEnv>
|
|
|
136
136
|
function messagesHandler(deps: ProtocolDeps) {
|
|
137
137
|
return async (c: Context<GatewayAuthEnv>) => {
|
|
138
138
|
const tenant = c.get("gatewayTenant");
|
|
139
|
+
const attributedTenantId = c.get("attributedTenantId");
|
|
139
140
|
|
|
140
141
|
// Budget check
|
|
141
142
|
const budgetResult = await deps.budgetChecker.check(tenant.id, tenant.spendLimits);
|
|
@@ -224,7 +225,16 @@ function messagesHandler(deps: ProtocolDeps) {
|
|
|
224
225
|
provider: "openrouter",
|
|
225
226
|
timestamp: Date.now(),
|
|
226
227
|
});
|
|
227
|
-
debitCredits(
|
|
228
|
+
debitCredits(
|
|
229
|
+
deps,
|
|
230
|
+
tenant.id,
|
|
231
|
+
costUsd,
|
|
232
|
+
deps.defaultMargin,
|
|
233
|
+
"chat-completions",
|
|
234
|
+
"openrouter",
|
|
235
|
+
undefined,
|
|
236
|
+
attributedTenantId,
|
|
237
|
+
);
|
|
228
238
|
}
|
|
229
239
|
|
|
230
240
|
return new Response(res.body, {
|
|
@@ -146,6 +146,7 @@ export function createOpenAIRoutes(deps: ProtocolDeps): Hono<GatewayAuthEnv> {
|
|
|
146
146
|
function chatCompletionsHandler(deps: ProtocolDeps) {
|
|
147
147
|
return async (c: Context<GatewayAuthEnv>) => {
|
|
148
148
|
const tenant = c.get("gatewayTenant");
|
|
149
|
+
const attributedTenantId = c.get("attributedTenantId");
|
|
149
150
|
|
|
150
151
|
// Budget check
|
|
151
152
|
const budgetResult = await deps.budgetChecker.check(tenant.id, tenant.spendLimits);
|
|
@@ -241,7 +242,16 @@ function chatCompletionsHandler(deps: ProtocolDeps) {
|
|
|
241
242
|
provider: "openrouter",
|
|
242
243
|
timestamp: Date.now(),
|
|
243
244
|
});
|
|
244
|
-
debitCredits(
|
|
245
|
+
debitCredits(
|
|
246
|
+
deps,
|
|
247
|
+
tenant.id,
|
|
248
|
+
costUsd,
|
|
249
|
+
deps.defaultMargin,
|
|
250
|
+
"chat-completions",
|
|
251
|
+
"openrouter",
|
|
252
|
+
undefined,
|
|
253
|
+
attributedTenantId,
|
|
254
|
+
);
|
|
245
255
|
}
|
|
246
256
|
|
|
247
257
|
return new Response(res.body, {
|
|
@@ -277,7 +287,16 @@ function chatCompletionsHandler(deps: ProtocolDeps) {
|
|
|
277
287
|
provider: "openrouter",
|
|
278
288
|
timestamp: Date.now(),
|
|
279
289
|
});
|
|
280
|
-
debitCredits(
|
|
290
|
+
debitCredits(
|
|
291
|
+
deps,
|
|
292
|
+
tenant.id,
|
|
293
|
+
costUsd,
|
|
294
|
+
deps.defaultMargin,
|
|
295
|
+
"chat-completions",
|
|
296
|
+
"openrouter",
|
|
297
|
+
undefined,
|
|
298
|
+
attributedTenantId,
|
|
299
|
+
);
|
|
281
300
|
}
|
|
282
301
|
|
|
283
302
|
return new Response(responseBody, {
|
|
@@ -309,6 +328,7 @@ function chatCompletionsHandler(deps: ProtocolDeps) {
|
|
|
309
328
|
function embeddingsHandler(deps: ProtocolDeps) {
|
|
310
329
|
return async (c: Context<GatewayAuthEnv>) => {
|
|
311
330
|
const tenant = c.get("gatewayTenant");
|
|
331
|
+
const attributedTenantId = c.get("attributedTenantId");
|
|
312
332
|
|
|
313
333
|
// Budget check
|
|
314
334
|
const budgetResult = await deps.budgetChecker.check(tenant.id, tenant.spendLimits);
|
|
@@ -391,7 +411,16 @@ function embeddingsHandler(deps: ProtocolDeps) {
|
|
|
391
411
|
provider: "openrouter",
|
|
392
412
|
timestamp: Date.now(),
|
|
393
413
|
});
|
|
394
|
-
debitCredits(
|
|
414
|
+
debitCredits(
|
|
415
|
+
deps,
|
|
416
|
+
tenant.id,
|
|
417
|
+
costUsd,
|
|
418
|
+
deps.defaultMargin,
|
|
419
|
+
"embeddings",
|
|
420
|
+
"openrouter",
|
|
421
|
+
undefined,
|
|
422
|
+
attributedTenantId,
|
|
423
|
+
);
|
|
395
424
|
}
|
|
396
425
|
|
|
397
426
|
return new Response(responseBody, {
|
|
@@ -14,6 +14,7 @@ export interface GatewayAuthEnv {
|
|
|
14
14
|
Variables: {
|
|
15
15
|
gatewayTenant: GatewayTenant;
|
|
16
16
|
webhookBody: Record<string, unknown>;
|
|
17
|
+
attributedTenantId: string | null;
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -90,6 +91,8 @@ export function serviceKeyAuth(
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
c.set("gatewayTenant", tenant);
|
|
94
|
+
const attributedTenantId = c.req.header("x-attribute-to") ?? null;
|
|
95
|
+
c.set("attributedTenantId", attributedTenantId);
|
|
93
96
|
return next();
|
|
94
97
|
};
|
|
95
98
|
}
|
package/src/gateway/types.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface GatewayEndpoint {
|
|
|
47
47
|
export interface GatewayTenant {
|
|
48
48
|
/** Tenant ID */
|
|
49
49
|
id: string;
|
|
50
|
+
/** Tenant type — platform_service accounts bypass the pre-call credit gate */
|
|
51
|
+
type?: "personal" | "org" | "platform_service";
|
|
50
52
|
/** Spend limits for budget checking */
|
|
51
53
|
spendLimits: SpendLimits;
|
|
52
54
|
/** Plan tier for rate limit lookup */
|
|
@@ -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
|