mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 23:00:22 +00:00
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:
@@ -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 }) => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
131
src/gateway/server.shared-token-hot-reload.test.ts
Normal file
131
src/gateway/server.shared-token-hot-reload.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
130
src/gateway/server.shared-token-session-rotation.test.ts
Normal file
130
src/gateway/server.shared-token-session-rotation.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
31
src/gateway/server/ws-shared-generation.ts
Normal file
31
src/gateway/server/ws-shared-generation.ts
Normal 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");
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export type GatewayWsClient = {
|
||||
connect: ConnectParams;
|
||||
connId: string;
|
||||
usesSharedGatewayAuth: boolean;
|
||||
sharedGatewaySessionGeneration?: string;
|
||||
presenceKey?: string;
|
||||
clientIp?: string;
|
||||
canvasHostUrl?: string;
|
||||
|
||||
Reference in New Issue
Block a user