Skip to content

Commit d3d8e31

Browse files
gateway: require pairing for backend scope upgrades (#55286)
1 parent b5d785f commit d3d8e31

4 files changed

Lines changed: 68 additions & 81 deletions

File tree

src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,68 @@ describe("gateway silent scope-upgrade reconnect", () => {
8484
}
8585
});
8686

87+
test("does not let backend reconnect bypass the paired scope baseline", async () => {
88+
const started = await startServerWithClient("secret");
89+
const paired = await issueOperatorToken({
90+
name: "backend-scope-upgrade-reconnect-poc",
91+
approvedScopes: ["operator.read"],
92+
clientId: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
93+
clientMode: GATEWAY_CLIENT_MODES.BACKEND,
94+
});
95+
96+
let watcherWs: WebSocket | undefined;
97+
let backendReconnectWs: WebSocket | undefined;
98+
99+
try {
100+
watcherWs = await openTrackedWs(started.port);
101+
await connectOk(watcherWs, { scopes: ["operator.admin"] });
102+
const requestedEvent = onceMessage(
103+
watcherWs,
104+
(obj) => obj.type === "event" && obj.event === "device.pair.requested",
105+
);
106+
107+
backendReconnectWs = await openTrackedWs(started.port);
108+
const reconnectAttempt = await connectReq(backendReconnectWs, {
109+
token: "secret",
110+
deviceIdentityPath: paired.identityPath,
111+
client: {
112+
id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
113+
version: "1.0.0",
114+
platform: "node",
115+
mode: GATEWAY_CLIENT_MODES.BACKEND,
116+
},
117+
role: "operator",
118+
scopes: ["operator.admin"],
119+
});
120+
expect(reconnectAttempt.ok).toBe(false);
121+
expect(reconnectAttempt.error?.message).toBe("pairing required");
122+
123+
const pending = await devicePairingModule.listDevicePairing();
124+
expect(pending.pending).toHaveLength(1);
125+
expect(
126+
(reconnectAttempt.error?.details as { requestId?: unknown; code?: string })?.requestId,
127+
).toBe(pending.pending[0]?.requestId);
128+
129+
const requested = (await requestedEvent) as {
130+
payload?: { requestId?: string; deviceId?: string; scopes?: string[] };
131+
};
132+
expect(requested.payload?.requestId).toBe(pending.pending[0]?.requestId);
133+
expect(requested.payload?.deviceId).toBe(paired.deviceId);
134+
expect(requested.payload?.scopes).toEqual(["operator.admin"]);
135+
136+
const afterAttempt = await getPairedDevice(paired.deviceId);
137+
expect(afterAttempt?.approvedScopes).toEqual(["operator.read"]);
138+
expect(afterAttempt?.tokens?.operator?.scopes).toEqual(["operator.read"]);
139+
expect(afterAttempt?.tokens?.operator?.token).toBe(paired.token);
140+
} finally {
141+
watcherWs?.close();
142+
backendReconnectWs?.close();
143+
started.ws.close();
144+
await started.server.close();
145+
started.envSnapshot.restore();
146+
}
147+
});
148+
87149
test("accepts local silent reconnect when pairing was concurrently approved", async () => {
88150
const started = await startServerWithClient("secret");
89151
const loaded = loadDeviceIdentity("silent-reconnect-race");

src/gateway/server/ws-connection/handshake-auth-helpers.test.ts

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
import { describe, expect, it } from "vitest";
22
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
3-
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js";
4-
import type { ConnectParams } from "../../protocol/index.js";
53
import {
64
BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP,
75
resolveHandshakeBrowserSecurityContext,
86
resolveUnauthorizedHandshakeContext,
97
shouldAllowSilentLocalPairing,
10-
shouldSkipBackendSelfPairing,
118
} from "./handshake-auth-helpers.js";
129

1310
function createRateLimiter(): AuthRateLimiter {
@@ -88,41 +85,4 @@ describe("handshake auth helpers", () => {
8885
}),
8986
).toBe(false);
9087
});
91-
92-
it("skips backend self-pairing for local trusted backend clients", () => {
93-
const connectParams = {
94-
client: {
95-
id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
96-
mode: GATEWAY_CLIENT_MODES.BACKEND,
97-
},
98-
} as ConnectParams;
99-
100-
expect(
101-
shouldSkipBackendSelfPairing({
102-
connectParams,
103-
isLocalClient: true,
104-
hasBrowserOriginHeader: false,
105-
sharedAuthOk: true,
106-
authMethod: "token",
107-
}),
108-
).toBe(true);
109-
expect(
110-
shouldSkipBackendSelfPairing({
111-
connectParams,
112-
isLocalClient: true,
113-
hasBrowserOriginHeader: false,
114-
sharedAuthOk: false,
115-
authMethod: "device-token",
116-
}),
117-
).toBe(true);
118-
expect(
119-
shouldSkipBackendSelfPairing({
120-
connectParams,
121-
isLocalClient: false,
122-
hasBrowserOriginHeader: false,
123-
sharedAuthOk: true,
124-
authMethod: "token",
125-
}),
126-
).toBe(false);
127-
});
12888
});

