mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
gateway: require pairing for backend scope upgrades (#55286)
This commit is contained in:
@@ -84,6 +84,68 @@ describe("gateway silent scope-upgrade reconnect", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("does not let backend reconnect bypass the paired scope baseline", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const paired = await issueOperatorToken({
|
||||
name: "backend-scope-upgrade-reconnect-poc",
|
||||
approvedScopes: ["operator.read"],
|
||||
clientId: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||
clientMode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
});
|
||||
|
||||
let watcherWs: WebSocket | undefined;
|
||||
let backendReconnectWs: WebSocket | undefined;
|
||||
|
||||
try {
|
||||
watcherWs = await openTrackedWs(started.port);
|
||||
await connectOk(watcherWs, { scopes: ["operator.admin"] });
|
||||
const requestedEvent = onceMessage(
|
||||
watcherWs,
|
||||
(obj) => obj.type === "event" && obj.event === "device.pair.requested",
|
||||
);
|
||||
|
||||
backendReconnectWs = await openTrackedWs(started.port);
|
||||
const reconnectAttempt = await connectReq(backendReconnectWs, {
|
||||
token: "secret",
|
||||
deviceIdentityPath: paired.identityPath,
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||
version: "1.0.0",
|
||||
platform: "node",
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
},
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
expect(reconnectAttempt.ok).toBe(false);
|
||||
expect(reconnectAttempt.error?.message).toBe("pairing required");
|
||||
|
||||
const pending = await devicePairingModule.listDevicePairing();
|
||||
expect(pending.pending).toHaveLength(1);
|
||||
expect(
|
||||
(reconnectAttempt.error?.details as { requestId?: unknown; code?: string })?.requestId,
|
||||
).toBe(pending.pending[0]?.requestId);
|
||||
|
||||
const requested = (await requestedEvent) as {
|
||||
payload?: { requestId?: string; deviceId?: string; scopes?: string[] };
|
||||
};
|
||||
expect(requested.payload?.requestId).toBe(pending.pending[0]?.requestId);
|
||||
expect(requested.payload?.deviceId).toBe(paired.deviceId);
|
||||
expect(requested.payload?.scopes).toEqual(["operator.admin"]);
|
||||
|
||||
const afterAttempt = await getPairedDevice(paired.deviceId);
|
||||
expect(afterAttempt?.approvedScopes).toEqual(["operator.read"]);
|
||||
expect(afterAttempt?.tokens?.operator?.scopes).toEqual(["operator.read"]);
|
||||
expect(afterAttempt?.tokens?.operator?.token).toBe(paired.token);
|
||||
} finally {
|
||||
watcherWs?.close();
|
||||
backendReconnectWs?.close();
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts local silent reconnect when pairing was concurrently approved", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const loaded = loadDeviceIdentity("silent-reconnect-race");
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
|
||||
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js";
|
||||
import type { ConnectParams } from "../../protocol/index.js";
|
||||
import {
|
||||
BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP,
|
||||
resolveHandshakeBrowserSecurityContext,
|
||||
resolveUnauthorizedHandshakeContext,
|
||||
shouldAllowSilentLocalPairing,
|
||||
shouldSkipBackendSelfPairing,
|
||||
} from "./handshake-auth-helpers.js";
|
||||
|
||||
function createRateLimiter(): AuthRateLimiter {
|
||||
@@ -88,41 +85,4 @@ describe("handshake auth helpers", () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("skips backend self-pairing for local trusted backend clients", () => {
|
||||
const connectParams = {
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
},
|
||||
} as ConnectParams;
|
||||
|
||||
expect(
|
||||
shouldSkipBackendSelfPairing({
|
||||
connectParams,
|
||||
isLocalClient: true,
|
||||
hasBrowserOriginHeader: false,
|
||||
sharedAuthOk: true,
|
||||
authMethod: "token",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSkipBackendSelfPairing({
|
||||
connectParams,
|
||||
isLocalClient: true,
|
||||
hasBrowserOriginHeader: false,
|
||||
sharedAuthOk: false,
|
||||
authMethod: "device-token",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSkipBackendSelfPairing({
|
||||
connectParams,
|
||||
isLocalClient: false,
|
||||
hasBrowserOriginHeader: false,
|
||||
sharedAuthOk: true,
|
||||
authMethod: "token",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { AuthRateLimiter } from "../../auth-rate-limit.js";
|
||||
import type { GatewayAuthResult } from "../../auth.js";
|
||||
import { buildDeviceAuthPayload, buildDeviceAuthPayloadV3 } from "../../device-auth.js";
|
||||
import { isLoopbackAddress } from "../../net.js";
|
||||
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js";
|
||||
import type { ConnectParams } from "../../protocol/index.js";
|
||||
import type { AuthProvidedKind } from "./auth-messages.js";
|
||||
|
||||
@@ -60,31 +59,6 @@ export function shouldAllowSilentLocalPairing(params: {
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldSkipBackendSelfPairing(params: {
|
||||
connectParams: ConnectParams;
|
||||
isLocalClient: boolean;
|
||||
hasBrowserOriginHeader: boolean;
|
||||
sharedAuthOk: boolean;
|
||||
authMethod: GatewayAuthResult["method"];
|
||||
}): boolean {
|
||||
const isGatewayBackendClient =
|
||||
params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT &&
|
||||
params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND;
|
||||
if (!isGatewayBackendClient) {
|
||||
return false;
|
||||
}
|
||||
const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
|
||||
const usesDeviceTokenAuth = params.authMethod === "device-token";
|
||||
// `authMethod === "device-token"` only reaches this helper after the caller
|
||||
// has already accepted auth (`authOk === true`), so a separate
|
||||
// `deviceTokenAuthOk` flag would be redundant here.
|
||||
return (
|
||||
params.isLocalClient &&
|
||||
!params.hasBrowserOriginHeader &&
|
||||
((params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSignatureToken(connectParams: ConnectParams): string | null {
|
||||
return (
|
||||
connectParams.auth?.token ??
|
||||
|
||||
@@ -91,7 +91,6 @@ import {
|
||||
resolveHandshakeBrowserSecurityContext,
|
||||
resolveUnauthorizedHandshakeContext,
|
||||
shouldAllowSilentLocalPairing,
|
||||
shouldSkipBackendSelfPairing,
|
||||
} from "./handshake-auth-helpers.js";
|
||||
import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js";
|
||||
|
||||
@@ -686,20 +685,12 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
authOk,
|
||||
authMethod,
|
||||
});
|
||||
const skipPairing =
|
||||
shouldSkipBackendSelfPairing({
|
||||
connectParams,
|
||||
isLocalClient,
|
||||
hasBrowserOriginHeader,
|
||||
sharedAuthOk,
|
||||
authMethod,
|
||||
}) ||
|
||||
shouldSkipControlUiPairing(
|
||||
controlUiAuthPolicy,
|
||||
role,
|
||||
trustedProxyAuthOk,
|
||||
resolvedAuth.mode,
|
||||
);
|
||||
const skipPairing = shouldSkipControlUiPairing(
|
||||
controlUiAuthPolicy,
|
||||
role,
|
||||
trustedProxyAuthOk,
|
||||
resolvedAuth.mode,
|
||||
);
|
||||
if (device && devicePublicKey && !skipPairing) {
|
||||
const formatAuditList = (items: string[] | undefined): string => {
|
||||
if (!items || items.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user