mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 14:00:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -420,7 +420,7 @@ describe("ws connect policy", () => {
|
||||
authMethod: undefined,
|
||||
trustedProxyAuthOk: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldClearUnboundScopesForMissingDeviceIdentity({
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user