mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 14:57:27 +00:00
refactor(gateway): centralize trusted-proxy control-ui bypass policy
This commit is contained in:
@@ -105,6 +105,13 @@ const CONTROL_UI_CLIENT = {
|
||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
};
|
||||
|
||||
const TRUSTED_PROXY_CONTROL_UI_HEADERS = {
|
||||
origin: "https://localhost",
|
||||
"x-forwarded-for": "203.0.113.10",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-user": "peter@example.com",
|
||||
} as const;
|
||||
|
||||
const NODE_CLIENT = {
|
||||
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
version: "1.0.0",
|
||||
@@ -794,89 +801,92 @@ describe("gateway server auth/connect", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("allows trusted-proxy control ui operator without device identity", async () => {
|
||||
await configureTrustedProxyControlUiAuth();
|
||||
await withGatewayServer(async ({ port }) => {
|
||||
const ws = await openWs(port, {
|
||||
origin: "https://localhost",
|
||||
"x-forwarded-for": "203.0.113.10",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-user": "peter@example.com",
|
||||
});
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
role: "operator",
|
||||
device: null,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const status = await rpcReq(ws, "status");
|
||||
expect(status.ok).toBe(false);
|
||||
expect(status.error?.message ?? "").toContain("missing scope");
|
||||
const health = await rpcReq(ws, "health");
|
||||
expect(health.ok).toBe(true);
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
const trustedProxyControlUiCases: Array<{
|
||||
name: string;
|
||||
role: "operator" | "node";
|
||||
withUnpairedNodeDevice: boolean;
|
||||
expectedOk: boolean;
|
||||
expectedErrorSubstring?: string;
|
||||
expectedErrorCode?: string;
|
||||
expectStatusChecks: boolean;
|
||||
}> = [
|
||||
{
|
||||
name: "allows trusted-proxy control ui operator without device identity",
|
||||
role: "operator",
|
||||
withUnpairedNodeDevice: false,
|
||||
expectedOk: true,
|
||||
expectStatusChecks: true,
|
||||
},
|
||||
{
|
||||
name: "rejects trusted-proxy control ui node role without device identity",
|
||||
role: "node",
|
||||
withUnpairedNodeDevice: false,
|
||||
expectedOk: false,
|
||||
expectedErrorSubstring: "control ui requires device identity",
|
||||
expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
|
||||
expectStatusChecks: false,
|
||||
},
|
||||
{
|
||||
name: "requires pairing for trusted-proxy control ui node role with unpaired device",
|
||||
role: "node",
|
||||
withUnpairedNodeDevice: true,
|
||||
expectedOk: false,
|
||||
expectedErrorSubstring: "pairing required",
|
||||
expectedErrorCode: ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
||||
expectStatusChecks: false,
|
||||
},
|
||||
];
|
||||
|
||||
test("rejects trusted-proxy control ui node role without device identity", async () => {
|
||||
await configureTrustedProxyControlUiAuth();
|
||||
await withGatewayServer(async ({ port }) => {
|
||||
const ws = await openWs(port, {
|
||||
origin: "https://localhost",
|
||||
"x-forwarded-for": "203.0.113.10",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-user": "peter@example.com",
|
||||
for (const tc of trustedProxyControlUiCases) {
|
||||
test(tc.name, async () => {
|
||||
await configureTrustedProxyControlUiAuth();
|
||||
await withGatewayServer(async ({ port }) => {
|
||||
const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
|
||||
const scopes = tc.withUnpairedNodeDevice ? [] : undefined;
|
||||
let device: Awaited<ReturnType<typeof createSignedDevice>>["device"] | null = null;
|
||||
if (tc.withUnpairedNodeDevice) {
|
||||
const challengeNonce = await readConnectChallengeNonce(ws);
|
||||
expect(challengeNonce).toBeTruthy();
|
||||
({ device } = await createSignedDevice({
|
||||
token: null,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
nonce: String(challengeNonce),
|
||||
}));
|
||||
}
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
role: tc.role,
|
||||
scopes,
|
||||
device,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
});
|
||||
expect(res.ok).toBe(tc.expectedOk);
|
||||
if (!tc.expectedOk) {
|
||||
if (tc.expectedErrorSubstring) {
|
||||
expect(res.error?.message ?? "").toContain(tc.expectedErrorSubstring);
|
||||
}
|
||||
if (tc.expectedErrorCode) {
|
||||
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
tc.expectedErrorCode,
|
||||
);
|
||||
}
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
if (tc.expectStatusChecks) {
|
||||
const status = await rpcReq(ws, "status");
|
||||
expect(status.ok).toBe(false);
|
||||
expect(status.error?.message ?? "").toContain("missing scope");
|
||||
const health = await rpcReq(ws, "health");
|
||||
expect(health.ok).toBe(true);
|
||||
}
|
||||
ws.close();
|
||||
});
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
role: "node",
|
||||
device: null,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("control ui requires device identity");
|
||||
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
|
||||
);
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
test("requires pairing for trusted-proxy control ui node role with unpaired device", async () => {
|
||||
await configureTrustedProxyControlUiAuth();
|
||||
await withGatewayServer(async ({ port }) => {
|
||||
const ws = await openWs(port, {
|
||||
origin: "https://localhost",
|
||||
"x-forwarded-for": "203.0.113.10",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-user": "peter@example.com",
|
||||
});
|
||||
const challengeNonce = await readConnectChallengeNonce(ws);
|
||||
expect(challengeNonce).toBeTruthy();
|
||||
const { device } = await createSignedDevice({
|
||||
token: null,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
nonce: String(challengeNonce),
|
||||
});
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
device,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("pairing required");
|
||||
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
||||
);
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("allows localhost control ui without device identity when insecure auth is enabled", async () => {
|
||||
testState.gatewayControlUi = { allowInsecureAuth: true };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
evaluateMissingDeviceIdentity,
|
||||
isTrustedProxyControlUiOperatorAuth,
|
||||
resolveControlUiAuthPolicy,
|
||||
shouldSkipControlUiPairing,
|
||||
} from "./connect-policy.js";
|
||||
@@ -186,4 +187,55 @@ describe("ws connect policy", () => {
|
||||
expect(shouldSkipControlUiPairing(strict, true, false)).toBe(false);
|
||||
expect(shouldSkipControlUiPairing(strict, false, true)).toBe(true);
|
||||
});
|
||||
|
||||
test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => {
|
||||
const cases: Array<{
|
||||
role: "operator" | "node";
|
||||
authMode: string;
|
||||
authOk: boolean;
|
||||
authMethod: string | undefined;
|
||||
expected: boolean;
|
||||
}> = [
|
||||
{
|
||||
role: "operator",
|
||||
authMode: "trusted-proxy",
|
||||
authOk: true,
|
||||
authMethod: "trusted-proxy",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
role: "node",
|
||||
authMode: "trusted-proxy",
|
||||
authOk: true,
|
||||
authMethod: "trusted-proxy",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
role: "operator",
|
||||
authMode: "token",
|
||||
authOk: true,
|
||||
authMethod: "token",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
role: "operator",
|
||||
authMode: "trusted-proxy",
|
||||
authOk: false,
|
||||
authMethod: "trusted-proxy",
|
||||
expected: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const tc of cases) {
|
||||
expect(
|
||||
isTrustedProxyControlUiOperatorAuth({
|
||||
isControlUi: true,
|
||||
role: tc.role,
|
||||
authMode: tc.authMode,
|
||||
authOk: tc.authOk,
|
||||
authMethod: tc.authMethod,
|
||||
}),
|
||||
).toBe(tc.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,22 @@ export function shouldSkipControlUiPairing(
|
||||
return policy.allowBypass && sharedAuthOk;
|
||||
}
|
||||
|
||||
export function isTrustedProxyControlUiOperatorAuth(params: {
|
||||
isControlUi: boolean;
|
||||
role: GatewayRole;
|
||||
authMode: string;
|
||||
authOk: boolean;
|
||||
authMethod: string | undefined;
|
||||
}): boolean {
|
||||
return (
|
||||
params.isControlUi &&
|
||||
params.role === "operator" &&
|
||||
params.authMode === "trusted-proxy" &&
|
||||
params.authOk &&
|
||||
params.authMethod === "trusted-proxy"
|
||||
);
|
||||
}
|
||||
|
||||
export type MissingDeviceIdentityDecision =
|
||||
| { kind: "allow" }
|
||||
| { kind: "reject-control-ui-insecure-auth" }
|
||||
|
||||
@@ -75,6 +75,7 @@ import { resolveConnectAuthDecision, resolveConnectAuthState } from "./auth-cont
|
||||
import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js";
|
||||
import {
|
||||
evaluateMissingDeviceIdentity,
|
||||
isTrustedProxyControlUiOperatorAuth,
|
||||
resolveControlUiAuthPolicy,
|
||||
shouldSkipControlUiPairing,
|
||||
} from "./connect-policy.js";
|
||||
@@ -489,12 +490,13 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
if (!device) {
|
||||
clearUnboundScopes();
|
||||
}
|
||||
const trustedProxyAuthOk =
|
||||
isControlUi &&
|
||||
role === "operator" &&
|
||||
resolvedAuth.mode === "trusted-proxy" &&
|
||||
authOk &&
|
||||
authMethod === "trusted-proxy";
|
||||
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
|
||||
isControlUi,
|
||||
role,
|
||||
authMode: resolvedAuth.mode,
|
||||
authOk,
|
||||
authMethod,
|
||||
});
|
||||
const decision = evaluateMissingDeviceIdentity({
|
||||
hasDeviceIdentity: Boolean(device),
|
||||
role,
|
||||
@@ -628,12 +630,13 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const trustedProxyAuthOk =
|
||||
isControlUi &&
|
||||
role === "operator" &&
|
||||
resolvedAuth.mode === "trusted-proxy" &&
|
||||
authOk &&
|
||||
authMethod === "trusted-proxy";
|
||||
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
|
||||
isControlUi,
|
||||
role,
|
||||
authMode: resolvedAuth.mode,
|
||||
authOk,
|
||||
authMethod,
|
||||
});
|
||||
const skipPairing = shouldSkipControlUiPairing(
|
||||
controlUiAuthPolicy,
|
||||
sharedAuthOk,
|
||||
|
||||
Reference in New Issue
Block a user