fix: enforce ws3 roles + node allowlist

This commit is contained in:
Peter Steinberger
2026-01-20 09:23:56 +00:00
parent 32a668e4d9
commit 9dbc1435a6
27 changed files with 3096 additions and 40 deletions

View File

@@ -24,6 +24,7 @@ import { authorizeGatewayConnect } from "../../auth.js";
import { loadConfig } from "../../../config/config.js";
import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLoopbackAddress } from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import {
type ConnectParams,
ErrorCodes,
@@ -253,17 +254,55 @@ export function attachGatewayWsMessageHandler(params: {
}
const authMethod = authResult.method ?? "none";
const role = connectParams.role ?? "operator";
const scopes = Array.isArray(connectParams.scopes)
? connectParams.scopes
: role === "operator"
? ["operator.admin"]
: [];
const roleRaw = connectParams.role ?? "operator";
const role = roleRaw === "operator" || roleRaw === "node" ? roleRaw : null;
if (!role) {
setHandshakeState("failed");
setCloseCause("invalid-role", {
role: roleRaw,
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "invalid role"),
});
close(1008, "invalid role");
return;
}
const requestedScopes = Array.isArray(connectParams.scopes) ? connectParams.scopes : [];
const scopes =
requestedScopes.length > 0
? requestedScopes
: role === "operator"
? ["operator.admin"]
: [];
connectParams.role = role;
connectParams.scopes = scopes;
const device = connectParams.device;
let devicePublicKey: string | null = null;
if (!device) {
setHandshakeState("failed");
setCloseCause("device-required", {
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.NOT_PAIRED, "device identity required"),
});
close(1008, "device identity required");
return;
}
if (device) {
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
if (!derivedId || derivedId !== device.id) {
@@ -307,7 +346,7 @@ export function attachGatewayWsMessageHandler(params: {
clientId: connectParams.client.id,
clientMode: connectParams.client.mode,
role,
scopes,
scopes: requestedScopes,
signedAtMs: signedAt,
token: connectParams.auth?.token ?? null,
});
@@ -347,9 +386,7 @@ export function attachGatewayWsMessageHandler(params: {
}
if (device && devicePublicKey) {
const paired = await getPairedDevice(device.id);
const isPaired = paired?.publicKey === devicePublicKey;
if (!isPaired) {
const requirePairing = async (reason: string, paired?: { deviceId: string }) => {
const pairing = await requestDevicePairing({
deviceId: device.id,
publicKey: devicePublicKey,
@@ -360,7 +397,7 @@ export function attachGatewayWsMessageHandler(params: {
role,
scopes,
remoteIp: remoteAddr,
silent: isLoopbackAddress(remoteAddr) && authMethod !== "none",
silent: isLoopbackAddress(remoteAddr),
});
const context = buildRequestContext();
if (pairing.request.silent === true) {
@@ -385,6 +422,7 @@ export function attachGatewayWsMessageHandler(params: {
setCloseCause("pairing-required", {
deviceId: device.id,
requestId: pairing.request.requestId,
reason,
});
send({
type: "res",
@@ -395,9 +433,47 @@ export function attachGatewayWsMessageHandler(params: {
}),
});
close(1008, "pairing required");
return;
return false;
}
return true;
};
const paired = await getPairedDevice(device.id);
const isPaired = paired?.publicKey === devicePublicKey;
if (!isPaired) {
const ok = await requirePairing("not-paired");
if (!ok) return;
} else {
const allowedRoles = new Set(
Array.isArray(paired.roles)
? paired.roles
: paired.role
? [paired.role]
: [],
);
if (allowedRoles.size === 0) {
const ok = await requirePairing("role-upgrade", paired);
if (!ok) return;
} else if (!allowedRoles.has(role)) {
const ok = await requirePairing("role-upgrade", paired);
if (!ok) return;
}
const pairedScopes = Array.isArray(paired.scopes) ? paired.scopes : [];
if (scopes.length > 0) {
if (pairedScopes.length === 0) {
const ok = await requirePairing("scope-upgrade", paired);
if (!ok) return;
} else {
const allowedScopes = new Set(pairedScopes);
const missingScope = scopes.find((scope) => !allowedScopes.has(scope));
if (missingScope) {
const ok = await requirePairing("scope-upgrade", paired);
if (!ok) return;
}
}
}
await updatePairedDeviceMetadata(device.id, {
displayName: connectParams.client.displayName,
platform: connectParams.client.platform,
@@ -410,10 +486,25 @@ export function attachGatewayWsMessageHandler(params: {
}
}
if (role === "node") {
const cfg = loadConfig();
const allowlist = resolveNodeCommandAllowlist(cfg, {
platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
});
const declared = Array.isArray(connectParams.commands) ? connectParams.commands : [];
const filtered = declared
.map((cmd) => cmd.trim())
.filter((cmd) => cmd.length > 0 && allowlist.has(cmd));
connectParams.commands = filtered;
}
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
const clientId = connectParams.client.id;
const instanceId = connectParams.client.instanceId;
const presenceKey = shouldTrackPresence ? (instanceId ?? connId) : undefined;
const presenceKey = shouldTrackPresence
? (connectParams.device?.id ?? instanceId ?? connId)
: undefined;
logWs("in", "connect", {
connId,
@@ -441,7 +532,7 @@ export function attachGatewayWsMessageHandler(params: {
deviceFamily: connectParams.client.deviceFamily,
modelIdentifier: connectParams.client.modelIdentifier,
mode: connectParams.client.mode,
instanceId,
instanceId: connectParams.device?.id ?? instanceId,
reason: "connect",
});
incrementPresenceVersion();