mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 08:01:23 +00:00
feat(gateway): add trusted-proxy auth mode (#15940)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 279d4b304f
Co-authored-by: nickytonline <833231+nickytonline@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import { authorizeGatewayConnect } from "./auth.js";
|
||||
import { authorizeGatewayConnect, resolveGatewayAuth } from "./auth.js";
|
||||
|
||||
function createLimiterSpy(): AuthRateLimiter & {
|
||||
check: ReturnType<typeof vi.fn>;
|
||||
@@ -18,6 +18,38 @@ function createLimiterSpy(): AuthRateLimiter & {
|
||||
}
|
||||
|
||||
describe("gateway auth", () => {
|
||||
it("resolves token/password from OPENCLAW gateway env vars", () => {
|
||||
expect(
|
||||
resolveGatewayAuth({
|
||||
authConfig: {},
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: "password",
|
||||
token: "env-token",
|
||||
password: "env-password",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not resolve legacy CLAWDBOT gateway env vars", () => {
|
||||
expect(
|
||||
resolveGatewayAuth({
|
||||
authConfig: {},
|
||||
env: {
|
||||
CLAWDBOT_GATEWAY_TOKEN: "legacy-token",
|
||||
CLAWDBOT_GATEWAY_PASSWORD: "legacy-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: "none",
|
||||
token: undefined,
|
||||
password: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not throw when req is missing socket", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: false },
|
||||
@@ -149,3 +181,250 @@ describe("gateway auth", () => {
|
||||
expect(limiter.recordFailure).toHaveBeenCalledWith(undefined, "custom-scope");
|
||||
});
|
||||
});
|
||||
|
||||
describe("trusted-proxy auth", () => {
|
||||
const trustedProxyConfig = {
|
||||
userHeader: "x-forwarded-user",
|
||||
requiredHeaders: ["x-forwarded-proto"],
|
||||
allowUsers: [],
|
||||
};
|
||||
|
||||
it("accepts valid request from trusted proxy", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "10.0.0.1" },
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"x-forwarded-user": "nick@example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("trusted-proxy");
|
||||
expect(res.user).toBe("nick@example.com");
|
||||
});
|
||||
|
||||
it("rejects request from untrusted source", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "192.168.1.100" },
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"x-forwarded-user": "attacker@evil.com",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("trusted_proxy_untrusted_source");
|
||||
});
|
||||
|
||||
it("rejects request with missing user header", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "10.0.0.1" },
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"x-forwarded-proto": "https",
|
||||
// missing x-forwarded-user
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("trusted_proxy_user_missing");
|
||||
});
|
||||
|
||||
it("rejects request with missing required headers", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "10.0.0.1" },
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"x-forwarded-user": "nick@example.com",
|
||||
// missing x-forwarded-proto
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("trusted_proxy_missing_header_x-forwarded-proto");
|
||||
});
|
||||
|
||||
it("rejects user not in allowlist", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
allowUsers: ["admin@example.com", "nick@example.com"],
|
||||
},
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "10.0.0.1" },
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"x-forwarded-user": "stranger@other.com",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("trusted_proxy_user_not_allowed");
|
||||
});
|
||||
|
||||
it("accepts user in allowlist", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
allowUsers: ["admin@example.com", "nick@example.com"],
|
||||
},
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "10.0.0.1" },
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"x-forwarded-user": "nick@example.com",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("trusted-proxy");
|
||||
expect(res.user).toBe("nick@example.com");
|
||||
});
|
||||
|
||||
it("rejects when no trustedProxies configured", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: [],
|
||||
req: {
|
||||
socket: { remoteAddress: "10.0.0.1" },
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"x-forwarded-user": "nick@example.com",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("trusted_proxy_no_proxies_configured");
|
||||
});
|
||||
|
||||
it("rejects when trustedProxy config missing", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
// trustedProxy missing
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "10.0.0.1" },
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"x-forwarded-user": "nick@example.com",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("trusted_proxy_config_missing");
|
||||
});
|
||||
|
||||
it("supports Pomerium-style headers", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: {
|
||||
userHeader: "x-pomerium-claim-email",
|
||||
requiredHeaders: ["x-pomerium-jwt-assertion"],
|
||||
},
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: ["172.17.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "172.17.0.1" },
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"x-pomerium-claim-email": "nick@example.com",
|
||||
"x-pomerium-jwt-assertion": "eyJ...",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("trusted-proxy");
|
||||
expect(res.user).toBe("nick@example.com");
|
||||
});
|
||||
|
||||
it("trims whitespace from user header value", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
},
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "10.0.0.1" },
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"x-forwarded-user": " nick@example.com ",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.user).toBe("nick@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
||||
import type {
|
||||
GatewayAuthConfig,
|
||||
GatewayTailscaleMode,
|
||||
GatewayTrustedProxyConfig,
|
||||
} from "../config/config.js";
|
||||
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
|
||||
import { safeEqualSecret } from "../security/secret-equal.js";
|
||||
import {
|
||||
@@ -14,18 +18,19 @@ import {
|
||||
resolveGatewayClientIp,
|
||||
} from "./net.js";
|
||||
|
||||
export type ResolvedGatewayAuthMode = "token" | "password";
|
||||
export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy";
|
||||
|
||||
export type ResolvedGatewayAuth = {
|
||||
mode: ResolvedGatewayAuthMode;
|
||||
token?: string;
|
||||
password?: string;
|
||||
allowTailscale: boolean;
|
||||
trustedProxy?: GatewayTrustedProxyConfig;
|
||||
};
|
||||
|
||||
export type GatewayAuthResult = {
|
||||
ok: boolean;
|
||||
method?: "token" | "password" | "tailscale" | "device-token";
|
||||
method?: "none" | "token" | "password" | "tailscale" | "device-token" | "trusted-proxy";
|
||||
user?: string;
|
||||
reason?: string;
|
||||
/** Present when the request was blocked by the rate limiter. */
|
||||
@@ -192,21 +197,31 @@ export function resolveGatewayAuth(params: {
|
||||
}): ResolvedGatewayAuth {
|
||||
const authConfig = params.authConfig ?? {};
|
||||
const env = params.env ?? process.env;
|
||||
const token =
|
||||
authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
|
||||
const password =
|
||||
authConfig.password ??
|
||||
env.OPENCLAW_GATEWAY_PASSWORD ??
|
||||
env.CLAWDBOT_GATEWAY_PASSWORD ??
|
||||
undefined;
|
||||
const mode: ResolvedGatewayAuth["mode"] = authConfig.mode ?? (password ? "password" : "token");
|
||||
const token = authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? undefined;
|
||||
const password = authConfig.password ?? env.OPENCLAW_GATEWAY_PASSWORD ?? undefined;
|
||||
const trustedProxy = authConfig.trustedProxy;
|
||||
|
||||
let mode: ResolvedGatewayAuth["mode"];
|
||||
if (authConfig.mode) {
|
||||
mode = authConfig.mode;
|
||||
} else if (password) {
|
||||
mode = "password";
|
||||
} else if (token) {
|
||||
mode = "token";
|
||||
} else {
|
||||
mode = "none";
|
||||
}
|
||||
|
||||
const allowTailscale =
|
||||
authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password");
|
||||
authConfig.allowTailscale ??
|
||||
(params.tailscaleMode === "serve" && mode !== "password" && mode !== "trusted-proxy");
|
||||
|
||||
return {
|
||||
mode,
|
||||
token,
|
||||
password,
|
||||
allowTailscale,
|
||||
trustedProxy,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -222,6 +237,61 @@ export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
|
||||
if (auth.mode === "password" && !auth.password) {
|
||||
throw new Error("gateway auth mode is password, but no password was configured");
|
||||
}
|
||||
if (auth.mode === "trusted-proxy") {
|
||||
if (!auth.trustedProxy) {
|
||||
throw new Error(
|
||||
"gateway auth mode is trusted-proxy, but no trustedProxy config was provided (set gateway.auth.trustedProxy)",
|
||||
);
|
||||
}
|
||||
if (!auth.trustedProxy.userHeader || auth.trustedProxy.userHeader.trim() === "") {
|
||||
throw new Error(
|
||||
"gateway auth mode is trusted-proxy, but trustedProxy.userHeader is empty (set gateway.auth.trustedProxy.userHeader)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the request came from a trusted proxy and extract user identity.
|
||||
* Returns the user identity if valid, or null with a reason if not.
|
||||
*/
|
||||
function authorizeTrustedProxy(params: {
|
||||
req?: IncomingMessage;
|
||||
trustedProxies?: string[];
|
||||
trustedProxyConfig: GatewayTrustedProxyConfig;
|
||||
}): { user: string } | { reason: string } {
|
||||
const { req, trustedProxies, trustedProxyConfig } = params;
|
||||
|
||||
if (!req) {
|
||||
return { reason: "trusted_proxy_no_request" };
|
||||
}
|
||||
|
||||
const remoteAddr = req.socket?.remoteAddress;
|
||||
if (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) {
|
||||
return { reason: "trusted_proxy_untrusted_source" };
|
||||
}
|
||||
|
||||
const requiredHeaders = trustedProxyConfig.requiredHeaders ?? [];
|
||||
for (const header of requiredHeaders) {
|
||||
const value = headerValue(req.headers[header.toLowerCase()]);
|
||||
if (!value || value.trim() === "") {
|
||||
return { reason: `trusted_proxy_missing_header_${header}` };
|
||||
}
|
||||
}
|
||||
|
||||
const userHeaderValue = headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]);
|
||||
if (!userHeaderValue || userHeaderValue.trim() === "") {
|
||||
return { reason: "trusted_proxy_user_missing" };
|
||||
}
|
||||
|
||||
const user = userHeaderValue.trim();
|
||||
|
||||
const allowUsers = trustedProxyConfig.allowUsers ?? [];
|
||||
if (allowUsers.length > 0 && !allowUsers.includes(user)) {
|
||||
return { reason: "trusted_proxy_user_not_allowed" };
|
||||
}
|
||||
|
||||
return { user };
|
||||
}
|
||||
|
||||
export async function authorizeGatewayConnect(params: {
|
||||
@@ -241,7 +311,26 @@ export async function authorizeGatewayConnect(params: {
|
||||
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
||||
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
||||
|
||||
// --- Rate-limit gate ---
|
||||
if (auth.mode === "trusted-proxy") {
|
||||
if (!auth.trustedProxy) {
|
||||
return { ok: false, reason: "trusted_proxy_config_missing" };
|
||||
}
|
||||
if (!trustedProxies || trustedProxies.length === 0) {
|
||||
return { ok: false, reason: "trusted_proxy_no_proxies_configured" };
|
||||
}
|
||||
|
||||
const result = authorizeTrustedProxy({
|
||||
req,
|
||||
trustedProxies,
|
||||
trustedProxyConfig: auth.trustedProxy,
|
||||
});
|
||||
|
||||
if ("user" in result) {
|
||||
return { ok: true, method: "trusted-proxy", user: result.user };
|
||||
}
|
||||
return { ok: false, reason: result.reason };
|
||||
}
|
||||
|
||||
const limiter = params.rateLimiter;
|
||||
const ip =
|
||||
params.clientIp ?? resolveRequestClientIp(req, trustedProxies) ?? req?.socket?.remoteAddress;
|
||||
@@ -264,7 +353,6 @@ export async function authorizeGatewayConnect(params: {
|
||||
tailscaleWhois,
|
||||
});
|
||||
if (tailscaleCheck.ok) {
|
||||
// Successful auth – reset rate-limit counter for this IP.
|
||||
limiter?.reset(ip, rateLimitScope);
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -2,10 +2,108 @@ import os from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isPrivateOrLoopbackAddress,
|
||||
isTrustedProxyAddress,
|
||||
pickPrimaryLanIPv4,
|
||||
resolveGatewayListenHosts,
|
||||
} from "./net.js";
|
||||
|
||||
describe("isTrustedProxyAddress", () => {
|
||||
describe("exact IP matching", () => {
|
||||
it("returns true when IP matches exactly", () => {
|
||||
expect(isTrustedProxyAddress("192.168.1.1", ["192.168.1.1"])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when IP does not match", () => {
|
||||
expect(isTrustedProxyAddress("192.168.1.2", ["192.168.1.1"])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when IP matches one of multiple proxies", () => {
|
||||
expect(isTrustedProxyAddress("10.0.0.5", ["192.168.1.1", "10.0.0.5", "172.16.0.1"])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CIDR subnet matching", () => {
|
||||
it("returns true when IP is within /24 subnet", () => {
|
||||
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/24"])).toBe(true);
|
||||
expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.0/24"])).toBe(true);
|
||||
expect(isTrustedProxyAddress("10.42.0.254", ["10.42.0.0/24"])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when IP is outside /24 subnet", () => {
|
||||
expect(isTrustedProxyAddress("10.42.1.1", ["10.42.0.0/24"])).toBe(false);
|
||||
expect(isTrustedProxyAddress("10.43.0.1", ["10.42.0.0/24"])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when IP is within /16 subnet", () => {
|
||||
expect(isTrustedProxyAddress("172.19.5.100", ["172.19.0.0/16"])).toBe(true);
|
||||
expect(isTrustedProxyAddress("172.19.255.255", ["172.19.0.0/16"])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when IP is outside /16 subnet", () => {
|
||||
expect(isTrustedProxyAddress("172.20.0.1", ["172.19.0.0/16"])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when IP is within /32 subnet (single IP)", () => {
|
||||
expect(isTrustedProxyAddress("10.42.0.0", ["10.42.0.0/32"])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when IP does not match /32 subnet", () => {
|
||||
expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.0/32"])).toBe(false);
|
||||
});
|
||||
|
||||
it("handles mixed exact IPs and CIDR notation", () => {
|
||||
const proxies = ["192.168.1.1", "10.42.0.0/24", "172.19.0.0/16"];
|
||||
expect(isTrustedProxyAddress("192.168.1.1", proxies)).toBe(true); // exact match
|
||||
expect(isTrustedProxyAddress("10.42.0.59", proxies)).toBe(true); // CIDR match
|
||||
expect(isTrustedProxyAddress("172.19.5.100", proxies)).toBe(true); // CIDR match
|
||||
expect(isTrustedProxyAddress("10.43.0.1", proxies)).toBe(false); // no match
|
||||
});
|
||||
});
|
||||
|
||||
describe("backward compatibility", () => {
|
||||
it("preserves exact IP matching behavior (no CIDR notation)", () => {
|
||||
// Old configs with exact IPs should work exactly as before
|
||||
expect(isTrustedProxyAddress("192.168.1.1", ["192.168.1.1"])).toBe(true);
|
||||
expect(isTrustedProxyAddress("192.168.1.2", ["192.168.1.1"])).toBe(false);
|
||||
expect(isTrustedProxyAddress("10.0.0.5", ["192.168.1.1", "10.0.0.5"])).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT treat plain IPs as /32 CIDR (exact match only)", () => {
|
||||
// "10.42.0.1" without /32 should match ONLY that exact IP
|
||||
expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.1"])).toBe(true);
|
||||
expect(isTrustedProxyAddress("10.42.0.2", ["10.42.0.1"])).toBe(false);
|
||||
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.1"])).toBe(false);
|
||||
});
|
||||
|
||||
it("handles IPv4-mapped IPv6 addresses (existing normalizeIp behavior)", () => {
|
||||
// Existing normalizeIp() behavior should be preserved
|
||||
expect(isTrustedProxyAddress("::ffff:192.168.1.1", ["192.168.1.1"])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("returns false when IP is undefined", () => {
|
||||
expect(isTrustedProxyAddress(undefined, ["192.168.1.1"])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when trustedProxies is undefined", () => {
|
||||
expect(isTrustedProxyAddress("192.168.1.1", undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when trustedProxies is empty", () => {
|
||||
expect(isTrustedProxyAddress("192.168.1.1", [])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for invalid CIDR notation", () => {
|
||||
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/33"])).toBe(false); // invalid prefix
|
||||
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/-1"])).toBe(false); // negative prefix
|
||||
expect(isTrustedProxyAddress("10.42.0.59", ["invalid/24"])).toBe(false); // invalid IP
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGatewayListenHosts", () => {
|
||||
it("returns the input host when not loopback", async () => {
|
||||
const hosts = await resolveGatewayListenHosts("0.0.0.0", {
|
||||
|
||||
@@ -139,12 +139,65 @@ function parseRealIp(realIp?: string): string | undefined {
|
||||
return normalizeIp(stripOptionalPort(raw));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP address matches a CIDR block.
|
||||
* Supports IPv4 CIDR notation (e.g., "10.42.0.0/24").
|
||||
*
|
||||
* @param ip - The IP address to check (e.g., "10.42.0.59")
|
||||
* @param cidr - The CIDR block (e.g., "10.42.0.0/24")
|
||||
* @returns True if the IP is within the CIDR block
|
||||
*/
|
||||
function ipMatchesCIDR(ip: string, cidr: string): boolean {
|
||||
// Handle exact IP match (no CIDR notation)
|
||||
if (!cidr.includes("/")) {
|
||||
return ip === cidr;
|
||||
}
|
||||
|
||||
const [subnet, prefixLenStr] = cidr.split("/");
|
||||
const prefixLen = parseInt(prefixLenStr, 10);
|
||||
|
||||
// Validate prefix length
|
||||
if (Number.isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert IPs to 32-bit integers
|
||||
const ipParts = ip.split(".").map((p) => parseInt(p, 10));
|
||||
const subnetParts = subnet.split(".").map((p) => parseInt(p, 10));
|
||||
|
||||
// Validate IP format
|
||||
if (
|
||||
ipParts.length !== 4 ||
|
||||
subnetParts.length !== 4 ||
|
||||
ipParts.some((p) => Number.isNaN(p) || p < 0 || p > 255) ||
|
||||
subnetParts.some((p) => Number.isNaN(p) || p < 0 || p > 255)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ipInt = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
|
||||
const subnetInt =
|
||||
(subnetParts[0] << 24) | (subnetParts[1] << 16) | (subnetParts[2] << 8) | subnetParts[3];
|
||||
|
||||
// Create mask and compare
|
||||
const mask = prefixLen === 0 ? 0 : (-1 >>> (32 - prefixLen)) << (32 - prefixLen);
|
||||
return (ipInt & mask) === (subnetInt & mask);
|
||||
}
|
||||
|
||||
export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean {
|
||||
const normalized = normalizeIp(ip);
|
||||
if (!normalized || !trustedProxies || trustedProxies.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return trustedProxies.some((proxy) => normalizeIp(proxy) === normalized);
|
||||
|
||||
return trustedProxies.some((proxy) => {
|
||||
// Handle CIDR notation
|
||||
if (proxy.includes("/")) {
|
||||
return ipMatchesCIDR(normalized, proxy);
|
||||
}
|
||||
// Exact IP match
|
||||
return normalizeIp(proxy) === normalized;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveGatewayClientIp(params: {
|
||||
|
||||
@@ -52,6 +52,14 @@ export const SnapshotSchema = Type.Object(
|
||||
configPath: Type.Optional(NonEmptyString),
|
||||
stateDir: Type.Optional(NonEmptyString),
|
||||
sessionDefaults: Type.Optional(SessionDefaultsSchema),
|
||||
authMode: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("none"),
|
||||
Type.Literal("token"),
|
||||
Type.Literal("password"),
|
||||
Type.Literal("trusted-proxy"),
|
||||
]),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
119
src/gateway/server-runtime-config.test.ts
Normal file
119
src/gateway/server-runtime-config.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
|
||||
|
||||
describe("resolveGatewayRuntimeConfig", () => {
|
||||
describe("trusted-proxy auth mode", () => {
|
||||
// This test validates BOTH validation layers:
|
||||
// 1. CLI validation in src/cli/gateway-cli/run.ts (line 246)
|
||||
// 2. Runtime config validation in src/gateway/server-runtime-config.ts (line 99)
|
||||
// Both must allow lan binding when authMode === "trusted-proxy"
|
||||
it("should allow lan binding with trusted-proxy auth mode", async () => {
|
||||
const cfg = {
|
||||
gateway: {
|
||||
bind: "lan" as const,
|
||||
auth: {
|
||||
mode: "trusted-proxy" as const,
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
},
|
||||
},
|
||||
trustedProxies: ["192.168.1.1"],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveGatewayRuntimeConfig({
|
||||
cfg,
|
||||
port: 18789,
|
||||
});
|
||||
|
||||
expect(result.authMode).toBe("trusted-proxy");
|
||||
expect(result.bindHost).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
it("should reject loopback binding with trusted-proxy auth mode", async () => {
|
||||
const cfg = {
|
||||
gateway: {
|
||||
bind: "loopback" as const,
|
||||
auth: {
|
||||
mode: "trusted-proxy" as const,
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
},
|
||||
},
|
||||
trustedProxies: ["192.168.1.1"],
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
resolveGatewayRuntimeConfig({
|
||||
cfg,
|
||||
port: 18789,
|
||||
}),
|
||||
).rejects.toThrow("gateway auth mode=trusted-proxy makes no sense with bind=loopback");
|
||||
});
|
||||
|
||||
it("should reject trusted-proxy without trustedProxies configured", async () => {
|
||||
const cfg = {
|
||||
gateway: {
|
||||
bind: "lan" as const,
|
||||
auth: {
|
||||
mode: "trusted-proxy" as const,
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
},
|
||||
},
|
||||
trustedProxies: [],
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
resolveGatewayRuntimeConfig({
|
||||
cfg,
|
||||
port: 18789,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("token/password auth modes", () => {
|
||||
it("should reject token mode without token configured", async () => {
|
||||
const cfg = {
|
||||
gateway: {
|
||||
bind: "lan" as const,
|
||||
auth: {
|
||||
mode: "token" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
resolveGatewayRuntimeConfig({
|
||||
cfg,
|
||||
port: 18789,
|
||||
}),
|
||||
).rejects.toThrow("gateway auth mode is token, but no token was configured");
|
||||
});
|
||||
|
||||
it("should allow lan binding with token", async () => {
|
||||
const cfg = {
|
||||
gateway: {
|
||||
bind: "lan" as const,
|
||||
auth: {
|
||||
mode: "token" as const,
|
||||
token: "test-token-123",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveGatewayRuntimeConfig({
|
||||
cfg,
|
||||
port: 18789,
|
||||
});
|
||||
|
||||
expect(result.authMode).toBe("token");
|
||||
expect(result.bindHost).toBe("0.0.0.0");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,8 @@ export async function resolveGatewayRuntimeConfig(params: {
|
||||
const canvasHostEnabled =
|
||||
process.env.OPENCLAW_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
|
||||
|
||||
const trustedProxies = params.cfg.gateway?.trustedProxies ?? [];
|
||||
|
||||
assertGatewayAuthConfigured(resolvedAuth);
|
||||
if (tailscaleMode === "funnel" && authMode !== "password") {
|
||||
throw new Error(
|
||||
@@ -94,12 +96,25 @@ export async function resolveGatewayRuntimeConfig(params: {
|
||||
if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) {
|
||||
throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)");
|
||||
}
|
||||
if (!isLoopbackHost(bindHost) && !hasSharedSecret) {
|
||||
if (!isLoopbackHost(bindHost) && !hasSharedSecret && authMode !== "trusted-proxy") {
|
||||
throw new Error(
|
||||
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (authMode === "trusted-proxy") {
|
||||
if (isLoopbackHost(bindHost)) {
|
||||
throw new Error(
|
||||
"gateway auth mode=trusted-proxy makes no sense with bind=loopback; use bind=lan or bind=custom with gateway.trustedProxies configured",
|
||||
);
|
||||
}
|
||||
if (trustedProxies.length === 0) {
|
||||
throw new Error(
|
||||
"gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured with at least one proxy IP",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bindHost,
|
||||
controlUiEnabled,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CONFIG_PATH, STATE_DIR, loadConfig } from "../../config/config.js";
|
||||
import { resolveMainSessionKey } from "../../config/sessions.js";
|
||||
import { listSystemPresence } from "../../infra/system-presence.js";
|
||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
import { resolveGatewayAuth } from "../auth.js";
|
||||
|
||||
let presenceVersion = 1;
|
||||
let healthVersion = 1;
|
||||
@@ -20,6 +21,7 @@ export function buildGatewaySnapshot(): Snapshot {
|
||||
const scope = cfg.session?.scope ?? "per-sender";
|
||||
const presence = listSystemPresence();
|
||||
const uptimeMs = Math.round(process.uptime() * 1000);
|
||||
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
|
||||
// Health is async; caller should await getHealthSnapshot and replace later if needed.
|
||||
const emptyHealth: unknown = {};
|
||||
return {
|
||||
@@ -36,6 +38,7 @@ export function buildGatewaySnapshot(): Snapshot {
|
||||
mainSessionKey,
|
||||
scope,
|
||||
},
|
||||
authMode: auth.mode,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user