fix(gateway): invalidate shared-token/password WS sessions on secret rotation [AI] (#62350)

* fix: address issue

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
pgondhi987
2026-04-07 21:40:10 +05:30
committed by GitHub
parent 6a6a279fda
commit 5880ec17b1
12 changed files with 494 additions and 10 deletions

View File

@@ -255,6 +255,19 @@ function queueSharedGatewayAuthDisconnect(
});
}
function queueSharedGatewayAuthGenerationRefresh(
shouldRefresh: boolean,
nextConfig: OpenClawConfig,
context?: GatewayRequestContext,
): void {
if (!shouldRefresh) {
return;
}
queueMicrotask(() => {
context?.enforceSharedGatewayAuthGenerationForConfigWrite?.(nextConfig);
});
}
function summarizeConfigValidationIssues(issues: ReadonlyArray<ConfigValidationIssue>): string {
const trimmed = issues.slice(0, MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE);
const lines = formatConfigIssueLines(trimmed, "", { normalizeRoot: true })
@@ -421,7 +434,7 @@ export const configHandlers: GatewayRequestHandlers = {
}
respond(true, result, undefined);
},
"config.set": async ({ params, respond }) => {
"config.set": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateConfigSetParams, "config.set", respond)) {
return;
}
@@ -446,6 +459,7 @@ export const configHandlers: GatewayRequestHandlers = {
},
undefined,
);
queueSharedGatewayAuthGenerationRefresh(true, parsed.config, context);
},
"config.patch": async ({ params, respond, client, context }) => {
if (!assertValidParams(params, validateConfigPatchParams, "config.patch", respond)) {
@@ -602,6 +616,7 @@ export const configHandlers: GatewayRequestHandlers = {
},
undefined,
);
queueSharedGatewayAuthGenerationRefresh(true, validated.config, context);
queueSharedGatewayAuthDisconnect(disconnectSharedAuthClients, context);
},
"config.apply": async ({ params, respond, client, context }) => {
@@ -674,6 +689,7 @@ export const configHandlers: GatewayRequestHandlers = {
},
undefined,
);
queueSharedGatewayAuthGenerationRefresh(true, parsed.config, context);
queueSharedGatewayAuthDisconnect(disconnectSharedAuthClients, context);
},
"config.openFile": async ({ params, respond, context }) => {

View File

@@ -1,6 +1,7 @@
import type { ModelCatalogEntry } from "../../agents/model-catalog.js";
import type { createDefaultDeps } from "../../cli/deps.js";
import type { HealthSummary } from "../../commands/health.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { CronService } from "../../cron/service.js";
import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js";
import type { createSubsystemLogger } from "../../logging/subsystem.js";
@@ -59,6 +60,7 @@ export type GatewayRequestContext = {
hasExecApprovalClients?: (excludeConnId?: string) => boolean;
disconnectClientsForDevice?: (deviceId: string, opts?: { role?: string }) => void;
disconnectClientsUsingSharedGatewayAuth?: () => void;
enforceSharedGatewayAuthGenerationForConfigWrite?: (nextConfig: OpenClawConfig) => void;
nodeRegistry: NodeRegistry;
agentRunSeq: Map<string, number>;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;

View File

@@ -153,7 +153,7 @@ export function createGatewayReloadHandlers(params: {
const requestGatewayRestart = (
plan: GatewayReloadPlan,
nextConfig: ReturnType<typeof loadConfig>,
) => {
): boolean => {
setGatewaySigusr1RestartPolicy({ allowExternal: isRestartEnabled(nextConfig) });
const reasons = plan.restartReasons.length
? plan.restartReasons.join(", ")
@@ -161,7 +161,7 @@ export function createGatewayReloadHandlers(params: {
if (process.listenerCount("SIGUSR1") === 0) {
params.logReload.warn("no SIGUSR1 listener found; restart skipped");
return;
return false;
}
const getActiveCounts = () => {
@@ -201,7 +201,7 @@ export function createGatewayReloadHandlers(params: {
params.logReload.info(
`config change requires gateway restart (${reasons}) — already waiting for operations to complete`,
);
return;
return true;
}
restartPending = true;
const initialDetails = formatActiveDetails(active);
@@ -232,6 +232,7 @@ export function createGatewayReloadHandlers(params: {
},
},
});
return true;
} else {
// No active operations or pending replies, restart immediately
params.logReload.warn(`config change requires gateway restart (${reasons})`);
@@ -239,6 +240,7 @@ export function createGatewayReloadHandlers(params: {
if (!emitted) {
params.logReload.info("gateway restart already scheduled; skipping duplicate signal");
}
return true;
}
};

View File

@@ -32,6 +32,7 @@ export function attachGatewayWsHandlers(params: GatewayWsRuntimeParams) {
canvasHostServerPort: params.canvasHostServerPort,
resolvedAuth: params.resolvedAuth,
getResolvedAuth: params.getResolvedAuth,
getRequiredSharedGatewaySessionGeneration: params.getRequiredSharedGatewaySessionGeneration,
rateLimiter: params.rateLimiter,
browserRateLimiter: params.browserRateLimiter,
gatewayMethods: params.gatewayMethods,

View File

@@ -84,7 +84,7 @@ import { runSetupWizard } from "../wizard/setup.js";
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
import { resolveGatewayAuth } from "./auth.js";
import { startChannelHealthMonitor } from "./channel-health-monitor.js";
import { startGatewayConfigReloader } from "./config-reload.js";
import { resolveGatewayReloadSettings, startGatewayConfigReloader } from "./config-reload.js";
import type { ControlUiRootState } from "./control-ui.js";
import {
GATEWAY_EVENT_UPDATE_AVAILABLE,
@@ -140,6 +140,7 @@ import {
import { resolveHookClientIpConfig } from "./server/hooks.js";
import { createReadinessChecker } from "./server/readiness.js";
import { loadGatewayTlsRuntime } from "./server/tls.js";
import { resolveSharedGatewaySessionGeneration } from "./server/ws-shared-generation.js";
import { resolveSessionKeyForTranscriptFile } from "./session-transcript-key.js";
import {
attachOpenClawTranscriptMeta,
@@ -676,6 +677,32 @@ export async function startGatewayServer(
env: process.env,
tailscaleMode,
});
const resolveSharedGatewaySessionGenerationForConfig = (config: OpenClawConfig) =>
resolveSharedGatewaySessionGeneration(
resolveGatewayAuth({
authConfig: config.gateway?.auth,
authOverride: opts.auth,
env: process.env,
tailscaleMode,
}),
);
const resolveCurrentSharedGatewaySessionGeneration = () =>
resolveSharedGatewaySessionGeneration(getResolvedAuth());
const resolveSharedGatewaySessionGenerationForRuntimeSnapshot = () =>
resolveSharedGatewaySessionGeneration(
resolveGatewayAuth({
authConfig: getRuntimeConfig().gateway?.auth,
authOverride: opts.auth,
env: process.env,
tailscaleMode,
}),
);
let currentSharedGatewaySessionGeneration = resolveCurrentSharedGatewaySessionGeneration();
let requiredSharedGatewaySessionGeneration: string | undefined | null = null;
const getRequiredSharedGatewaySessionGeneration = () =>
requiredSharedGatewaySessionGeneration === null
? currentSharedGatewaySessionGeneration
: requiredSharedGatewaySessionGeneration;
let hooksConfig = runtimeConfig.hooksConfig;
let hookClientIpConfig = resolveHookClientIpConfig(cfgAtStart);
const canvasHostEnabled = runtimeConfig.canvasHostEnabled;
@@ -801,6 +828,46 @@ export async function startGatewayServer(
logPlugins,
getReadiness,
});
const disconnectStaleSharedGatewayAuthClients = (expectedGeneration: string | undefined) => {
for (const gatewayClient of clients) {
if (!gatewayClient.usesSharedGatewayAuth) {
continue;
}
if (gatewayClient.sharedGatewaySessionGeneration === expectedGeneration) {
continue;
}
try {
gatewayClient.socket.close(4001, "gateway auth changed");
} catch {
/* ignore */
}
}
};
const setCurrentSharedGatewaySessionGeneration = (nextGeneration: string | undefined) => {
const previousGeneration = currentSharedGatewaySessionGeneration;
currentSharedGatewaySessionGeneration = nextGeneration;
if (requiredSharedGatewaySessionGeneration === nextGeneration) {
requiredSharedGatewaySessionGeneration = null;
return;
}
if (requiredSharedGatewaySessionGeneration !== null && previousGeneration !== nextGeneration) {
requiredSharedGatewaySessionGeneration = null;
}
};
const enforceSharedGatewaySessionGenerationForConfigWrite = (nextConfig: OpenClawConfig) => {
const reloadMode = resolveGatewayReloadSettings(nextConfig).mode;
const nextSharedGatewaySessionGeneration =
resolveSharedGatewaySessionGenerationForRuntimeSnapshot();
if (reloadMode === "off") {
currentSharedGatewaySessionGeneration = nextSharedGatewaySessionGeneration;
requiredSharedGatewaySessionGeneration = nextSharedGatewaySessionGeneration;
disconnectStaleSharedGatewayAuthClients(nextSharedGatewaySessionGeneration);
return;
}
requiredSharedGatewaySessionGeneration = null;
setCurrentSharedGatewaySessionGeneration(nextSharedGatewaySessionGeneration);
disconnectStaleSharedGatewayAuthClients(nextSharedGatewaySessionGeneration);
};
let bonjourStop: (() => Promise<void>) | null = null;
const noopInterval = () => setInterval(() => {}, 1 << 30);
let tickInterval = noopInterval();
@@ -1241,10 +1308,18 @@ export async function startGatewayServer(
if (!active) {
throw new Error("Secrets runtime snapshot is not active.");
}
const previousSharedGatewaySessionGeneration = currentSharedGatewaySessionGeneration;
const prepared = await activateRuntimeSecrets(active.sourceConfig, {
reason: "reload",
activate: true,
});
const nextSharedGatewaySessionGeneration = resolveSharedGatewaySessionGenerationForConfig(
prepared.config,
);
setCurrentSharedGatewaySessionGeneration(nextSharedGatewaySessionGeneration);
if (previousSharedGatewaySessionGeneration !== nextSharedGatewaySessionGeneration) {
disconnectStaleSharedGatewayAuthClients(nextSharedGatewaySessionGeneration);
}
return { warningCount: prepared.warnings.length };
},
resolveSecrets: async ({ commandName, targetIds }) => {
@@ -1326,6 +1401,9 @@ export async function startGatewayServer(
}
}
},
enforceSharedGatewayAuthGenerationForConfigWrite: (nextConfig: OpenClawConfig) => {
enforceSharedGatewaySessionGenerationForConfigWrite(nextConfig);
},
nodeRegistry,
agentRunSeq,
chatAbortControllers,
@@ -1372,6 +1450,7 @@ export async function startGatewayServer(
canvasHostServerPort,
resolvedAuth,
getResolvedAuth,
getRequiredSharedGatewaySessionGeneration,
rateLimiter: authRateLimiter,
browserRateLimiter: browserAuthRateLimiter,
gatewayMethods,
@@ -1507,11 +1586,25 @@ export async function startGatewayServer(
readSnapshot: readConfigFileSnapshot,
subscribeToWrites: registerConfigWriteListener,
onHotReload: async (plan, nextConfig) => {
const previousSharedGatewaySessionGeneration = currentSharedGatewaySessionGeneration;
const previousSnapshot = getActiveSecretsRuntimeSnapshot();
const prepared = await activateRuntimeSecrets(nextConfig, {
reason: "reload",
activate: true,
});
const nextSharedGatewaySessionGeneration =
resolveSharedGatewaySessionGenerationForConfig(prepared.config);
// activateRuntimeSecrets(..., { activate: true }) can make getResolvedAuth()
// observe the rotated secret before applyHotReload settles; advance current
// generation now so fresh reconnects are not rejected during that window.
currentSharedGatewaySessionGeneration = nextSharedGatewaySessionGeneration;
const sharedGatewaySessionGenerationChanged =
previousSharedGatewaySessionGeneration !== nextSharedGatewaySessionGeneration;
if (sharedGatewaySessionGenerationChanged) {
// Close stale shared-auth sockets before potentially long reload work so old
// sessions cannot continue receiving broadcasts while auth has rotated.
disconnectStaleSharedGatewayAuthClients(nextSharedGatewaySessionGeneration);
}
try {
await applyHotReload(plan, prepared.config);
} catch (err) {
@@ -1520,15 +1613,56 @@ export async function startGatewayServer(
} else {
clearSecretsRuntimeSnapshot();
}
currentSharedGatewaySessionGeneration = previousSharedGatewaySessionGeneration;
if (sharedGatewaySessionGenerationChanged) {
// Rollback may have allowed reconnects on the transient new generation;
// close them immediately so passive sockets cannot linger after revert.
disconnectStaleSharedGatewayAuthClients(previousSharedGatewaySessionGeneration);
}
throw err;
}
setCurrentSharedGatewaySessionGeneration(nextSharedGatewaySessionGeneration);
},
onRestart: async (plan, nextConfig) => {
await activateRuntimeSecrets(nextConfig, {
reason: "restart-check",
activate: false,
});
requestGatewayRestart(plan, nextConfig);
const previousRequiredSharedGatewaySessionGeneration =
requiredSharedGatewaySessionGeneration;
const previousSharedGatewaySessionGeneration = currentSharedGatewaySessionGeneration;
// Restart checks run with activate:false, so enforce invalidation
// only after SecretRefs are resolved from prepared.config.
try {
const prepared = await activateRuntimeSecrets(nextConfig, {
reason: "restart-check",
activate: false,
});
const nextSharedGatewaySessionGeneration =
resolveSharedGatewaySessionGenerationForConfig(prepared.config);
const restartQueued = requestGatewayRestart(plan, nextConfig);
if (!restartQueued) {
if (
previousSharedGatewaySessionGeneration !== nextSharedGatewaySessionGeneration
) {
// If restart is unavailable, activate the resolved secrets snapshot so
// token/password auth accepts the rotated secret instead of lockout.
activateSecretsRuntimeSnapshot(prepared);
setCurrentSharedGatewaySessionGeneration(nextSharedGatewaySessionGeneration);
requiredSharedGatewaySessionGeneration = null;
disconnectStaleSharedGatewayAuthClients(nextSharedGatewaySessionGeneration);
} else {
requiredSharedGatewaySessionGeneration = null;
}
return;
}
if (previousSharedGatewaySessionGeneration !== nextSharedGatewaySessionGeneration) {
requiredSharedGatewaySessionGeneration = nextSharedGatewaySessionGeneration;
disconnectStaleSharedGatewayAuthClients(nextSharedGatewaySessionGeneration);
} else {
requiredSharedGatewaySessionGeneration = null;
}
} catch (error) {
requiredSharedGatewaySessionGeneration =
previousRequiredSharedGatewaySessionGeneration;
throw error;
}
},
log: {
info: (msg) => logReload.info(msg),

View File

@@ -0,0 +1,131 @@
import fs from "node:fs/promises";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import {
connectOk,
getFreePort,
installGatewayTestHooks,
rpcReq,
startGatewayServer,
testState,
trackConnectChallengeNonce,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
const ORIGINAL_GATEWAY_AUTH = testState.gatewayAuth;
const SECRET_REF_TOKEN_ID = "OPENCLAW_SHARED_TOKEN_HOT_RELOAD_SECRET_REF";
const OLD_TOKEN = "shared-token-hot-reload-old";
const NEW_TOKEN = "shared-token-hot-reload-new";
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
function toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function bumpReloadDebounce(config: Record<string, unknown>): Record<string, unknown> {
const next = structuredClone(config);
const gateway = { ...toRecord(next.gateway) };
const reload = { ...toRecord(gateway.reload) };
const debounceMsRaw = reload.debounceMs;
const debounceMsCurrent =
typeof debounceMsRaw === "number" && Number.isFinite(debounceMsRaw) ? debounceMsRaw : 0;
reload.debounceMs = debounceMsCurrent + 1;
gateway.reload = reload;
next.gateway = gateway;
return next;
}
async function openAuthenticatedWs(token: string): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws, { token });
return ws;
}
async function waitForClose(ws: WebSocket): Promise<{ code: number; reason: string }> {
return await new Promise((resolve) => {
ws.once("close", (code, reason) => {
resolve({ code, reason: reason.toString() });
});
});
}
async function loadCurrentConfig(ws: WebSocket): Promise<Record<string, unknown>> {
const current = await rpcReq<{
config?: Record<string, unknown>;
}>(ws, "config.get", {});
expect(current.ok).toBe(true);
return structuredClone(current.payload?.config ?? {});
}
beforeAll(async () => {
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
throw new Error("OPENCLAW_CONFIG_PATH missing in gateway test environment");
}
port = await getFreePort();
testState.gatewayAuth = undefined;
process.env[SECRET_REF_TOKEN_ID] = OLD_TOKEN;
await fs.writeFile(
configPath,
`${JSON.stringify(
{
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: SECRET_REF_TOKEN_ID },
},
reload: {
mode: "hybrid",
debounceMs: 0,
},
},
},
null,
2,
)}\n`,
"utf-8",
);
server = await startGatewayServer(port, { controlUiEnabled: true });
});
beforeEach(() => {
process.env[SECRET_REF_TOKEN_ID] = OLD_TOKEN;
});
afterAll(async () => {
delete process.env[SECRET_REF_TOKEN_ID];
testState.gatewayAuth = ORIGINAL_GATEWAY_AUTH;
await server.close();
});
describe("gateway shared token hot reload rotation", () => {
it("disconnects existing shared-token websocket sessions after hot reload picks up a rotated SecretRef value", async () => {
const ws = await openAuthenticatedWs(OLD_TOKEN);
try {
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
throw new Error("OPENCLAW_CONFIG_PATH missing in gateway test environment");
}
const currentConfig = await loadCurrentConfig(ws);
const nextConfig = bumpReloadDebounce(currentConfig);
process.env[SECRET_REF_TOKEN_ID] = NEW_TOKEN;
const closed = waitForClose(ws);
await fs.writeFile(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf-8");
await expect(closed).resolves.toMatchObject({
code: 4001,
reason: "gateway auth changed",
});
} finally {
ws.close();
}
});
});

View File

@@ -0,0 +1,130 @@
import fs from "node:fs/promises";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import {
connectOk,
getFreePort,
installGatewayTestHooks,
rpcReq,
startGatewayServer,
testState,
trackConnectChallengeNonce,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
const ORIGINAL_GATEWAY_AUTH = testState.gatewayAuth;
const OLD_TOKEN = "shared-token-session-old";
const NEW_TOKEN = "shared-token-session-new";
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
beforeAll(async () => {
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
throw new Error("OPENCLAW_CONFIG_PATH missing in gateway test environment");
}
port = await getFreePort();
testState.gatewayAuth = undefined;
await fs.writeFile(
configPath,
`${JSON.stringify(
{
gateway: {
auth: {
mode: "token",
token: OLD_TOKEN,
},
reload: {
mode: "off",
},
},
},
null,
2,
)}\n`,
"utf-8",
);
server = await startGatewayServer(port, { controlUiEnabled: true });
});
afterAll(async () => {
testState.gatewayAuth = ORIGINAL_GATEWAY_AUTH;
await server.close();
});
function toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function buildConfigSetWithRotatedToken(config: Record<string, unknown>): Record<string, unknown> {
const next = structuredClone(config);
const gateway = { ...toRecord(next.gateway) };
const auth = { ...toRecord(gateway.auth), mode: "token", token: NEW_TOKEN };
const reload = { ...toRecord(gateway.reload), mode: "off" };
gateway.auth = auth;
gateway.reload = reload;
next.gateway = gateway;
return next;
}
async function openAuthenticatedWs(token: string): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws, { token });
return ws;
}
async function waitForClose(ws: WebSocket): Promise<{ code: number; reason: string }> {
return await new Promise((resolve) => {
ws.once("close", (code, reason) => {
resolve({ code, reason: reason.toString() });
});
});
}
async function loadCurrentConfig(ws: WebSocket): Promise<{
hash: string;
config: Record<string, unknown>;
}> {
const current = await rpcReq<{
hash?: string;
config?: Record<string, unknown>;
}>(ws, "config.get", {});
expect(current.ok).toBe(true);
expect(typeof current.payload?.hash).toBe("string");
return {
hash: String(current.payload?.hash),
config: structuredClone(current.payload?.config ?? {}),
};
}
describe("gateway shared token session rotation", () => {
it("invalidates shared-token websocket sessions after config.set rotation even with reload mode off", async () => {
const ws = await openAuthenticatedWs(OLD_TOKEN);
try {
const current = await loadCurrentConfig(ws);
const nextConfig = buildConfigSetWithRotatedToken(current.config);
const closed = waitForClose(ws);
const setRes = await rpcReq(ws, "config.set", {
baseHash: current.hash,
raw: JSON.stringify(nextConfig, null, 2),
});
expect(setRes.ok).toBe(true);
await expect(rpcReq(ws, "config.get", {})).rejects.toThrow(
"closed 4001: gateway auth changed",
);
await expect(closed).resolves.toMatchObject({
code: 4001,
reason: "gateway auth changed",
});
} finally {
ws.close();
}
});
});

View File

@@ -20,6 +20,7 @@ import {
attachGatewayWsMessageHandler,
type WsOriginCheckMetrics,
} from "./ws-connection/message-handler.js";
import { resolveSharedGatewaySessionGeneration } from "./ws-shared-generation.js";
import type { GatewayWsClient } from "./ws-types.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
@@ -69,6 +70,7 @@ export type GatewayWsSharedHandlerParams = {
canvasHostServerPort?: number;
resolvedAuth: ResolvedGatewayAuth;
getResolvedAuth?: () => ResolvedGatewayAuth;
getRequiredSharedGatewaySessionGeneration?: () => string | undefined;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
/** Browser-origin fallback limiter (loopback is never exempt). */
@@ -104,6 +106,8 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
canvasHostServerPort,
resolvedAuth,
getResolvedAuth = () => resolvedAuth,
getRequiredSharedGatewaySessionGeneration = () =>
resolveSharedGatewaySessionGeneration(getResolvedAuth()),
rateLimiter,
browserRateLimiter,
gatewayMethods,
@@ -316,6 +320,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
canvasHostUrl,
connectNonce,
getResolvedAuth,
getRequiredSharedGatewaySessionGeneration,
rateLimiter,
browserRateLimiter,
gatewayMethods,

View File

@@ -99,6 +99,7 @@ import {
incrementPresenceVersion,
refreshGatewayHealthSnapshot,
} from "../health-state.js";
import { resolveSharedGatewaySessionGeneration } from "../ws-shared-generation.js";
import type { GatewayWsClient } from "../ws-types.js";
import { resolveConnectAuthDecision, resolveConnectAuthState } from "./auth-context.js";
import { formatGatewayAuthFailureMessage } from "./auth-messages.js";
@@ -167,6 +168,7 @@ export function attachGatewayWsMessageHandler(params: {
canvasHostUrl?: string;
connectNonce: string;
getResolvedAuth: () => ResolvedGatewayAuth;
getRequiredSharedGatewaySessionGeneration: () => string | undefined;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
/** Browser-origin fallback limiter (loopback is never exempt). */
@@ -202,6 +204,7 @@ export function attachGatewayWsMessageHandler(params: {
canvasHostUrl,
connectNonce,
getResolvedAuth,
getRequiredSharedGatewaySessionGeneration,
rateLimiter,
browserRateLimiter,
gatewayMethods,
@@ -719,6 +722,21 @@ export function attachGatewayWsMessageHandler(params: {
rejectUnauthorized(authResult);
return;
}
const sharedGatewaySessionGeneration =
authMethod === "token" || authMethod === "password"
? resolveSharedGatewaySessionGeneration(resolvedAuth)
: undefined;
if (authMethod === "token" || authMethod === "password") {
const requiredSharedGatewaySessionGeneration =
getRequiredSharedGatewaySessionGeneration();
if (sharedGatewaySessionGeneration !== requiredSharedGatewaySessionGeneration) {
setCloseCause("gateway-auth-rotated", {
authGenerationStale: true,
});
close(4001, "gateway auth changed");
return;
}
}
const issuedBootstrapProfile =
authMethod === "bootstrap-token" && bootstrapTokenCandidate
? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate })
@@ -1217,6 +1235,7 @@ export function attachGatewayWsMessageHandler(params: {
connect: connectParams,
connId,
usesSharedGatewayAuth: authMethod === "token" || authMethod === "password",
sharedGatewaySessionGeneration,
presenceKey,
clientIp: reportedClientIp,
canvasHostUrl,
@@ -1330,6 +1349,17 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
if (client.usesSharedGatewayAuth) {
const requiredSharedGatewaySessionGeneration = getRequiredSharedGatewaySessionGeneration();
if (client.sharedGatewaySessionGeneration !== requiredSharedGatewaySessionGeneration) {
setCloseCause("gateway-auth-rotated", {
authGenerationStale: true,
});
close(4001, "gateway auth changed");
return;
}
}
// After handshake, accept only req frames
if (!validateRequestFrame(parsed)) {
send({

View File

@@ -0,0 +1,31 @@
import { createHash } from "node:crypto";
import type { ResolvedGatewayAuth } from "../auth.js";
function resolveSharedSecret(
auth: ResolvedGatewayAuth,
): { mode: "token" | "password"; secret: string } | null {
// trim() is only a blank-value guard; generation must hash the exact raw secret bytes.
if (auth.mode === "token" && typeof auth.token === "string" && auth.token.trim().length > 0) {
return { mode: "token", secret: auth.token };
}
if (
auth.mode === "password" &&
typeof auth.password === "string" &&
auth.password.trim().length > 0
) {
return { mode: "password", secret: auth.password };
}
return null;
}
export function resolveSharedGatewaySessionGeneration(
auth: ResolvedGatewayAuth,
): string | undefined {
const shared = resolveSharedSecret(auth);
if (!shared) {
return undefined;
}
return createHash("sha256")
.update(`${shared.mode}\u0000${shared.secret}`, "utf8")
.digest("base64url");
}

View File

@@ -6,6 +6,7 @@ export type GatewayWsClient = {
connect: ConnectParams;
connId: string;
usesSharedGatewayAuth: boolean;
sharedGatewaySessionGeneration?: string;
presenceKey?: string;
clientIp?: string;
canvasHostUrl?: string;