fix(gateway): preserve trusted-proxy Control UI scopes (#79643)

* fix(gateway): preserve trusted proxy control ui scopes

* docs: add trusted proxy control ui changelog
This commit is contained in:
Josh Avant
2026-05-08 23:15:19 -05:00
committed by GitHub
parent fa126d43fb
commit 3391c5b050
5 changed files with 106 additions and 9 deletions

View File

@@ -442,6 +442,7 @@ Docs: https://docs.openclaw.ai
- Discord: clear stale startup probe bot/application status when the async bot probe throws, not just when it returns a degraded probe result. Thanks @vincentkoc.
- Discord: start the gateway monitor without waiting for the startup bot/application probe, so WSL2 hosts with a slow `/users/@me` REST path still bring the channel online while status enrichment finishes asynchronously. Fixes #77103. Thanks @Suited78.
- Discord/Gateway startup: retry Discord READY waits with backoff, defer startup `sessions.list` and native approval readiness failures until sidecars recover, and preserve component-only Discord payloads when final reply scrubbing removes all text. (#77478) Thanks @NikolaFC.
- Control UI/Gateway: preserve verified trusted-proxy operator scopes for browser WebSocket sessions so nginx/Authelia deployments can load chat history, models, sessions, nodes, and logs instead of failing with missing operator.read. Fixes #78508. (#79643) Thanks @joshavant.
- Webhooks/Gmail/Windows: resolve `gcloud`, `gog`, and `tailscale` PATH/PATHEXT shims before setup and watcher spawns, using the Windows-safe `.cmd` wrapper for long-lived `gog serve` processes. (#74881, fixes #54470) Thanks @Angfr95.
- Infra/Windows: skip the POSIX `/tmp/openclaw` preferred path on Windows in `resolvePreferredOpenClawTmpDir` so log files, TTS temp files, and other writes land in `%TEMP%\openclaw-<uid>` instead of `C:\tmp\openclaw`. Fixes #60713. Thanks @juan-flores077.
- Media/Windows: open saved attachment temp files read/write before fsync so Windows WebChat and `chat.send` media offloads no longer fail with EPERM during durability flush. (#76593) Thanks @qq230849622-a11y.

View File

@@ -315,7 +315,7 @@ export function registerControlUiAndPairingSuite(): void {
});
});
test("clamps trusted-proxy control ui scopes for unpaired device identity", async () => {
test("preserves trusted-proxy control ui scopes for unpaired device identity", async () => {
const { replaceConfigFile } = await import("../config/config.js");
testState.gatewayAuth = undefined;
testState.gatewayControlUi = {
@@ -348,14 +348,14 @@ export function registerControlUiAndPairingSuite(): void {
const { device } = await createSignedDevice({
token: null,
role: "operator",
scopes: ["operator.admin"],
scopes: ["operator.admin", "operator.read"],
clientId: CONTROL_UI_CLIENT.id,
clientMode: CONTROL_UI_CLIENT.mode,
nonce: challengeNonce,
});
const res = await connectReq(ws, {
skipDefaultAuth: true,
scopes: ["operator.admin"],
scopes: ["operator.admin", "operator.read"],
device,
client: { ...CONTROL_UI_CLIENT },
});
@@ -365,12 +365,79 @@ export function registerControlUiAndPairingSuite(): void {
auth?: { scopes?: string[]; deviceToken?: string };
}
| undefined;
expect(payload?.auth?.scopes).toEqual([]);
expect(payload?.auth?.scopes).toEqual(["operator.admin", "operator.read"]);
expect(payload?.auth?.deviceToken).toBeUndefined();
const admin = await rpcReq(ws, "set-heartbeats", { enabled: false });
expect(admin.ok).toBe(true);
} finally {
ws.close();
}
});
});
test("bounds trusted-proxy control ui scopes to proxy-declared scope header", async () => {
const { replaceConfigFile } = await import("../config/config.js");
testState.gatewayAuth = undefined;
testState.gatewayControlUi = {
...testState.gatewayControlUi,
allowedOrigins: ["https://localhost"],
};
await replaceConfigFile({
nextConfig: {
gateway: {
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
requiredHeaders: ["x-forwarded-proto"],
allowLoopback: true,
},
},
trustedProxies: ["127.0.0.1"],
controlUi: {
allowedOrigins: ["https://localhost"],
},
},
},
afterWrite: { mode: "auto" },
});
await withControlUiGatewayServer(async ({ port }) => {
const ws = await openWs(port, {
...TRUSTED_PROXY_CONTROL_UI_HEADERS,
"x-openclaw-scopes": "operator.read",
});
try {
const challengeNonce = await readConnectChallengeNonce(ws);
const { device } = await createSignedDevice({
token: null,
role: "operator",
scopes: ["operator.admin", "operator.read"],
clientId: CONTROL_UI_CLIENT.id,
clientMode: CONTROL_UI_CLIENT.mode,
nonce: challengeNonce,
});
const res = await connectReq(ws, {
skipDefaultAuth: true,
scopes: ["operator.admin", "operator.read"],
device,
client: { ...CONTROL_UI_CLIENT },
});
expect(res.ok).toBe(true);
const payload = res.payload as
| {
auth?: { scopes?: string[]; deviceToken?: string };
}
| undefined;
expect(payload?.auth?.scopes).toEqual(["operator.read"]);
expect(payload?.auth?.deviceToken).toBeUndefined();
const admin = await rpcReq(ws, "set-heartbeats", { enabled: false });
expect(admin.ok).toBe(false);
expect(admin.error?.message ?? "").toContain("missing scope");
const health = await rpcReq(ws, "health");
expect(health.ok).toBe(true);
} finally {
ws.close();
}

View File

@@ -420,7 +420,7 @@ describe("ws connect policy", () => {
authMethod: undefined,
trustedProxyAuthOk: true,
}),
).toBe(true);
).toBe(false);
expect(
shouldClearUnboundScopesForMissingDeviceIdentity({

View File

@@ -96,12 +96,12 @@ export function shouldClearUnboundScopesForMissingDeviceIdentity(params: {
params.decision.kind !== "allow" ||
(!params.controlUiAuthPolicy.allowBypass &&
!params.preserveInsecureLocalControlUiScopes &&
params.trustedProxyAuthOk !== true &&
// trusted-proxy auth can bypass pairing for some clients, but those
// self-declared scopes are still unbound without device identity.
(params.authMethod === "token" ||
params.authMethod === "password" ||
params.authMethod === "trusted-proxy" ||
params.trustedProxyAuthOk === true))
params.authMethod === "trusted-proxy"))
);
}

View File

@@ -149,6 +149,30 @@ export type WsOriginCheckMetrics = {
hostHeaderFallbackAccepted: number;
};
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function resolveTrustedProxyControlUiScopes(params: {
requestedScopes: string[];
upgradeReq: IncomingMessage;
}): string[] {
const rawHeader = firstHeaderValue(params.upgradeReq.headers["x-openclaw-scopes"]);
if (rawHeader === undefined) {
return params.requestedScopes;
}
const declaredScopes = new Set(
rawHeader
.split(",")
.map((scope) => scope.trim())
.filter((scope) => scope.length > 0),
);
if (declaredScopes.size === 0) {
return [];
}
return params.requestedScopes.filter((scope) => declaredScopes.has(scope));
}
function resolvePinnedClientMetadata(params: {
claimedPlatform?: string;
claimedDeviceFamily?: string;
@@ -875,6 +899,13 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
authOk,
authMethod,
});
if (trustedProxyAuthOk) {
scopes = resolveTrustedProxyControlUiScopes({
requestedScopes: scopes,
upgradeReq,
});
connectParams.scopes = scopes;
}
const skipControlUiPairingForDevice = shouldSkipControlUiPairing(
controlUiAuthPolicy,
role,
@@ -1143,8 +1174,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
return;
}
hasServerApprovedDeviceTokenBaseline = true;
} else if (trustedProxyAuthOk) {
clearUnboundScopes();
} else if (
skipControlUiPairingForDevice ||
(skipLocalBackendSelfPairing && authMethod !== "device-token")