mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-14 19:40:40 +00:00
275 lines
8.6 KiB
TypeScript
275 lines
8.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
|
|
|
|
const TRUSTED_PROXY_AUTH = {
|
|
mode: "trusted-proxy" as const,
|
|
trustedProxy: {
|
|
userHeader: "x-forwarded-user",
|
|
},
|
|
};
|
|
|
|
const TOKEN_AUTH = {
|
|
mode: "token" as const,
|
|
token: "test-token-123",
|
|
};
|
|
|
|
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.each([
|
|
{
|
|
name: "lan binding",
|
|
cfg: {
|
|
gateway: {
|
|
bind: "lan" as const,
|
|
auth: TRUSTED_PROXY_AUTH,
|
|
trustedProxies: ["192.168.1.1"],
|
|
controlUi: { allowedOrigins: ["https://control.example.com"] },
|
|
},
|
|
},
|
|
expectedBindHost: "0.0.0.0",
|
|
},
|
|
{
|
|
name: "loopback binding with 127.0.0.1 proxy",
|
|
cfg: {
|
|
gateway: {
|
|
bind: "loopback" as const,
|
|
auth: TRUSTED_PROXY_AUTH,
|
|
trustedProxies: ["127.0.0.1"],
|
|
},
|
|
},
|
|
expectedBindHost: "127.0.0.1",
|
|
},
|
|
{
|
|
name: "loopback binding with ::1 proxy",
|
|
cfg: {
|
|
gateway: { bind: "loopback" as const, auth: TRUSTED_PROXY_AUTH, trustedProxies: ["::1"] },
|
|
},
|
|
expectedBindHost: "127.0.0.1",
|
|
},
|
|
{
|
|
name: "loopback binding with loopback cidr proxy",
|
|
cfg: {
|
|
gateway: {
|
|
bind: "loopback" as const,
|
|
auth: TRUSTED_PROXY_AUTH,
|
|
trustedProxies: ["127.0.0.0/8"],
|
|
},
|
|
},
|
|
expectedBindHost: "127.0.0.1",
|
|
},
|
|
])("allows $name", async ({ cfg, expectedBindHost }) => {
|
|
const result = await resolveGatewayRuntimeConfig({ cfg, port: 18789 });
|
|
expect(result.authMode).toBe("trusted-proxy");
|
|
expect(result.bindHost).toBe(expectedBindHost);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "loopback binding without trusted proxies",
|
|
cfg: {
|
|
gateway: { bind: "loopback" as const, auth: TRUSTED_PROXY_AUTH, trustedProxies: [] },
|
|
},
|
|
expectedMessage:
|
|
"gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured",
|
|
},
|
|
{
|
|
name: "loopback binding without loopback trusted proxy",
|
|
cfg: {
|
|
gateway: {
|
|
bind: "loopback" as const,
|
|
auth: TRUSTED_PROXY_AUTH,
|
|
trustedProxies: ["10.0.0.1"],
|
|
},
|
|
},
|
|
expectedMessage:
|
|
"gateway auth mode=trusted-proxy with bind=loopback requires gateway.trustedProxies to include 127.0.0.1, ::1, or a loopback CIDR",
|
|
},
|
|
{
|
|
name: "lan binding without trusted proxies",
|
|
cfg: {
|
|
gateway: {
|
|
bind: "lan" as const,
|
|
auth: TRUSTED_PROXY_AUTH,
|
|
trustedProxies: [],
|
|
controlUi: { allowedOrigins: ["https://control.example.com"] },
|
|
},
|
|
},
|
|
expectedMessage:
|
|
"gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured",
|
|
},
|
|
])("rejects $name", async ({ cfg, expectedMessage }) => {
|
|
await expect(resolveGatewayRuntimeConfig({ cfg, port: 18789 })).rejects.toThrow(
|
|
expectedMessage,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("token/password auth modes", () => {
|
|
let originalToken: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
originalToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalToken !== undefined) {
|
|
process.env.OPENCLAW_GATEWAY_TOKEN = originalToken;
|
|
} else {
|
|
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "lan binding with token",
|
|
cfg: {
|
|
gateway: {
|
|
bind: "lan" as const,
|
|
auth: TOKEN_AUTH,
|
|
controlUi: { allowedOrigins: ["https://control.example.com"] },
|
|
},
|
|
},
|
|
expectedAuthMode: "token",
|
|
expectedBindHost: "0.0.0.0",
|
|
},
|
|
{
|
|
name: "loopback binding with explicit none auth",
|
|
cfg: { gateway: { bind: "loopback" as const, auth: { mode: "none" as const } } },
|
|
expectedAuthMode: "none",
|
|
expectedBindHost: "127.0.0.1",
|
|
},
|
|
])("allows $name", async ({ cfg, expectedAuthMode, expectedBindHost }) => {
|
|
const result = await resolveGatewayRuntimeConfig({ cfg, port: 18789 });
|
|
expect(result.authMode).toBe(expectedAuthMode);
|
|
expect(result.bindHost).toBe(expectedBindHost);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "token mode without token",
|
|
cfg: { gateway: { bind: "lan" as const, auth: { mode: "token" as const } } },
|
|
expectedMessage:
|
|
"gateway auth mode is token, but no token was configured (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)",
|
|
},
|
|
{
|
|
name: "lan binding with explicit none auth",
|
|
cfg: { gateway: { bind: "lan" as const, auth: { mode: "none" as const } } },
|
|
expectedMessage: "refusing to bind gateway",
|
|
},
|
|
{
|
|
name: "loopback binding that resolves to non-loopback host",
|
|
cfg: { gateway: { bind: "loopback" as const, auth: { mode: "none" as const } } },
|
|
host: "0.0.0.0",
|
|
expectedMessage: "gateway bind=loopback resolved to non-loopback host",
|
|
},
|
|
{
|
|
name: "custom bind without customBindHost",
|
|
cfg: { gateway: { bind: "custom" as const, auth: TOKEN_AUTH } },
|
|
expectedMessage: "gateway.bind=custom requires gateway.customBindHost",
|
|
},
|
|
{
|
|
name: "custom bind with invalid customBindHost",
|
|
cfg: {
|
|
gateway: {
|
|
bind: "custom" as const,
|
|
customBindHost: "192.168.001.100",
|
|
auth: TOKEN_AUTH,
|
|
},
|
|
},
|
|
expectedMessage: "gateway.bind=custom requires a valid IPv4 customBindHost",
|
|
},
|
|
{
|
|
name: "custom bind with mismatched resolved host",
|
|
cfg: {
|
|
gateway: {
|
|
bind: "custom" as const,
|
|
customBindHost: "192.168.1.100",
|
|
auth: TOKEN_AUTH,
|
|
},
|
|
},
|
|
host: "0.0.0.0",
|
|
expectedMessage: "gateway bind=custom requested 192.168.1.100 but resolved 0.0.0.0",
|
|
},
|
|
])("rejects $name", async ({ cfg, host, expectedMessage }) => {
|
|
await expect(resolveGatewayRuntimeConfig({ cfg, port: 18789, host })).rejects.toThrow(
|
|
expectedMessage,
|
|
);
|
|
});
|
|
|
|
it("rejects non-loopback control UI when allowed origins are missing", async () => {
|
|
await expect(
|
|
resolveGatewayRuntimeConfig({
|
|
cfg: {
|
|
gateway: {
|
|
bind: "lan",
|
|
auth: TOKEN_AUTH,
|
|
},
|
|
},
|
|
port: 18789,
|
|
}),
|
|
).rejects.toThrow("non-loopback Control UI requires gateway.controlUi.allowedOrigins");
|
|
});
|
|
|
|
it("allows non-loopback control UI without allowed origins when dangerous fallback is enabled", async () => {
|
|
const result = await resolveGatewayRuntimeConfig({
|
|
cfg: {
|
|
gateway: {
|
|
bind: "lan",
|
|
auth: TOKEN_AUTH,
|
|
controlUi: {
|
|
dangerouslyAllowHostHeaderOriginFallback: true,
|
|
},
|
|
},
|
|
},
|
|
port: 18789,
|
|
});
|
|
expect(result.bindHost).toBe("0.0.0.0");
|
|
});
|
|
});
|
|
|
|
describe("HTTP security headers", () => {
|
|
it("resolves strict transport security header from config", async () => {
|
|
const result = await resolveGatewayRuntimeConfig({
|
|
cfg: {
|
|
gateway: {
|
|
bind: "loopback",
|
|
auth: { mode: "none" },
|
|
http: {
|
|
securityHeaders: {
|
|
strictTransportSecurity: " max-age=31536000; includeSubDomains ",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
port: 18789,
|
|
});
|
|
|
|
expect(result.strictTransportSecurityHeader).toBe("max-age=31536000; includeSubDomains");
|
|
});
|
|
|
|
it("does not set strict transport security when explicitly disabled", async () => {
|
|
const result = await resolveGatewayRuntimeConfig({
|
|
cfg: {
|
|
gateway: {
|
|
bind: "loopback",
|
|
auth: { mode: "none" },
|
|
http: {
|
|
securityHeaders: {
|
|
strictTransportSecurity: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
port: 18789,
|
|
});
|
|
|
|
expect(result.strictTransportSecurityHeader).toBeUndefined();
|
|
});
|
|
});
|
|
});
|