src/gateway/server/ws-connection/handshake-auth-helpers.ts

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type { AuthRateLimiter } from "../../auth-rate-limit.js";
33
import type { GatewayAuthResult } from "../../auth.js";
44
import { buildDeviceAuthPayload, buildDeviceAuthPayloadV3 } from "../../device-auth.js";
55
import { isLoopbackAddress } from "../../net.js";
6-
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js";
76
import type { ConnectParams } from "../../protocol/index.js";
87
import type { AuthProvidedKind } from "./auth-messages.js";
98

@@ -60,31 +59,6 @@ export function shouldAllowSilentLocalPairing(params: {
6059
);
6160
}
6261

63-
export function shouldSkipBackendSelfPairing(params: {
64-
connectParams: ConnectParams;
65-
isLocalClient: boolean;
66-
hasBrowserOriginHeader: boolean;
67-
sharedAuthOk: boolean;
68-
authMethod: GatewayAuthResult["method"];
69-
}): boolean {
70-
const isGatewayBackendClient =
71-
params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT &&
72-
params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND;
73-
if (!isGatewayBackendClient) {
74-
return false;
75-
}
76-
const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
77-
const usesDeviceTokenAuth = params.authMethod === "device-token";
78-
// `authMethod === "device-token"` only reaches this helper after the caller
79-
// has already accepted auth (`authOk === true`), so a separate
80-
// `deviceTokenAuthOk` flag would be redundant here.
81-
return (
82-
params.isLocalClient &&
83-
!params.hasBrowserOriginHeader &&
84-
((params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth)
85-
);
86-
}
87-
8862
function resolveSignatureToken(connectParams: ConnectParams): string | null {
8963
return (
9064
connectParams.auth?.token ??

src/gateway/server/ws-connection/message-handler.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ import {
9191
resolveHandshakeBrowserSecurityContext,
9292
resolveUnauthorizedHandshakeContext,
9393
shouldAllowSilentLocalPairing,
94-
shouldSkipBackendSelfPairing,
9594
} from "./handshake-auth-helpers.js";
9695
import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js";
9796

@@ -686,20 +685,12 @@ export function attachGatewayWsMessageHandler(params: {
686685
authOk,
687686
authMethod,
688687
});
689-
const skipPairing =
690-
shouldSkipBackendSelfPairing({
691-
connectParams,
692-
isLocalClient,
693-
hasBrowserOriginHeader,
694-
sharedAuthOk,
695-
authMethod,
696-
}) ||
697-
shouldSkipControlUiPairing(
698-
controlUiAuthPolicy,
699-
role,
700-
trustedProxyAuthOk,
701-
resolvedAuth.mode,
702-
);
688+
const skipPairing = shouldSkipControlUiPairing(
689+
controlUiAuthPolicy,
690+
role,
691+
trustedProxyAuthOk,
692+
resolvedAuth.mode,
693+
);
703694
if (device && devicePublicKey && !skipPairing) {
704695
const formatAuditList = (items: string[] | undefined): string => {
705696
if (!items || items.length === 0) {

0 commit comments

Comments
 (0)