diff --git a/CHANGELOG.md b/CHANGELOG.md index a2212c281c5..c5232105140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Setup/providers: guard preferred-provider lookup during setup so malformed plugin metadata with a missing provider id no longer crashes the wizard with `Cannot read properties of undefined (reading 'trim')`. (#66649) Thanks @Tianworld. - Matrix/security: normalize sandboxed profile avatar params, preserve `mxc://` avatar URLs, and surface gmail watcher stop failures during reload. (#64701) Thanks @slepybear. - Telegram/documents: drop leaked binary caption bytes from inbound Telegram text handling so document uploads like `.mobi` or `.epub` no longer explode prompt token counts. (#66663) Thanks @joelnishanth. +- Gateway/auth: resolve the active gateway bearer per-request on the HTTP server and the HTTP upgrade handler via `getResolvedAuth()`, mirroring the WebSocket path, so a secret rotated through `secrets.reload` or config hot-reload stops authenticating on `/v1/*`, `/tools/invoke`, plugin HTTP routes, and the canvas upgrade path immediately instead of remaining valid on HTTP until gateway restart. (#66651) Thanks @mmaps. ## 2026.4.14 diff --git a/src/gateway/server-http.probe.test.ts b/src/gateway/server-http.probe.test.ts index 7a08915987f..aced4ec3ae5 100644 --- a/src/gateway/server-http.probe.test.ts +++ b/src/gateway/server-http.probe.test.ts @@ -113,6 +113,65 @@ describe("gateway probe endpoints", () => { }); }); + it("re-resolves auth for remote /ready requests after shared auth rotation", async () => { + const getReadiness: ReadinessChecker = () => ({ + ready: false, + failing: ["discord", "telegram"], + uptimeMs: 8_000, + }); + let currentAuth = AUTH_TOKEN; + + await withGatewayServer({ + prefix: "probe-remote-rotated-auth", + // `resolvedAuth` remains the static fallback; `getResolvedAuth` drives the rotated value. + resolvedAuth: AUTH_TOKEN, + overrides: { + getReadiness, + getResolvedAuth: () => currentAuth, + }, + run: async (server) => { + const sendReady = async (authorization: string) => { + const req = createRequest({ + path: "/ready", + remoteAddress: "10.0.0.8", + host: "gateway.test", + authorization, + }); + const { res, getBody } = createResponse(); + await dispatchRequest(server, req, res); + return { statusCode: res.statusCode, body: JSON.parse(getBody()) }; + }; + + await expect(sendReady("Bearer test-token")).resolves.toEqual({ + statusCode: 503, + body: { + ready: false, + failing: ["discord", "telegram"], + uptimeMs: 8_000, + }, + }); + + currentAuth = { + ...AUTH_TOKEN, + token: "rotated-token", + }; + + await expect(sendReady("Bearer test-token")).resolves.toEqual({ + statusCode: 503, + body: { ready: false }, + }); + await expect(sendReady("Bearer rotated-token")).resolves.toEqual({ + statusCode: 503, + body: { + ready: false, + failing: ["discord", "telegram"], + uptimeMs: 8_000, + }, + }); + }, + }); + }); + it("hides readiness details when trusted-proxy auth violates browser origin policy", async () => { const getReadiness: ReadinessChecker = () => ({ ready: false, diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index a86350c3929..41b04f64445 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -838,6 +838,7 @@ export function createGatewayHttpServer(opts: { handlePluginRequest?: PluginHttpRequestHandler; shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean; resolvedAuth: ResolvedGatewayAuth; + getResolvedAuth?: () => ResolvedGatewayAuth; /** Optional rate limiter for auth brute-force protection. */ rateLimiter?: AuthRateLimiter; getReadiness?: ReadinessChecker; @@ -861,6 +862,7 @@ export function createGatewayHttpServer(opts: { rateLimiter, getReadiness, } = opts; + const getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth); const openAiCompatEnabled = openAiChatCompletionsEnabled || openResponsesEnabled; const httpServer: HttpServer = opts.tlsOptions ? createHttpsServer(opts.tlsOptions, (req, res) => { @@ -896,6 +898,7 @@ export function createGatewayHttpServer(opts: { const pluginPathContext = handlePluginRequest ? resolvePluginRoutePathContext(requestPath) : null; + const resolvedAuth = getResolvedAuth(); const requestStages: GatewayHttpRequestStage[] = [ { name: "hooks", @@ -1117,6 +1120,7 @@ export function attachGatewayUpgradeHandler(opts: { clients: Set; preauthConnectionBudget: PreauthConnectionBudget; resolvedAuth: ResolvedGatewayAuth; + getResolvedAuth?: () => ResolvedGatewayAuth; /** Optional rate limiter for auth brute-force protection. */ rateLimiter?: AuthRateLimiter; }) { @@ -1129,6 +1133,7 @@ export function attachGatewayUpgradeHandler(opts: { resolvedAuth, rateLimiter, } = opts; + const getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth); httpServer.on("upgrade", (req, socket, head) => { void (async () => { const configSnapshot = loadConfig(); @@ -1143,6 +1148,7 @@ export function attachGatewayUpgradeHandler(opts: { if (scopedCanvas.rewrittenUrl) { req.url = scopedCanvas.rewrittenUrl; } + const resolvedAuth = getResolvedAuth(); if (canvasHost) { const url = new URL(req.url ?? "/", "http://localhost"); if (url.pathname === CANVAS_WS_PATH) { diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 9d5949c434e..2058636c526 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -61,6 +61,7 @@ export async function createGatewayRuntimeState(params: { openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig; strictTransportSecurityHeader?: string; resolvedAuth: ResolvedGatewayAuth; + getResolvedAuth: () => ResolvedGatewayAuth; /** Optional rate limiter for auth brute-force protection. */ rateLimiter?: AuthRateLimiter; gatewayTls?: GatewayTlsRuntime; @@ -185,6 +186,7 @@ export async function createGatewayRuntimeState(params: { handlePluginRequest, shouldEnforcePluginGatewayAuth, resolvedAuth: params.resolvedAuth, + getResolvedAuth: params.getResolvedAuth, rateLimiter: params.rateLimiter, getReadiness: params.getReadiness, tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, @@ -224,6 +226,7 @@ export async function createGatewayRuntimeState(params: { clients, preauthConnectionBudget, resolvedAuth: params.resolvedAuth, + getResolvedAuth: params.getResolvedAuth, rateLimiter: params.rateLimiter, }); } diff --git a/src/gateway/server.canvas-auth.test.ts b/src/gateway/server.canvas-auth.test.ts index 86b5bc817ee..df779e8bb30 100644 --- a/src/gateway/server.canvas-auth.test.ts +++ b/src/gateway/server.canvas-auth.test.ts @@ -123,9 +123,9 @@ async function expectWsRejected( }); } -async function expectWsConnected(url: string): Promise { +async function expectWsConnected(url: string, headers?: Record): Promise { await new Promise((resolve, reject) => { - const ws = new WebSocket(url); + const ws = new WebSocket(url, headers ? { headers } : undefined); let settled = false; const finish = (fn: () => void) => { if (settled) { @@ -207,6 +207,7 @@ const allowCanvasHostHttp: CanvasHostHandler["handleHttpRequest"] = async (req, }; async function withCanvasGatewayHarness(params: { resolvedAuth: ResolvedGatewayAuth; + getResolvedAuth?: () => ResolvedGatewayAuth; listenHost?: string; rateLimiter?: ReturnType; handleHttpRequest: CanvasHostHandler["handleHttpRequest"]; @@ -241,6 +242,7 @@ async function withCanvasGatewayHarness(params: { openResponsesEnabled: false, handleHooksRequest: async () => false, resolvedAuth: params.resolvedAuth, + getResolvedAuth: params.getResolvedAuth, rateLimiter: params.rateLimiter, }); @@ -252,6 +254,7 @@ async function withCanvasGatewayHarness(params: { clients, preauthConnectionBudget: createPreauthConnectionBudget(8), resolvedAuth: params.resolvedAuth, + getResolvedAuth: params.getResolvedAuth, rateLimiter: params.rateLimiter, }); @@ -424,6 +427,35 @@ describe("gateway canvas host auth", () => { }); }, 60_000); + test("re-resolves canvas bearer auth on each upgrade after shared auth rotation", async () => { + let currentAuth = tokenResolvedAuth; + + await withCanvasGatewayHarness({ + resolvedAuth: tokenResolvedAuth, + getResolvedAuth: () => currentAuth, + handleHttpRequest: allowCanvasHostHttp, + run: async ({ listener }) => { + const url = `ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`; + + await expectWsConnected(url, { + authorization: "Bearer test-token", + }); + + currentAuth = { + ...tokenResolvedAuth, + token: "rotated-token", + }; + + await expectWsRejected(url, { + authorization: "Bearer test-token", + }); + await expectWsConnected(url, { + authorization: "Bearer rotated-token", + }); + }, + }); + }, 60_000); + test("accepts capability-scoped paths over IPv6 loopback", async () => { await withTempConfig({ cfg: { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 6e8fc9fe194..c99c48099ac 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -450,6 +450,7 @@ export async function startGatewayServer( resolvedAuth, rateLimiter: authRateLimiter, gatewayTls, + getResolvedAuth, hooksConfig: () => runtimeState?.hooksConfig ?? initialHooksConfig, getHookClientIpConfig: () => runtimeState?.hookClientIpConfig ?? initialHookClientIpConfig, pluginRegistry,