VYPR
Moderate severityNVD Advisory· Published Feb 21, 2026· Updated Feb 24, 2026

OpenClaw hardened cron webhook delivery against SSRF

CVE-2026-27488

Description

OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, Cron webhook delivery in src/gateway/server-cron.ts uses fetch() directly, so webhook targets can reach private/metadata/internal endpoints without SSRF policy checks. This issue was fixed in version 2026.2.19.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.192026.2.19

Affected products

1

Patches

1
99db4d13e5c1

fix(gateway): guard cron webhook delivery against SSRF

https://github.com/openclaw/openclawPeter SteinbergerFeb 18, 2026via ghsa
3 files changed · +100 17
  • CHANGELOG.md+1 0 modified
    @@ -113,6 +113,7 @@ Docs: https://docs.openclaw.ai
     - Cron announce injection now targets the session determined by delivery config (`to` + channel) instead of defaulting to the current session. Thanks @tyler6204.
     - Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07.
     - Cron/Webhooks: reuse existing session IDs for webhook/cron runs when the session key is stable and still fresh, preserving conversation history. (#18031) Thanks @Operative-001.
    +- Cron/Webhooks: protect cron webhook POST delivery with SSRF-guarded outbound fetch (`fetchWithSsrFGuard`) to block private/metadata destinations before request dispatch. Thanks @Adam55A-code.
     - Cron: prevent spin loops when cron jobs complete within the scheduled second by advancing the next run and enforcing a minimum refire gap. (#18073) Thanks @widingmarcus-cyber.
     - OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky.
     - iOS/Onboarding: add QR-first onboarding wizard with setup-code deep link support, pairing/auth issue guidance, and device-pair QR generation improvements for Telegram/Web/TUI fallback flows. (#18162) Thanks @mbelinky and @Marvae.
    
  • src/gateway/server-cron.test.ts+61 0 modified
    @@ -3,10 +3,12 @@ import path from "node:path";
     import { beforeEach, describe, expect, it, vi } from "vitest";
     import type { CliDeps } from "../cli/deps.js";
     import type { OpenClawConfig } from "../config/config.js";
    +import { SsrFBlockedError } from "../infra/net/ssrf.js";
     
     const enqueueSystemEventMock = vi.fn();
     const requestHeartbeatNowMock = vi.fn();
     const loadConfigMock = vi.fn();
    +const fetchWithSsrFGuardMock = vi.fn();
     
     vi.mock("../infra/system-events.js", () => ({
       enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
    @@ -24,13 +26,18 @@ vi.mock("../config/config.js", async () => {
       };
     });
     
    +vi.mock("../infra/net/fetch-guard.js", () => ({
    +  fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
    +}));
    +
     import { buildGatewayCronService } from "./server-cron.js";
     
     describe("buildGatewayCronService", () => {
       beforeEach(() => {
         enqueueSystemEventMock.mockReset();
         requestHeartbeatNowMock.mockReset();
         loadConfigMock.mockReset();
    +    fetchWithSsrFGuardMock.mockReset();
       });
     
       it("canonicalizes non-agent sessionKey to agent store key for enqueue + wake", async () => {
    @@ -78,4 +85,58 @@ describe("buildGatewayCronService", () => {
           state.cron.stop();
         }
       });
    +
    +  it("blocks private webhook URLs via SSRF-guarded fetch", async () => {
    +    const tmpDir = path.join(os.tmpdir(), `server-cron-ssrf-${Date.now()}`);
    +    const cfg = {
    +      session: {
    +        mainKey: "main",
    +      },
    +      cron: {
    +        store: path.join(tmpDir, "cron.json"),
    +      },
    +    } as OpenClawConfig;
    +
    +    loadConfigMock.mockReturnValue(cfg);
    +    fetchWithSsrFGuardMock.mockRejectedValue(
    +      new SsrFBlockedError("Blocked: private/internal IP address"),
    +    );
    +
    +    const state = buildGatewayCronService({
    +      cfg,
    +      deps: {} as CliDeps,
    +      broadcast: () => {},
    +    });
    +    try {
    +      const job = await state.cron.add({
    +        name: "ssrf-webhook-blocked",
    +        enabled: true,
    +        schedule: { kind: "at", at: new Date(1).toISOString() },
    +        sessionTarget: "main",
    +        wakeMode: "next-heartbeat",
    +        payload: { kind: "systemEvent", text: "hello" },
    +        delivery: {
    +          mode: "webhook",
    +          to: "http://127.0.0.1:8080/cron-finished",
    +        },
    +      });
    +
    +      await state.cron.run(job.id, "force");
    +
    +      expect(fetchWithSsrFGuardMock).toHaveBeenCalledOnce();
    +      expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({
    +        url: "http://127.0.0.1:8080/cron-finished",
    +        init: {
    +          method: "POST",
    +          headers: {
    +            "Content-Type": "application/json",
    +          },
    +          body: expect.stringContaining('"action":"finished"'),
    +          signal: expect.any(AbortSignal),
    +        },
    +      });
    +    } finally {
    +      state.cron.stop();
    +    }
    +  });
     });
    
  • src/gateway/server-cron.ts+38 17 modified
    @@ -12,8 +12,11 @@ import { appendCronRunLog, resolveCronRunLogPath } from "../cron/run-log.js";
     import { CronService } from "../cron/service.js";
     import { resolveCronStorePath } from "../cron/store.js";
     import { normalizeHttpWebhookUrl } from "../cron/webhook-url.js";
    +import { formatErrorMessage } from "../infra/errors.js";
     import { runHeartbeatOnce } from "../infra/heartbeat-runner.js";
     import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
    +import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
    +import { SsrFBlockedError } from "../infra/net/ssrf.js";
     import { enqueueSystemEvent } from "../infra/system-events.js";
     import { getChildLogger } from "../logging.js";
     import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
    @@ -243,25 +246,43 @@ export function buildGatewayCronService(params: {
               const timeout = setTimeout(() => {
                 abortController.abort();
               }, CRON_WEBHOOK_TIMEOUT_MS);
    -          void fetch(webhookTarget.url, {
    -            method: "POST",
    -            headers,
    -            body: JSON.stringify(evt),
    -            signal: abortController.signal,
    -          })
    -            .catch((err) => {
    -              cronLogger.warn(
    -                {
    -                  err: String(err),
    -                  jobId: evt.jobId,
    -                  webhookUrl: redactWebhookUrl(webhookTarget.url),
    +
    +          void (async () => {
    +            try {
    +              const result = await fetchWithSsrFGuard({
    +                url: webhookTarget.url,
    +                init: {
    +                  method: "POST",
    +                  headers,
    +                  body: JSON.stringify(evt),
    +                  signal: abortController.signal,
                     },
    -                "cron: webhook delivery failed",
    -              );
    -            })
    -            .finally(() => {
    +              });
    +              await result.release();
    +            } catch (err) {
    +              if (err instanceof SsrFBlockedError) {
    +                cronLogger.warn(
    +                  {
    +                    reason: formatErrorMessage(err),
    +                    jobId: evt.jobId,
    +                    webhookUrl: redactWebhookUrl(webhookTarget.url),
    +                  },
    +                  "cron: webhook delivery blocked by SSRF guard",
    +                );
    +              } else {
    +                cronLogger.warn(
    +                  {
    +                    err: formatErrorMessage(err),
    +                    jobId: evt.jobId,
    +                    webhookUrl: redactWebhookUrl(webhookTarget.url),
    +                  },
    +                  "cron: webhook delivery failed",
    +                );
    +              }
    +            } finally {
                   clearTimeout(timeout);
    -            });
    +            }
    +          })();
             }
             const logPath = resolveCronRunLogPath({
               storePath,
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.