fix(webhooks): reload route secrets per request (#70727)

* fix(webhooks): reload route secrets per request

* docs(changelog): note webhook secret reload fix
This commit is contained in:
Devin Robison
2026-04-23 15:48:10 -06:00
committed by GitHub
parent e64da8bde0
commit 36c4a372a0
3 changed files with 27 additions and 13 deletions

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -667,7 +667,6 @@ export function createTaskFlowWebhookRequestHandler(params: {
targetsByPath: Map<string, TaskFlowWebhookTarget[]>;
inFlightLimiter?: WebhookInFlightLimiter;
}): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
const secretByTarget = new WeakMap<TaskFlowWebhookTarget, Promise<string | undefined>>();
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<string | undefined> => {
const cached = secretByTarget.get(target);
if (cached) {
return cached;
const resolveTargetSecret = async (
target: TaskFlowWebhookTarget,
): Promise<string | undefined> => {
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<boolean> => {