diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d09bb8e39b..0585fae89ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Providers/Anthropic Vertex: restore ADC-backed model discovery after the lightweight provider-discovery path by resolving emitted discovery entries, exposing synthetic auth on bootstrap discovery, and honoring copied env snapshots when probing the default GCP ADC path. Fixes #65715. (#65716) Thanks @feiskyer. - Codex harness/status: pin embedded harness selection per session, show active non-PI harness ids such as `codex` in `/status`, and keep legacy transcripts on PI until `/new` or `/reset` so config changes cannot hot-switch existing sessions. - Gateway/security: fail closed on agent-driven `gateway config.apply`/`config.patch` runtime edits by allowlisting a narrow set of agent-tunable prompt, model, and mention-gating paths (including Telegram topic-level `requireMention`) instead of relying on a hand-maintained denylist of protected subtrees that could miss new sensitive config keys. (#70726) Thanks @drobison00. +- Webhooks/security: re-resolve `SecretRef`-backed webhook route secrets on each request so `openclaw secrets reload` revokes the previous secret immediately instead of waiting for a gateway restart. (#70727) Thanks @drobison00. ## 2026.4.22 diff --git a/extensions/webhooks/src/http.test.ts b/extensions/webhooks/src/http.test.ts index 3dad0138bdd..34ff6bd6308 100644 --- a/extensions/webhooks/src/http.test.ts +++ b/extensions/webhooks/src/http.test.ts @@ -158,9 +158,10 @@ describe("createTaskFlowWebhookRequestHandler", () => { expect(res.statusCode).toBe(401); expect(res.body).toBe("unauthorized"); expect(target.taskFlow.list()).toEqual([]); + expect(hoisted.resolveConfiguredSecretInputStringMock).not.toHaveBeenCalled(); }); - it("caches SecretRef resolution across requests for the same route", async () => { + it("re-resolves SecretRef-backed secrets across requests", async () => { const runtime = createRuntimeTaskFlow(); const target: TaskFlowWebhookTarget = { routeId: "cached", @@ -176,7 +177,10 @@ describe("createTaskFlowWebhookRequestHandler", () => { sessionKey: "agent:main:webhook-cached", }), }; - hoisted.resolveConfiguredSecretInputStringMock.mockResolvedValue({ value: "shared-secret" }); + hoisted.resolveConfiguredSecretInputStringMock + .mockResolvedValueOnce({ value: "shared-secret" }) + .mockResolvedValueOnce({ value: "rotated-secret" }) + .mockResolvedValueOnce({ value: "rotated-secret" }); const handler = createHandlerWithTarget(target); const first = await dispatchJsonRequest({ @@ -195,10 +199,20 @@ describe("createTaskFlowWebhookRequestHandler", () => { action: "list_flows", }, }); + const third = await dispatchJsonRequest({ + handler, + path: target.path, + secret: "rotated-secret", + body: { + action: "list_flows", + }, + }); expect(first.statusCode).toBe(200); - expect(second.statusCode).toBe(200); - expect(hoisted.resolveConfiguredSecretInputStringMock).toHaveBeenCalledTimes(1); + expect(second.statusCode).toBe(401); + expect(second.body).toBe("unauthorized"); + expect(third.statusCode).toBe(200); + expect(hoisted.resolveConfiguredSecretInputStringMock).toHaveBeenCalledTimes(3); }); it("creates flows through the bound session and scrubs owner metadata from responses", async () => { diff --git a/extensions/webhooks/src/http.ts b/extensions/webhooks/src/http.ts index 365fc5767c5..98ab0e3c2c0 100644 --- a/extensions/webhooks/src/http.ts +++ b/extensions/webhooks/src/http.ts @@ -667,7 +667,6 @@ export function createTaskFlowWebhookRequestHandler(params: { targetsByPath: Map; inFlightLimiter?: WebhookInFlightLimiter; }): (req: IncomingMessage, res: ServerResponse) => Promise { - const secretByTarget = new WeakMap>(); const rateLimiter = createFixedWindowRateLimiter({ windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests, @@ -679,19 +678,19 @@ export function createTaskFlowWebhookRequestHandler(params: { maxInFlightPerKey: WEBHOOK_IN_FLIGHT_DEFAULTS.maxInFlightPerKey, maxTrackedKeys: WEBHOOK_IN_FLIGHT_DEFAULTS.maxTrackedKeys, }); - const resolveTargetSecret = (target: TaskFlowWebhookTarget): Promise => { - const cached = secretByTarget.get(target); - if (cached) { - return cached; + const resolveTargetSecret = async ( + target: TaskFlowWebhookTarget, + ): Promise => { + if (typeof target.secretInput === "string") { + return target.secretInput; } - const pending = resolveConfiguredSecretInputString({ + const resolved = await resolveConfiguredSecretInputString({ config: params.cfg, env: process.env, value: target.secretInput, path: target.secretConfigPath, - }).then((resolved) => resolved.value); - secretByTarget.set(target, pending); - return pending; + }); + return resolved.value; }; return async (req: IncomingMessage, res: ServerResponse): Promise => {