mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(gateway): constrain proxy device auto-approval roles (#111394)
This commit is contained in:
committed by
GitHub
parent
d3f2c5ad81
commit
d9465c941e
@@ -12,7 +12,7 @@ import {
|
||||
} from "../infra/device-identity.js";
|
||||
import { getPairedDevice, listDevicePairing } from "../infra/device-pairing.js";
|
||||
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
|
||||
import { CONTROL_UI_CLIENT } from "./server.auth.test-helpers.js";
|
||||
import { CONTROL_UI_CLIENT, NODE_CLIENT } from "./server.auth.test-helpers.js";
|
||||
import {
|
||||
connectReq,
|
||||
installGatewayTestHooks,
|
||||
@@ -230,6 +230,43 @@ describe("trusted-proxy browser device auto-approval", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("leaves mixed node and operator requests pending for manual approval", async () => {
|
||||
await writeGatewayAuthConfig({
|
||||
mode: "trusted-proxy",
|
||||
deviceAutoApprove: { enabled: true, scopes: ["operator.read"] },
|
||||
});
|
||||
const identityPath = deviceIdentityPath("trusted-proxy-mixed-role");
|
||||
const identity = loadOrCreateDeviceIdentity({ path: identityPath });
|
||||
|
||||
await withGatewayServer(async ({ port }) => {
|
||||
const nodeWs = await openBrowserWs(port, trustedProxyHeaders());
|
||||
try {
|
||||
const nodeRes = await connectReq(nodeWs, {
|
||||
skipDefaultAuth: true,
|
||||
client: NODE_CLIENT,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(nodeRes.ok).toBe(false);
|
||||
} finally {
|
||||
nodeWs.close();
|
||||
}
|
||||
|
||||
const browserRes = await connectBrowser({
|
||||
port,
|
||||
identityPath,
|
||||
scopes: ["operator.read"],
|
||||
});
|
||||
expect(browserRes.ok).toBe(false);
|
||||
});
|
||||
|
||||
await expect(getPairedDevice(identity.deviceId)).resolves.toBeNull();
|
||||
expect(
|
||||
(await listDevicePairing()).pending.find((entry) => entry.deviceId === identity.deviceId),
|
||||
).toMatchObject({ roles: ["node", "operator"] });
|
||||
});
|
||||
|
||||
test("caps omitted scopes to the declared proxy scopes", async () => {
|
||||
await writeGatewayAuthConfig({
|
||||
mode: "trusted-proxy",
|
||||
|
||||
@@ -552,6 +552,49 @@ describe("device pairing tokens", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("refuses trusted-proxy auto-approval for a merged node and operator request", async () => {
|
||||
const baseDir = await makeDevicePairingDir();
|
||||
await requestDevicePairing(
|
||||
{
|
||||
deviceId: "mixed-role-device-1",
|
||||
publicKey: "public-key-mixed-role-1",
|
||||
role: "node",
|
||||
scopes: [],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
const browser = await requestDevicePairing(
|
||||
{
|
||||
deviceId: "mixed-role-device-1",
|
||||
publicKey: "public-key-mixed-role-1",
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
expect(browser.request.roles).toEqual(["node", "operator"]);
|
||||
|
||||
await expect(
|
||||
approveDevicePairing(
|
||||
browser.request.requestId,
|
||||
{
|
||||
callerScopes: ["operator.read"],
|
||||
approvedVia: "trusted-proxy",
|
||||
autoApproveNewDeviceScopes: ["operator.read"],
|
||||
},
|
||||
baseDir,
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await expect(getPairedDevice("mixed-role-device-1", baseDir)).resolves.toBeNull();
|
||||
expect((await listDevicePairing(baseDir)).pending).toContainEqual(
|
||||
expect.objectContaining({
|
||||
requestId: browser.request.requestId,
|
||||
roles: ["node", "operator"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "node custom scope",
|
||||
|
||||
@@ -792,9 +792,9 @@ export async function approveDevicePairing(
|
||||
"owner" | "silent" | "trusted-cidr" | "trusted-proxy" | "ssh-verified"
|
||||
>;
|
||||
/**
|
||||
* Replace the pending scopes only if this is still a brand-new device.
|
||||
* Used by trusted-proxy auto-approval to cap grants without ever approving
|
||||
* an existing-device repair or upgrade request.
|
||||
* Replace the pending scopes only if this is still a brand-new operator device.
|
||||
* The live role set is rechecked under the pairing lock so a merged request
|
||||
* cannot inherit non-operator access through browser auto-approval.
|
||||
*/
|
||||
autoApproveNewDeviceScopes?: readonly string[];
|
||||
},
|
||||
@@ -826,16 +826,19 @@ export async function approveDevicePairing(
|
||||
if (!pendingRecord) {
|
||||
return null;
|
||||
}
|
||||
const autoApproveScopes = options?.autoApproveNewDeviceScopes;
|
||||
const requestedRoles = resolveRequestedRoles(pendingRecord);
|
||||
if (
|
||||
options?.autoApproveNewDeviceScopes &&
|
||||
(pendingRecord.isRepair || state.pairedByDeviceId[pendingRecord.deviceId])
|
||||
autoApproveScopes &&
|
||||
(pendingRecord.isRepair ||
|
||||
state.pairedByDeviceId[pendingRecord.deviceId] ||
|
||||
!sameStringSet(requestedRoles, [OPERATOR_ROLE]))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const pending = options?.autoApproveNewDeviceScopes
|
||||
? { ...pendingRecord, scopes: [...options.autoApproveNewDeviceScopes] }
|
||||
const pending = autoApproveScopes
|
||||
? { ...pendingRecord, scopes: [...autoApproveScopes] }
|
||||
: pendingRecord;
|
||||
const requestedRoles = mergeRoles(pending.roles, pending.role) ?? [];
|
||||
const requestedScopes = normalizeDeviceAuthScopes(pending.scopes);
|
||||
const roleMismatchScope = resolveScopeOutsideRequestedRoles({
|
||||
requestedRoles,
|
||||
|
||||
Reference in New Issue
Block a user