VYPR
High severity7.7NVD Advisory· Published May 6, 2026· Updated May 7, 2026

CVE-2026-43580

CVE-2026-43580

Description

OpenClaw before 2026.4.10 contains an incomplete navigation guard vulnerability that allows attackers to trigger navigation without complete SSRF policy enforcement. Browser press/type style interactions, including pressKey and type submit flows, can bypass post-action security checks to execute unauthorized navigation.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.4.102026.4.10

Affected products

2
  • OpenClaw/Openclawreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*range: <2026.4.10

Patches

3
e0b8ddc1a551

fix(browser): apply three-phase interaction navigation guard to pressKey and type(submit) [AI-assisted] (#63889)

https://github.com/openclaw/openclawMichael AppelApr 10, 2026via ghsa
3 files changed · +124 25
  • CHANGELOG.md+1 0 modified
    @@ -126,6 +126,7 @@ Docs: https://docs.openclaw.ai
     - Discord/sandbox: include `image` in sandbox media param normalization so Discord event cover images cannot bypass sandbox path rewriting. (#64377) Thanks @mmaps.
     - Agents/exec: extend exec completion detection to cover local background exec formats so the owner-downgrade fires correctly for all exec paths. (#64376) Thanks @mmaps.
     - Security/dependencies: pin axios to 1.15.0 and add a plugin install dependency denylist that blocks known malicious packages before install. (#63891) Thanks @mmaps.
    +- Browser/security: apply three-phase interaction navigation guard to pressKey and type(submit) so delayed JS redirects from keypress cannot bypass SSRF policy. (#63889) Thanks @mmaps.
     ## 2026.4.9
     
     ### Changes
    
  • extensions/browser/src/browser/pw-tools-core.interactions.navigation-guard.test.ts+109 0 modified
    @@ -398,6 +398,115 @@ describe("pw-tools-core interaction navigation guard", () => {
         });
       });
     
    +  it("runs the post-keypress navigation guard when navigation starts shortly after the keypress resolves", async () => {
    +    vi.useFakeTimers();
    +    try {
    +      const listeners = new Set<() => void>();
    +      let currentUrl = "http://127.0.0.1:9222/json/version";
    +      const page = {
    +        keyboard: {
    +          press: vi.fn(async () => {
    +            setTimeout(() => {
    +              currentUrl = "http://127.0.0.1:9222/private-target";
    +              for (const listener of listeners) {
    +                listener();
    +              }
    +            }, 10);
    +          }),
    +        },
    +        on: vi.fn((event: string, listener: () => void) => {
    +          if (event === "framenavigated") {
    +            listeners.add(listener);
    +          }
    +        }),
    +        off: vi.fn((event: string, listener: () => void) => {
    +          if (event === "framenavigated") {
    +            listeners.delete(listener);
    +          }
    +        }),
    +        url: vi.fn(() => currentUrl),
    +      };
    +      setPwToolsCoreCurrentPage(page);
    +
    +      const task = mod.pressKeyViaPlaywright({
    +        cdpUrl: "http://127.0.0.1:18792",
    +        targetId: "T1",
    +        key: "Enter",
    +        ssrfPolicy: { allowPrivateNetwork: false },
    +      });
    +
    +      await vi.advanceTimersByTimeAsync(10);
    +      await task;
    +
    +      expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith(
    +        {
    +          cdpUrl: "http://127.0.0.1:18792",
    +          page,
    +          response: null,
    +          ssrfPolicy: { allowPrivateNetwork: false },
    +          targetId: "T1",
    +        },
    +      );
    +    } finally {
    +      vi.useRealTimers();
    +    }
    +  });
    +
    +  it("propagates blocked delayed submit navigation instead of reporting type success", async () => {
    +    vi.useFakeTimers();
    +    try {
    +      const listeners = new Set<() => void>();
    +      let currentUrl = "https://example.com/form";
    +      const locator = {
    +        fill: vi.fn(async () => {}),
    +        press: vi.fn(async () => {
    +          setTimeout(() => {
    +            currentUrl = "http://127.0.0.1:9222/private-target";
    +            for (const listener of listeners) {
    +              listener();
    +            }
    +          }, 10);
    +        }),
    +      };
    +      const page = {
    +        on: vi.fn((event: string, listener: () => void) => {
    +          if (event === "framenavigated") {
    +            listeners.add(listener);
    +          }
    +        }),
    +        off: vi.fn((event: string, listener: () => void) => {
    +          if (event === "framenavigated") {
    +            listeners.delete(listener);
    +          }
    +        }),
    +        url: vi.fn(() => currentUrl),
    +      };
    +      setPwToolsCoreCurrentRefLocator(locator);
    +      setPwToolsCoreCurrentPage(page);
    +
    +      const blocked = new Error("blocked delayed interaction navigation");
    +      getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely.mockRejectedValueOnce(
    +        blocked,
    +      );
    +
    +      const task = mod.typeViaPlaywright({
    +        cdpUrl: "http://127.0.0.1:18792",
    +        targetId: "T1",
    +        ref: "1",
    +        text: "hello",
    +        submit: true,
    +        ssrfPolicy: { allowPrivateNetwork: false },
    +      });
    +      const rejection = expect(task).rejects.toThrow("blocked delayed interaction navigation");
    +
    +      await vi.advanceTimersByTimeAsync(10);
    +      await rejection;
    +      expect(listeners.size).toBe(0);
    +    } finally {
    +      vi.useRealTimers();
    +    }
    +  });
    +
       it("does not run the post-click navigation guard when the url is unchanged", async () => {
         const click = vi.fn(async () => {});
         const page = { url: vi.fn(() => "http://127.0.0.1:9222/json/version") };
    
  • extensions/browser/src/browser/pw-tools-core.interactions.ts+14 25 modified
    @@ -379,25 +379,6 @@ function createAbortPromiseWithListener(
         },
       };
     }
    -
    -async function assertPostInteractionNavigationSafe(opts: {
    -  cdpUrl: string;
    -  page: Awaited<ReturnType<typeof getPageForTargetId>>;
    -  ssrfPolicy?: SsrFPolicy;
    -  targetId?: string;
    -}): Promise<void> {
    -  if (!opts.ssrfPolicy) {
    -    return;
    -  }
    -  await assertPageNavigationCompletedSafely({
    -    cdpUrl: opts.cdpUrl,
    -    page: opts.page,
    -    response: null,
    -    ssrfPolicy: opts.ssrfPolicy,
    -    targetId: opts.targetId,
    -  });
    -}
    -
     export async function highlightViaPlaywright(opts: {
       cdpUrl: string;
       targetId?: string;
    @@ -559,12 +540,16 @@ export async function pressKeyViaPlaywright(opts: {
       }
       const page = await getPageForTargetId(opts);
       ensurePageState(page);
    -  await page.keyboard.press(key, {
    -    delay: Math.max(0, Math.floor(opts.delayMs ?? 0)),
    -  });
    -  await assertPostInteractionNavigationSafe({
    +  const previousUrl = page.url();
    +  await assertInteractionNavigationCompletedSafely({
    +    action: async () => {
    +      await page.keyboard.press(key, {
    +        delay: Math.max(0, Math.floor(opts.delayMs ?? 0)),
    +      });
    +    },
         cdpUrl: opts.cdpUrl,
         page,
    +    previousUrl,
         ssrfPolicy: opts.ssrfPolicy,
         targetId: opts.targetId,
       });
    @@ -597,10 +582,14 @@ export async function typeViaPlaywright(opts: {
           await locator.fill(text, { timeout });
         }
         if (opts.submit) {
    -      await locator.press("Enter", { timeout });
    -      await assertPostInteractionNavigationSafe({
    +      const previousUrl = page.url();
    +      await assertInteractionNavigationCompletedSafely({
    +        action: async () => {
    +          await locator.press("Enter", { timeout });
    +        },
             cdpUrl: opts.cdpUrl,
             page,
    +        previousUrl,
             ssrfPolicy: opts.ssrfPolicy,
             targetId: opts.targetId,
           });
    
5f5b3d733bdd

fix(browser): re-check interaction-driven navigations (#63226)

https://github.com/openclaw/openclawAgustin RiveraApr 8, 2026via ghsa
7 files changed · +847 27
  • CHANGELOG.md+1 0 modified
    @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
     - Gateway/sessions: clear auto-fallback-pinned model overrides on `/reset` and `/new` while still preserving explicit user model selections, including legacy sessions created before override-source tracking existed. (#63155) Thanks @frankekn.
     - Codex CLI: pass OpenClaw's system prompt through Codex's `model_instructions_file` config override so fresh Codex CLI sessions receive the same prompt guidance as Claude CLI sessions.
     - Matrix/gateway: wait for Matrix sync readiness before marking startup successful, keep Matrix background handler failures contained, and route fatal Matrix sync stops through channel-level restart handling instead of crashing the whole gateway. (#62779) Thanks @gumadeiras.
    +- Browser/security: re-run blocked-destination safety checks after interaction-driven main-frame navigations from click, evaluate, hook-triggered click, and batched action flows, so browser interactions cannot bypass the SSRF quarantine when they land on forbidden URLs. (#63226) Thanks @eleqtrizit.
     
     ## 2026.4.8
     
    
  • extensions/browser/src/browser/pw-tools-core.interactions.batch.test.ts+7 1 modified
    @@ -1,6 +1,9 @@
     import { beforeEach, describe, expect, it, vi } from "vitest";
     
    -let page: { evaluate: ReturnType<typeof vi.fn> } | null = null;
    +let page: {
    +  evaluate: ReturnType<typeof vi.fn>;
    +  url: ReturnType<typeof vi.fn>;
    +} | null = null;
     
     const getPageForTargetId = vi.fn(async () => {
       if (!page) {
    @@ -9,6 +12,7 @@ const getPageForTargetId = vi.fn(async () => {
       return page;
     });
     const ensurePageState = vi.fn(() => {});
    +const assertPageNavigationCompletedSafely = vi.fn(async () => {});
     const forceDisconnectPlaywrightForTarget = vi.fn(async () => {});
     const refLocator = vi.fn(() => {
       throw new Error("test: refLocator should not be called");
    @@ -19,6 +23,7 @@ const closePageViaPlaywright = vi.fn(async () => {});
     const resizeViewportViaPlaywright = vi.fn(async () => {});
     
     vi.mock("./pw-session.js", () => ({
    +  assertPageNavigationCompletedSafely,
       ensurePageState,
       forceDisconnectPlaywrightForTarget,
       getPageForTargetId,
    @@ -38,6 +43,7 @@ describe("batchViaPlaywright", () => {
         vi.clearAllMocks();
         page = {
           evaluate: vi.fn(async () => "ok"),
    +      url: vi.fn(() => "about:blank"),
         };
       });
     
    
  • extensions/browser/src/browser/pw-tools-core.interactions.evaluate.abort.test.ts+4 1 modified
    @@ -1,6 +1,6 @@
     import { beforeEach, describe, expect, it, vi } from "vitest";
     
    -let page: { evaluate: ReturnType<typeof vi.fn> } | null = null;
    +let page: { evaluate: ReturnType<typeof vi.fn>; url: ReturnType<typeof vi.fn> } | null = null;
     let locator: { evaluate: ReturnType<typeof vi.fn> } | null = null;
     
     const forceDisconnectPlaywrightForTarget = vi.fn(async () => {});
    @@ -11,6 +11,7 @@ const getPageForTargetId = vi.fn(async () => {
       return page;
     });
     const ensurePageState = vi.fn(() => {});
    +const assertPageNavigationCompletedSafely = vi.fn(async () => {});
     const restoreRoleRefsForTarget = vi.fn(() => {});
     const refLocator = vi.fn(() => {
       if (!locator) {
    @@ -21,6 +22,7 @@ const refLocator = vi.fn(() => {
     
     vi.mock("./pw-session.js", () => {
       return {
    +    assertPageNavigationCompletedSafely,
         ensurePageState,
         forceDisconnectPlaywrightForTarget,
         getPageForTargetId,
    @@ -64,6 +66,7 @@ describe("evaluateViaPlaywright (abort)", () => {
             }
             return pendingPromise;
           }),
    +      url: vi.fn(() => "https://example.com/current"),
         };
         locator = {
           evaluate: vi.fn(() => {
    
  • extensions/browser/src/browser/pw-tools-core.interactions.navigation-guard.test.ts+527 0 added
    @@ -0,0 +1,527 @@
    +import { describe, expect, it, vi } from "vitest";
    +import {
    +  getPwToolsCoreSessionMocks,
    +  installPwToolsCoreTestHooks,
    +  setPwToolsCoreCurrentPage,
    +  setPwToolsCoreCurrentRefLocator,
    +} from "./pw-tools-core.test-harness.js";
    +
    +installPwToolsCoreTestHooks();
    +const mod = await import("./pw-tools-core.js");
    +
    +describe("pw-tools-core interaction navigation guard", () => {
    +  it("does not wait for the grace window after a successful non-navigating click", async () => {
    +    vi.useFakeTimers();
    +    try {
    +      const listeners = new Set<() => void>();
    +      const click = vi.fn(async () => {});
    +      const page = {
    +        on: vi.fn((event: string, listener: () => void) => {
    +          if (event === "framenavigated") {
    +            listeners.add(listener);
    +          }
    +        }),
    +        off: vi.fn((event: string, listener: () => void) => {
    +          if (event === "framenavigated") {
    +            listeners.delete(listener);
    +          }
    +        }),
    +        url: vi.fn(() => "http://127.0.0.1:9222/json/version"),
    +      };
    +      setPwToolsCoreCurrentRefLocator({ click });
    +      setPwToolsCoreCurrentPage(page);
    +
    +      const completion = vi.fn();
    +      const task = mod
    +        .clickViaPlaywright({
    +          cdpUrl: "http://127.0.0.1:18792",
    +          targetId: "T1",
    +          ref: "1",
    +          ssrfPolicy: { allowPrivateNetwork: false },
    +        })
    +        .then(completion);
    +
    +      await vi.advanceTimersByTimeAsync(0);
    +      expect(completion).toHaveBeenCalledTimes(1);
    +      expect(listeners.size).toBe(1);
    +      expect(
    +        getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely,
    +      ).not.toHaveBeenCalled();
    +
    +      await vi.advanceTimersByTimeAsync(250);
    +      expect(listeners.size).toBe(0);
    +      await task;
    +    } finally {
    +      vi.useRealTimers();
    +    }
    +  });
    +
    +  it("runs the post-click navigation guard when navigation starts shortly after the click resolves", async () => {
    +    vi.useFakeTimers();
    +    try {
    +      const listeners = new Set<() => void>();
    +      let currentUrl = "http://127.0.0.1:9222/json/version";
    +      const click = vi.fn(async () => {
    +        setTimeout(() => {
    +          currentUrl = "http://127.0.0.1:9222/json/list";
    +          for (const listener of listeners) {
    +            listener();
    +          }
    +        }, 10);
    +      });
    +      const page = {
    +        on: vi.fn((event: string, listener: () => void) => {
    +          if (event === "framenavigated") {
    +            listeners.add(listener);
    +          }
    +        }),
    +        off: vi.fn((event: string, listener: () => void) => {
    +          if (event === "framenavigated") {
    +            listeners.delete(listener);
    +          }
    +        }),
    +        url: vi.fn(() => currentUrl),
    +      };
    +      setPwToolsCoreCurrentRefLocator({ click });
    +      setPwToolsCoreCurrentPage(page);
    +
    +      const completion = vi.fn();
    +      const task = mod
    +        .clickViaPlaywright({
    +          cdpUrl: "http://127.0.0.1:18792",
    +          targetId: "T1",
    +          ref: "1",
    +          ssrfPolicy: { allowPrivateNetwork: false },
    +        })
    +        .then(completion);
    +
    +      await vi.advanceTimersByTimeAsync(0);
    +      expect(completion).toHaveBeenCalledTimes(1);
    +      expect(
    +        getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely,
    +      ).not.toHaveBeenCalled();
    +
    +      await vi.advanceTimersByTimeAsync(10);
    +      await task;
    +
    +      expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith(
    +        {
    +          cdpUrl: "http://127.0.0.1:18792",
    +          page,
    +          response: null,
    +          ssrfPolicy: { allowPrivateNetwork: false },
    +          targetId: "T1",
    +        },
    +      );
    +    } finally {
    +      vi.useRealTimers();
    +    }
    +  });
    +
    +  it("ignores subframe framenavigated events before the main frame navigates", async () => {
    +    vi.useFakeTimers();
    +    try {
    +      const listeners = new Set<(frame: object) => void>();
    +      const mainFrame = {};
    +      const subframe = {};
    +      let currentUrl = "http://127.0.0.1:9222/json/version";
    +      const click = vi.fn(async () => {
    +        setTimeout(() => {
    +          for (const listener of listeners) {
    +            listener(subframe);
    +          }
    +        }, 10);
    +        setTimeout(() => {
    +          currentUrl = "http://127.0.0.1:9222/json/list";
    +          for (const listener of listeners) {
    +            listener(mainFrame);
    +          }
    +        }, 20);
    +      });
    +      const page = {
    +        mainFrame: vi.fn(() => mainFrame),
    +        on: vi.fn((event: string, listener: (frame: object) => void) => {
    +          if (event === "framenavigated") {
    +            listeners.add(listener);
    +          }
    +        }),
    +        off: vi.fn((event: string, listener: (frame: object) => void) => {
    +          if (event === "framenavigated") {
    +            listeners.delete(listener);
    +          }
    +        }),
    +        url: vi.fn(() => currentUrl),
    +      };
    +      setPwToolsCoreCurrentRefLocator({ click });
    +      setPwToolsCoreCurrentPage(page);
    +
    +      const task = mod.clickViaPlaywright({
    +        cdpUrl: "http://127.0.0.1:18792",
    +        targetId: "T1",
    +        ref: "1",
    +        ssrfPolicy: { allowPrivateNetwork: false },
    +      });
    +
    +      await vi.advanceTimersByTimeAsync(10);
    +      expect(listeners.size).toBe(1);
    +      expect(
    +        getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely,
    +      ).not.toHaveBeenCalled();
    +
    +      await vi.advanceTimersByTimeAsync(10);
    +      await task;
    +
    +      expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith(
    +        {
    +          cdpUrl: "http://127.0.0.1:18792",
    +          page,
    +          response: null,
    +          ssrfPolicy: { allowPrivateNetwork: false },
    +          targetId: "T1",
    +        },
    +      );
    +    } finally {
    +      vi.useRealTimers();
    +    }
    +  });
    +
    +  it("deduplicates delayed navigation guards across repeated successful interactions", async () => {
    +    vi.useFakeTimers();
    +    try {
    +      const listeners = new Set<() => void>();
    +      let currentUrl = "http://127.0.0.1:9222/json/version";
    +      const click = vi.fn(async () => {});
    +      const page = {
    +        on: vi.fn((event: string, listener: () => void) => {
    +          if (event === "framenavigated") {
    +            listeners.add(listener);
    +          }
    +        }),
    +        off: vi.fn((event: string, listener: () => void) => {
    +          if (event === "framenavigated") {
    +            listeners.delete(listener);
    +          }
    +        }),
    +        url: vi.fn(() => currentUrl),
    +      };
    +      setPwToolsCoreCurrentRefLocator({ click });
    +      setPwToolsCoreCurrentPage(page);
    +
    +      await mod.clickViaPlaywright({
    +        cdpUrl: "http://127.0.0.1:18792",
    +        targetId: "T1",
    +        ref: "1",
    +        ssrfPolicy: { allowPrivateNetwork: false },
    +      });
    +      expect(listeners.size).toBe(1);
    +
    +      await mod.clickViaPlaywright({
    +        cdpUrl: "http://127.0.0.1:18792",
    +        targetId: "T1",
    +        ref: "1",
    +        ssrfPolicy: { allowPrivateNetwork: false },
    +      });
    +      expect(listeners.size).toBe(1);
    +
    +      currentUrl = "http://127.0.0.1:9222/json/list";
    +      for (const listener of Array.from(listeners)) {
    +        listener();
    +      }
    +      await vi.advanceTimersByTimeAsync(0);
    +
    +      expect(
    +        getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely,
    +      ).toHaveBeenCalledTimes(1);
    +      expect(listeners.size).toBe(0);
    +    } finally {
    +      vi.useRealTimers();
    +    }
    +  });
    +
    +  it("runs the post-click navigation guard with the resolved SSRF policy", async () => {
    +    const click = vi.fn(async () => {});
    +    const page = {
    +      url: vi
    +        .fn()
    +        .mockReturnValueOnce("http://127.0.0.1:9222/json/version")
    +        .mockReturnValue("http://127.0.0.1:9222/json/list"),
    +    };
    +    setPwToolsCoreCurrentRefLocator({ click });
    +    setPwToolsCoreCurrentPage(page);
    +
    +    const blocked = new Error("blocked interaction navigation");
    +    getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely.mockRejectedValueOnce(blocked);
    +
    +    await expect(
    +      mod.clickViaPlaywright({
    +        cdpUrl: "http://127.0.0.1:18792",
    +        targetId: "T1",
    +        ref: "1",
    +        ssrfPolicy: { allowPrivateNetwork: false },
    +      }),
    +    ).rejects.toThrow("blocked interaction navigation");
    +
    +    expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      page,
    +      response: null,
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +      targetId: "T1",
    +    });
    +  });
    +
    +  it("skips interaction navigation guards when no explicit SSRF policy is provided", async () => {
    +    vi.useFakeTimers();
    +    try {
    +      const listeners = new Set<(frame: object) => void>();
    +      const mainFrame = {};
    +      let currentUrl = "http://127.0.0.1:9222/json/version";
    +      const click = vi.fn(async () => {
    +        currentUrl = "http://127.0.0.1:9222/json/list";
    +        for (const listener of listeners) {
    +          listener(mainFrame);
    +        }
    +      });
    +      const page = {
    +        mainFrame: vi.fn(() => mainFrame),
    +        on: vi.fn((event: string, listener: (frame: object) => void) => {
    +          if (event === "framenavigated") {
    +            listeners.add(listener);
    +          }
    +        }),
    +        off: vi.fn((event: string, listener: (frame: object) => void) => {
    +          if (event === "framenavigated") {
    +            listeners.delete(listener);
    +          }
    +        }),
    +        url: vi.fn(() => currentUrl),
    +      };
    +      setPwToolsCoreCurrentRefLocator({ click });
    +      setPwToolsCoreCurrentPage(page);
    +
    +      await mod.clickViaPlaywright({
    +        cdpUrl: "http://127.0.0.1:18792",
    +        targetId: "T1",
    +        ref: "1",
    +      });
    +      await vi.runAllTimersAsync();
    +
    +      expect(page.on).not.toHaveBeenCalled();
    +      expect(page.off).not.toHaveBeenCalled();
    +      expect(
    +        getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely,
    +      ).not.toHaveBeenCalled();
    +    } finally {
    +      vi.useRealTimers();
    +    }
    +  });
    +
    +  it("runs the post-evaluate navigation guard after page evaluation", async () => {
    +    const page = {
    +      evaluate: vi.fn(async () => "ok"),
    +      url: vi
    +        .fn()
    +        .mockReturnValueOnce("http://127.0.0.1:9222/json/version")
    +        .mockReturnValue("http://127.0.0.1:9222/json/list"),
    +    };
    +    setPwToolsCoreCurrentPage(page);
    +
    +    const result = await mod.evaluateViaPlaywright({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      targetId: "T1",
    +      fn: "() => location.href = 'http://127.0.0.1:9222/json/version'",
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(result).toBe("ok");
    +    expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      page,
    +      response: null,
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +      targetId: "T1",
    +    });
    +  });
    +
    +  it("does not run the post-click navigation guard when the url is unchanged", async () => {
    +    const click = vi.fn(async () => {});
    +    const page = { url: vi.fn(() => "http://127.0.0.1:9222/json/version") };
    +    setPwToolsCoreCurrentRefLocator({ click });
    +    setPwToolsCoreCurrentPage(page);
    +
    +    await mod.clickViaPlaywright({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      targetId: "T1",
    +      ref: "1",
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).not.toHaveBeenCalled();
    +  });
    +
    +  it("does not run the navigation guard when only the URL hash changes (same-document navigation)", async () => {
    +    const click = vi.fn(async () => {});
    +    const page = {
    +      url: vi
    +        .fn()
    +        .mockReturnValueOnce("https://example.com/page")
    +        .mockReturnValue("https://example.com/page#section"),
    +    };
    +    setPwToolsCoreCurrentRefLocator({ click });
    +    setPwToolsCoreCurrentPage(page);
    +
    +    await mod.clickViaPlaywright({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      targetId: "T1",
    +      ref: "1",
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).not.toHaveBeenCalled();
    +  });
    +
    +  it("runs the navigation guard when a same-URL reload fires framenavigated during a click", async () => {
    +    // A page reload (form submit, location.reload()) keeps the URL identical but
    +    // fires framenavigated. Prior to the isHashOnlyNavigation fix, didCrossDocumentUrlChange
    +    // would treat currentUrl === previousUrl as "no navigation" and skip the SSRF guard.
    +    const listeners = new Set<() => void>();
    +    const sameUrl = "http://192.168.1.1/admin";
    +    const click = vi.fn(async () => {
    +      // Simulate reload: URL stays the same but framenavigated fires during the click
    +      for (const listener of listeners) {
    +        listener();
    +      }
    +    });
    +    const page = {
    +      on: vi.fn((event: string, listener: () => void) => {
    +        if (event === "framenavigated") {
    +          listeners.add(listener);
    +        }
    +      }),
    +      off: vi.fn((event: string, listener: () => void) => {
    +        if (event === "framenavigated") {
    +          listeners.delete(listener);
    +        }
    +      }),
    +      url: vi.fn(() => sameUrl),
    +    };
    +    setPwToolsCoreCurrentRefLocator({ click });
    +    setPwToolsCoreCurrentPage(page);
    +
    +    await mod.clickViaPlaywright({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      targetId: "T1",
    +      ref: "1",
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      page,
    +      response: null,
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +      targetId: "T1",
    +    });
    +  });
    +
    +  it("does not run the post-evaluate navigation guard when the url is unchanged", async () => {
    +    const page = {
    +      evaluate: vi.fn(async () => "ok"),
    +      url: vi.fn(() => "http://127.0.0.1:9222/json/version"),
    +    };
    +    setPwToolsCoreCurrentPage(page);
    +
    +    const result = await mod.evaluateViaPlaywright({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      targetId: "T1",
    +      fn: "() => 1",
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(result).toBe("ok");
    +    expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).not.toHaveBeenCalled();
    +  });
    +
    +  it("propagates the SSRF policy through batch interaction actions", async () => {
    +    const click = vi.fn(async () => {});
    +    const page = {
    +      url: vi.fn().mockReturnValueOnce("about:blank").mockReturnValue("https://example.com/after"),
    +    };
    +    setPwToolsCoreCurrentRefLocator({ click });
    +    setPwToolsCoreCurrentPage(page);
    +
    +    await mod.batchViaPlaywright({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      targetId: "T1",
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +      actions: [{ kind: "click", ref: "1" }],
    +    });
    +
    +    expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      page,
    +      response: null,
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +      targetId: "T1",
    +    });
    +  });
    +
    +  it("runs the post-evaluate navigation guard when evaluate rejects after triggering navigation", async () => {
    +    vi.useFakeTimers();
    +    try {
    +      const listeners = new Set<() => void>();
    +      let currentUrl = "http://127.0.0.1:9222/json/version";
    +      const page = {
    +        evaluate: vi.fn(async () => {
    +          setTimeout(() => {
    +            currentUrl = "http://127.0.0.1:9222/json/list";
    +            for (const listener of listeners) {
    +              listener();
    +            }
    +          }, 0);
    +          throw new Error("evaluate failed after scheduling navigation");
    +        }),
    +        on: vi.fn((event: string, listener: () => void) => {
    +          if (event === "framenavigated") {
    +            listeners.add(listener);
    +          }
    +        }),
    +        off: vi.fn((event: string, listener: () => void) => {
    +          if (event === "framenavigated") {
    +            listeners.delete(listener);
    +          }
    +        }),
    +        url: vi.fn(() => currentUrl),
    +      };
    +      setPwToolsCoreCurrentPage(page);
    +
    +      const blocked = new Error("blocked interaction navigation");
    +      getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely.mockRejectedValueOnce(
    +        blocked,
    +      );
    +
    +      const task = mod.evaluateViaPlaywright({
    +        cdpUrl: "http://127.0.0.1:18792",
    +        targetId: "T1",
    +        fn: "() => location.href = 'http://127.0.0.1:9222/json/list'",
    +        ssrfPolicy: { allowPrivateNetwork: false },
    +      });
    +      const expectation = expect(task).rejects.toThrow("blocked interaction navigation");
    +
    +      await vi.runAllTimersAsync();
    +      await expectation;
    +
    +      expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith(
    +        {
    +          cdpUrl: "http://127.0.0.1:18792",
    +          page,
    +          response: null,
    +          ssrfPolicy: { allowPrivateNetwork: false },
    +          targetId: "T1",
    +        },
    +      );
    +    } finally {
    +      vi.useRealTimers();
    +    }
    +  });
    +});
    
  • extensions/browser/src/browser/pw-tools-core.interactions.ts+304 22 modified
    @@ -1,5 +1,6 @@
     import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
     import { formatErrorMessage } from "../infra/errors.js";
    +import type { Frame, Page } from "playwright-core";
     import type { SsrFPolicy } from "../infra/net/ssrf.js";
     import type { BrowserActRequest, BrowserFormField } from "./client-actions-core.js";
     import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js";
    @@ -28,6 +29,15 @@ type TargetOpts = {
     const MAX_CLICK_DELAY_MS = 5_000;
     const MAX_WAIT_TIME_MS = 30_000;
     const MAX_BATCH_ACTIONS = 100;
    +const INTERACTION_NAVIGATION_GRACE_MS = 250;
    +
    +type NavigationObservablePage = Pick<Page, "url"> & {
    +  mainFrame?: () => Frame;
    +  on?: (event: "framenavigated", listener: (frame: Frame) => void) => unknown;
    +  off?: (event: "framenavigated", listener: (frame: Frame) => void) => unknown;
    +};
    +
    +const pendingInteractionNavigationGuardCleanup = new WeakMap<Page, () => void>();
     
     function resolveBoundedDelayMs(value: number | undefined, label: string, maxMs: number): number {
       const normalized = Math.floor(value ?? 0);
    @@ -51,6 +61,254 @@ function resolveInteractionTimeoutMs(timeoutMs?: number): number {
       return Math.max(500, Math.min(60_000, Math.floor(timeoutMs ?? 8000)));
     }
     
    +// Returns true only when the URL change indicates a cross-document navigation
    +// (i.e., a real network fetch occurred). Same-document hash-only mutations —
    +// anchor clicks and history.pushState/replaceState that change only the
    +// fragment — do not cause a network request and must not trigger SSRF checks.
    +function didCrossDocumentUrlChange(page: { url(): string }, previousUrl: string): boolean {
    +  const currentUrl = page.url();
    +  if (currentUrl === previousUrl) {
    +    return false;
    +  }
    +  try {
    +    const prev = new URL(previousUrl);
    +    const curr = new URL(currentUrl);
    +    if (
    +      prev.origin === curr.origin &&
    +      prev.pathname === curr.pathname &&
    +      prev.search === curr.search
    +    ) {
    +      // Only the fragment changed — same-document navigation, no fetch.
    +      return false;
    +    }
    +  } catch {
    +    // Non-parseable URL; fall through to string comparison.
    +  }
    +  return true;
    +}
    +
    +// Returns true when a framenavigated event represents only a hash-only
    +// same-document mutation (no network request). Used in event-driven checks
    +// where the event itself is the navigation signal — unlike URL polling, we
    +// cannot use identical URLs as a "no navigation" sentinel because same-URL
    +// reloads and form submits also fire framenavigated with an unchanged URL.
    +function isHashOnlyNavigation(currentUrl: string, previousUrl: string): boolean {
    +  if (currentUrl === previousUrl) {
    +    // Exact same URL + framenavigated firing = reload or form submit, not a
    +    // fragment hop. Must run SSRF checks.
    +    return false;
    +  }
    +  try {
    +    const prev = new URL(previousUrl);
    +    const curr = new URL(currentUrl);
    +    return (
    +      prev.origin === curr.origin && prev.pathname === curr.pathname && prev.search === curr.search
    +    );
    +  } catch {
    +    return false;
    +  }
    +}
    +
    +function isMainFrameNavigation(page: NavigationObservablePage, frame: Frame): boolean {
    +  if (typeof page.mainFrame !== "function") {
    +    return true;
    +  }
    +  return frame === page.mainFrame();
    +}
    +
    +function observeDelayedInteractionNavigation(
    +  page: NavigationObservablePage,
    +  previousUrl: string,
    +): Promise<boolean> {
    +  if (didCrossDocumentUrlChange(page, previousUrl)) {
    +    return Promise.resolve(true);
    +  }
    +  if (typeof page.on !== "function" || typeof page.off !== "function") {
    +    return Promise.resolve(false);
    +  }
    +
    +  return new Promise<boolean>((resolve) => {
    +    const onFrameNavigated = (frame: Frame) => {
    +      if (!isMainFrameNavigation(page, frame)) {
    +        return;
    +      }
    +      // Use isHashOnlyNavigation rather than !didCrossDocumentUrlChange: the
    +      // event firing is itself the navigation signal, so a same-URL reload must
    +      // not be treated as "no navigation" the way URL polling would.
    +      if (isHashOnlyNavigation(page.url(), previousUrl)) {
    +        return;
    +      }
    +      cleanup();
    +      resolve(true);
    +    };
    +    const timeout = setTimeout(() => {
    +      cleanup();
    +      resolve(didCrossDocumentUrlChange(page, previousUrl));
    +    }, INTERACTION_NAVIGATION_GRACE_MS);
    +    const cleanup = () => {
    +      clearTimeout(timeout);
    +      // Call off directly on page (not via a cached reference) to preserve
    +      // Playwright's EventEmitter `this` binding.
    +      page.off!("framenavigated", onFrameNavigated);
    +    };
    +
    +    // Call on directly on page (not via a cached reference) to preserve
    +    // Playwright's EventEmitter `this` binding.
    +    page.on!("framenavigated", onFrameNavigated);
    +  });
    +}
    +
    +function scheduleDelayedInteractionNavigationGuard(opts: {
    +  cdpUrl: string;
    +  page: Page;
    +  previousUrl: string;
    +  ssrfPolicy?: SsrFPolicy;
    +  targetId?: string;
    +}): void {
    +  if (!opts.ssrfPolicy) {
    +    return;
    +  }
    +  const page = opts.page as unknown as NavigationObservablePage;
    +  if (didCrossDocumentUrlChange(page, opts.previousUrl)) {
    +    void assertPageNavigationCompletedSafely({
    +      cdpUrl: opts.cdpUrl,
    +      page: opts.page,
    +      response: null,
    +      ssrfPolicy: opts.ssrfPolicy,
    +      targetId: opts.targetId,
    +    }).catch(() => {});
    +    return;
    +  }
    +  if (typeof page.on !== "function" || typeof page.off !== "function") {
    +    return;
    +  }
    +
    +  pendingInteractionNavigationGuardCleanup.get(opts.page)?.();
    +
    +  const onFrameNavigated = (frame: Frame) => {
    +    if (!isMainFrameNavigation(page, frame)) {
    +      return;
    +    }
    +    // Use isHashOnlyNavigation rather than !didCrossDocumentUrlChange: the
    +    // event firing is itself the navigation signal, so a same-URL reload must
    +    // not be treated as "no navigation" the way URL polling would.
    +    if (isHashOnlyNavigation(page.url(), opts.previousUrl)) {
    +      return;
    +    }
    +    cleanup();
    +    void assertPageNavigationCompletedSafely({
    +      cdpUrl: opts.cdpUrl,
    +      page: opts.page,
    +      response: null,
    +      ssrfPolicy: opts.ssrfPolicy,
    +      targetId: opts.targetId,
    +    }).catch(() => {});
    +  };
    +  const timeout = setTimeout(() => {
    +    cleanup();
    +  }, INTERACTION_NAVIGATION_GRACE_MS);
    +  const cleanup = () => {
    +    clearTimeout(timeout);
    +    page.off!("framenavigated", onFrameNavigated);
    +    if (pendingInteractionNavigationGuardCleanup.get(opts.page) === cleanup) {
    +      pendingInteractionNavigationGuardCleanup.delete(opts.page);
    +    }
    +  };
    +
    +  pendingInteractionNavigationGuardCleanup.set(opts.page, cleanup);
    +  page.on("framenavigated", onFrameNavigated);
    +}
    +
    +async function assertInteractionNavigationCompletedSafely<T>(opts: {
    +  action: () => Promise<T>;
    +  cdpUrl: string;
    +  page: Page;
    +  previousUrl: string;
    +  ssrfPolicy?: SsrFPolicy;
    +  targetId?: string;
    +}): Promise<T> {
    +  if (!opts.ssrfPolicy) {
    +    return await opts.action();
    +  }
    +  // Phase 1: keep a framenavigated listener alive for the entire duration of the
    +  // action so navigations triggered mid-click or mid-evaluate are not missed.
    +  // Using a fixed pre-action timer would expire before the action finishes for
    +  // slow interactions, silently bypassing the SSRF guard.
    +  const navPage = opts.page as unknown as NavigationObservablePage;
    +  let navigatedDuringAction = false;
    +  const onFrameNavigated = (frame: Frame) => {
    +    if (!isMainFrameNavigation(navPage, frame)) {
    +      return;
    +    }
    +    // Use isHashOnlyNavigation rather than didCrossDocumentUrlChange: the event
    +    // firing is the navigation signal, so a same-URL reload must not be skipped
    +    // the way it would be by URL-equality polling.
    +    if (!isHashOnlyNavigation(opts.page.url(), opts.previousUrl)) {
    +      navigatedDuringAction = true;
    +    }
    +  };
    +  if (typeof navPage.on === "function") {
    +    navPage.on("framenavigated", onFrameNavigated);
    +  }
    +
    +  let result: T | undefined;
    +  let actionError: unknown = null;
    +  try {
    +    result = await opts.action();
    +  } catch (err) {
    +    actionError = err;
    +  } finally {
    +    if (typeof navPage.off === "function") {
    +      navPage.off("framenavigated", onFrameNavigated);
    +    }
    +  }
    +
    +  const navigationObserved =
    +    navigatedDuringAction || didCrossDocumentUrlChange(opts.page, opts.previousUrl);
    +
    +  if (navigationObserved) {
    +    await assertPageNavigationCompletedSafely({
    +      cdpUrl: opts.cdpUrl,
    +      page: opts.page,
    +      response: null,
    +      ssrfPolicy: opts.ssrfPolicy,
    +      targetId: opts.targetId,
    +    });
    +  } else if (actionError) {
    +    // Preserve the action-error path semantics: if a rejected click/evaluate still
    +    // triggers a delayed navigation, the SSRF block must win over the original
    +    // action error instead of surfacing a stale interaction failure.
    +    const delayedNavigationObserved = await observeDelayedInteractionNavigation(
    +      opts.page,
    +      opts.previousUrl,
    +    );
    +    if (delayedNavigationObserved) {
    +      await assertPageNavigationCompletedSafely({
    +        cdpUrl: opts.cdpUrl,
    +        page: opts.page,
    +        response: null,
    +        ssrfPolicy: opts.ssrfPolicy,
    +        targetId: opts.targetId,
    +      });
    +    }
    +  } else {
    +    // Successful non-navigating interactions should not wait out the grace window,
    +    // but we still keep a short-lived listener alive to quarantine late SSRF hops.
    +    scheduleDelayedInteractionNavigationGuard({
    +      cdpUrl: opts.cdpUrl,
    +      page: opts.page,
    +      previousUrl: opts.previousUrl,
    +      ssrfPolicy: opts.ssrfPolicy,
    +      targetId: opts.targetId,
    +    });
    +  }
    +
    +  if (actionError) {
    +    throw actionError;
    +  }
    +  return result as T;
    +}
    +
     async function awaitEvalWithAbort<T>(
       evalPromise: Promise<T>,
       abortPromise?: Promise<never>,
    @@ -118,28 +376,32 @@ export async function clickViaPlaywright(opts: {
         ? refLocator(page, requireRef(resolved.ref))
         : page.locator(resolved.selector!);
       const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
    +  const previousUrl = page.url();
       try {
    -    const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
    -    if (delayMs > 0) {
    -      await locator.hover({ timeout });
    -      await new Promise((resolve) => setTimeout(resolve, delayMs));
    -    }
    -    if (opts.doubleClick) {
    -      await locator.dblclick({
    -        timeout,
    -        button: opts.button,
    -        modifiers: opts.modifiers,
    -      });
    -    } else {
    -      await locator.click({
    -        timeout,
    -        button: opts.button,
    -        modifiers: opts.modifiers,
    -      });
    -    }
    -    await assertPostInteractionNavigationSafe({
    +    await assertInteractionNavigationCompletedSafely({
    +      action: async () => {
    +        const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
    +        if (delayMs > 0) {
    +          await locator.hover({ timeout });
    +          await new Promise((resolve) => setTimeout(resolve, delayMs));
    +        }
    +        if (opts.doubleClick) {
    +          await locator.dblclick({
    +            timeout,
    +            button: opts.button,
    +            modifiers: opts.modifiers,
    +          });
    +          return;
    +        }
    +        await locator.click({
    +          timeout,
    +          button: opts.button,
    +          modifiers: opts.modifiers,
    +        });
    +      },
           cdpUrl: opts.cdpUrl,
           page,
    +      previousUrl,
           ssrfPolicy: opts.ssrfPolicy,
           targetId: opts.targetId,
         });
    @@ -332,6 +594,7 @@ export async function fillFormViaPlaywright(opts: {
     export async function evaluateViaPlaywright(opts: {
       cdpUrl: string;
       targetId?: string;
    +  ssrfPolicy?: SsrFPolicy;
       fn: string;
       ref?: string;
       timeoutMs?: number;
    @@ -393,6 +656,7 @@ export async function evaluateViaPlaywright(opts: {
       try {
         if (opts.ref) {
           const locator = refLocator(page, opts.ref);
    +      const previousUrl = page.url();
           // eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval
           const elementEvaluator = new Function(
             "el",
    @@ -421,9 +685,18 @@ export async function evaluateViaPlaywright(opts: {
             fnBody: fnText,
             timeoutMs: evaluateTimeout,
           });
    -      return await awaitEvalWithAbort(evalPromise, abortPromise);
    +      const result = await assertInteractionNavigationCompletedSafely({
    +        action: () => awaitEvalWithAbort(evalPromise, abortPromise),
    +        cdpUrl: opts.cdpUrl,
    +        page,
    +        previousUrl,
    +        ssrfPolicy: opts.ssrfPolicy,
    +        targetId: opts.targetId,
    +      });
    +      return result;
         }
     
    +    const previousUrl = page.url();
         // eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval
         const browserEvaluator = new Function(
           "args",
    @@ -451,7 +724,15 @@ export async function evaluateViaPlaywright(opts: {
           fnBody: fnText,
           timeoutMs: evaluateTimeout,
         });
    -    return await awaitEvalWithAbort(evalPromise, abortPromise);
    +    const result = await assertInteractionNavigationCompletedSafely({
    +      action: () => awaitEvalWithAbort(evalPromise, abortPromise),
    +      cdpUrl: opts.cdpUrl,
    +      page,
    +      previousUrl,
    +      ssrfPolicy: opts.ssrfPolicy,
    +      targetId: opts.targetId,
    +    });
    +    return result;
       } finally {
         if (signal && abortListener) {
           signal.removeEventListener("abort", abortListener);
    @@ -880,6 +1161,7 @@ async function executeSingleAction(
           await evaluateViaPlaywright({
             cdpUrl,
             targetId: effectiveTargetId,
    +        ssrfPolicy,
             fn: action.fn,
             ref: action.ref,
             timeoutMs: action.timeoutMs,
    @@ -895,10 +1177,10 @@ async function executeSingleAction(
           await batchViaPlaywright({
             cdpUrl,
             targetId: effectiveTargetId,
    +        ssrfPolicy,
             actions: action.actions,
             stopOnError: action.stopOnError,
             evaluateEnabled,
    -        ssrfPolicy,
             depth: depth + 1,
           });
           break;
    
  • extensions/browser/src/browser/routes/agent.act.hooks.ts+1 1 modified
    @@ -100,8 +100,8 @@ export function registerBrowserAgentActHookRoutes(
                 await pw.clickViaPlaywright({
                   cdpUrl,
                   targetId: tab.targetId,
    -              ref,
                   ssrfPolicy: ctx.state().resolved.ssrfPolicy,
    +              ref,
                 });
               }
             }
    
  • extensions/browser/src/browser/routes/agent.act.ts+3 2 modified
    @@ -539,8 +539,8 @@ export function registerBrowserAgentActRoutes(
                 const clickRequest: Parameters<typeof pw.clickViaPlaywright>[0] = {
                   cdpUrl,
                   targetId: tab.targetId,
    +              ssrfPolicy: ctx.state().resolved.ssrfPolicy,
                   doubleClick,
    -              ssrfPolicy,
                 };
                 if (ref) {
                   clickRequest.ref = ref;
    @@ -1047,6 +1047,7 @@ export function registerBrowserAgentActRoutes(
                 const evalRequest: Parameters<typeof pw.evaluateViaPlaywright>[0] = {
                   cdpUrl,
                   targetId: tab.targetId,
    +              ssrfPolicy: ctx.state().resolved.ssrfPolicy,
                   fn,
                   ref,
                   signal: req.signal,
    @@ -1106,10 +1107,10 @@ export function registerBrowserAgentActRoutes(
                 const result = await pw.batchViaPlaywright({
                   cdpUrl,
                   targetId: tab.targetId,
    +              ssrfPolicy: ctx.state().resolved.ssrfPolicy,
                   actions,
                   stopOnError,
                   evaluateEnabled,
    -              ssrfPolicy,
                 });
                 return res.json({ ok: true, targetId: tab.targetId, results: result.results });
               }
    
049acf23cb03

fix(browser): guard interaction-driven navigations

https://github.com/openclaw/openclawAgustin RiveraApr 7, 2026via ghsa
7 files changed · +292 1
  • extensions/browser/src/browser/pw-tools-core.browser-ssrf-guard.test.ts+180 0 added
    @@ -0,0 +1,180 @@
    +import { beforeEach, describe, expect, it, vi } from "vitest";
    +
    +const pageState = vi.hoisted(() => ({
    +  page: null as Record<string, unknown> | null,
    +  locator: null as Record<string, unknown> | null,
    +}));
    +
    +const sessionMocks = vi.hoisted(() => ({
    +  assertPageNavigationCompletedSafely: vi.fn(async () => {}),
    +  ensurePageState: vi.fn(() => ({})),
    +  forceDisconnectPlaywrightForTarget: vi.fn(async () => {}),
    +  getPageForTargetId: vi.fn(async () => {
    +    if (!pageState.page) {
    +      throw new Error("missing page");
    +    }
    +    return pageState.page;
    +  }),
    +  gotoPageWithNavigationGuard: vi.fn(async () => null),
    +  refLocator: vi.fn(() => {
    +    if (!pageState.locator) {
    +      throw new Error("missing locator");
    +    }
    +    return pageState.locator;
    +  }),
    +  restoreRoleRefsForTarget: vi.fn(() => {}),
    +  storeRoleRefsForTarget: vi.fn(() => {}),
    +}));
    +
    +const pageCdpMocks = vi.hoisted(() => ({
    +  withPageScopedCdpClient: vi.fn(
    +    async ({ fn }: { fn: (send: () => Promise<unknown>) => unknown }) =>
    +      await fn(async () => ({ nodes: [] })),
    +  ),
    +}));
    +
    +vi.mock("./pw-session.js", () => sessionMocks);
    +vi.mock("./pw-session.page-cdp.js", () => pageCdpMocks);
    +
    +const interactions = await import("./pw-tools-core.interactions.js");
    +const snapshots = await import("./pw-tools-core.snapshot.js");
    +
    +describe("pw-tools-core browser SSRF guards", () => {
    +  beforeEach(() => {
    +    pageState.page = null;
    +    pageState.locator = null;
    +    for (const fn of Object.values(sessionMocks)) {
    +      fn.mockClear();
    +    }
    +    for (const fn of Object.values(pageCdpMocks)) {
    +      fn.mockClear();
    +    }
    +  });
    +
    +  it("re-checks click-triggered navigations with the session safety helper", async () => {
    +    pageState.page = { url: vi.fn(() => "https://example.com") };
    +    pageState.locator = { click: vi.fn(async () => {}) };
    +
    +    await interactions.clickViaPlaywright({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      targetId: "tab-1",
    +      ref: "1",
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      page: pageState.page,
    +      response: null,
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +      targetId: "tab-1",
    +    });
    +  });
    +
    +  it("preserves helper compatibility when no ssrfPolicy is provided", async () => {
    +    pageState.page = { url: vi.fn(() => "https://example.com") };
    +    pageState.locator = { click: vi.fn(async () => {}) };
    +
    +    await interactions.clickViaPlaywright({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      targetId: "tab-1",
    +      ref: "1",
    +      // no ssrfPolicy: direct helper callers keep previous compatibility semantics
    +    });
    +
    +    expect(sessionMocks.assertPageNavigationCompletedSafely).not.toHaveBeenCalled();
    +  });
    +
    +  it("re-checks batched click-triggered navigations with the session safety helper", async () => {
    +    pageState.page = { url: vi.fn(() => "https://example.com") };
    +    pageState.locator = { click: vi.fn(async () => {}) };
    +
    +    await interactions.batchViaPlaywright({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      targetId: "tab-1",
    +      actions: [{ kind: "click", ref: "1" }],
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      page: pageState.page,
    +      response: null,
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +      targetId: "tab-1",
    +    });
    +  });
    +
    +  it("re-checks current page URL before snapshotting AI content", async () => {
    +    const snapshotForAI = vi.fn(async () => ({ full: 'button "Save"' }));
    +    pageState.page = {
    +      _snapshotForAI: snapshotForAI,
    +      url: vi.fn(() => "https://example.com"),
    +    };
    +
    +    await snapshots.snapshotAiViaPlaywright({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      targetId: "tab-1",
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      page: pageState.page,
    +      response: null,
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +      targetId: "tab-1",
    +    });
    +    expect(
    +      sessionMocks.assertPageNavigationCompletedSafely.mock.invocationCallOrder[0],
    +    ).toBeLessThan(snapshotForAI.mock.invocationCallOrder[0]);
    +  });
    +
    +  it("re-checks current page URL before role snapshots", async () => {
    +    const ariaSnapshot = vi.fn(async () => "");
    +    pageState.page = {
    +      locator: vi.fn(() => ({ ariaSnapshot })),
    +      url: vi.fn(() => "https://example.com"),
    +    };
    +
    +    await snapshots.snapshotRoleViaPlaywright({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      targetId: "tab-1",
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      page: pageState.page,
    +      response: null,
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +      targetId: "tab-1",
    +    });
    +    expect(
    +      sessionMocks.assertPageNavigationCompletedSafely.mock.invocationCallOrder[0],
    +    ).toBeLessThan(ariaSnapshot.mock.invocationCallOrder[0]);
    +  });
    +
    +  it("re-checks current page URL before aria snapshots", async () => {
    +    pageState.page = {
    +      url: vi.fn(() => "https://example.com"),
    +    };
    +
    +    await snapshots.snapshotAriaViaPlaywright({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      targetId: "tab-1",
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
    +      cdpUrl: "http://127.0.0.1:18792",
    +      page: pageState.page,
    +      response: null,
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +      targetId: "tab-1",
    +    });
    +    expect(
    +      sessionMocks.assertPageNavigationCompletedSafely.mock.invocationCallOrder[0],
    +    ).toBeLessThan(pageCdpMocks.withPageScopedCdpClient.mock.invocationCallOrder[0]);
    +  });
    +});
    
  • extensions/browser/src/browser/pw-tools-core.interactions.ts+55 1 modified
    @@ -1,8 +1,10 @@
     import { formatErrorMessage } from "../infra/errors.js";
    +import type { SsrFPolicy } from "../infra/net/ssrf.js";
     import type { BrowserActRequest, BrowserFormField } from "./client-actions-core.js";
     import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js";
     import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
     import {
    +  assertPageNavigationCompletedSafely,
       ensurePageState,
       forceDisconnectPlaywrightForTarget,
       getPageForTargetId,
    @@ -64,6 +66,24 @@ async function awaitEvalWithAbort<T>(
       }
     }
     
    +async function assertPostInteractionNavigationSafe(opts: {
    +  cdpUrl: string;
    +  page: Awaited<ReturnType<typeof getPageForTargetId>>;
    +  ssrfPolicy?: SsrFPolicy;
    +  targetId?: string;
    +}): Promise<void> {
    +  if (!opts.ssrfPolicy) {
    +    return;
    +  }
    +  await assertPageNavigationCompletedSafely({
    +    cdpUrl: opts.cdpUrl,
    +    page: opts.page,
    +    response: null,
    +    ssrfPolicy: opts.ssrfPolicy,
    +    targetId: opts.targetId,
    +  });
    +}
    +
     export async function highlightViaPlaywright(opts: {
       cdpUrl: string;
       targetId?: string;
    @@ -88,6 +108,7 @@ export async function clickViaPlaywright(opts: {
       modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">;
       delayMs?: number;
       timeoutMs?: number;
    +  ssrfPolicy?: SsrFPolicy;
     }): Promise<void> {
       const resolved = requireRefOrSelector(opts.ref, opts.selector);
       const page = await getRestoredPageForTarget(opts);
    @@ -115,6 +136,12 @@ export async function clickViaPlaywright(opts: {
             modifiers: opts.modifiers,
           });
         }
    +    await assertPostInteractionNavigationSafe({
    +      cdpUrl: opts.cdpUrl,
    +      page,
    +      ssrfPolicy: opts.ssrfPolicy,
    +      targetId: opts.targetId,
    +    });
       } catch (err) {
         throw toAIFriendlyError(err, label);
       }
    @@ -202,6 +229,7 @@ export async function pressKeyViaPlaywright(opts: {
       targetId?: string;
       key: string;
       delayMs?: number;
    +  ssrfPolicy?: SsrFPolicy;
     }): Promise<void> {
       const key = String(opts.key ?? "").trim();
       if (!key) {
    @@ -212,6 +240,12 @@ export async function pressKeyViaPlaywright(opts: {
       await page.keyboard.press(key, {
         delay: Math.max(0, Math.floor(opts.delayMs ?? 0)),
       });
    +  await assertPostInteractionNavigationSafe({
    +    cdpUrl: opts.cdpUrl,
    +    page,
    +    ssrfPolicy: opts.ssrfPolicy,
    +    targetId: opts.targetId,
    +  });
     }
     
     export async function typeViaPlaywright(opts: {
    @@ -223,6 +257,7 @@ export async function typeViaPlaywright(opts: {
       submit?: boolean;
       slowly?: boolean;
       timeoutMs?: number;
    +  ssrfPolicy?: SsrFPolicy;
     }): Promise<void> {
       const resolved = requireRefOrSelector(opts.ref, opts.selector);
       const text = String(opts.text ?? "");
    @@ -241,6 +276,12 @@ export async function typeViaPlaywright(opts: {
         }
         if (opts.submit) {
           await locator.press("Enter", { timeout });
    +      await assertPostInteractionNavigationSafe({
    +        cdpUrl: opts.cdpUrl,
    +        page,
    +        ssrfPolicy: opts.ssrfPolicy,
    +        targetId: opts.targetId,
    +      });
         }
       } catch (err) {
         throw toAIFriendlyError(err, label);
    @@ -713,6 +754,7 @@ async function executeSingleAction(
       cdpUrl: string,
       targetId?: string,
       evaluateEnabled?: boolean,
    +  ssrfPolicy?: SsrFPolicy,
       depth = 0,
     ): Promise<void> {
       if (depth > MAX_BATCH_DEPTH) {
    @@ -733,6 +775,7 @@ async function executeSingleAction(
             >,
             delayMs: action.delayMs,
             timeoutMs: action.timeoutMs,
    +        ssrfPolicy,
           });
           break;
         case "type":
    @@ -745,6 +788,7 @@ async function executeSingleAction(
             submit: action.submit,
             slowly: action.slowly,
             timeoutMs: action.timeoutMs,
    +        ssrfPolicy,
           });
           break;
         case "press":
    @@ -753,6 +797,7 @@ async function executeSingleAction(
             targetId: effectiveTargetId,
             key: action.key,
             delayMs: action.delayMs,
    +        ssrfPolicy,
           });
           break;
         case "hover":
    @@ -852,6 +897,7 @@ async function executeSingleAction(
             actions: action.actions,
             stopOnError: action.stopOnError,
             evaluateEnabled,
    +        ssrfPolicy,
             depth: depth + 1,
           });
           break;
    @@ -866,6 +912,7 @@ export async function batchViaPlaywright(opts: {
       actions: BrowserActRequest[];
       stopOnError?: boolean;
       evaluateEnabled?: boolean;
    +  ssrfPolicy?: SsrFPolicy;
       depth?: number;
     }): Promise<{ results: Array<{ ok: boolean; error?: string }> }> {
       const depth = opts.depth ?? 0;
    @@ -878,7 +925,14 @@ export async function batchViaPlaywright(opts: {
       const results: Array<{ ok: boolean; error?: string }> = [];
       for (const action of opts.actions) {
         try {
    -      await executeSingleAction(action, opts.cdpUrl, opts.targetId, opts.evaluateEnabled, depth);
    +      await executeSingleAction(
    +        action,
    +        opts.cdpUrl,
    +        opts.targetId,
    +        opts.evaluateEnabled,
    +        opts.ssrfPolicy,
    +        depth,
    +      );
           results.push({ ok: true });
         } catch (err) {
           const message = formatErrorMessage(err);
    
  • extensions/browser/src/browser/pw-tools-core.snapshot.ts+30 0 modified
    @@ -23,13 +23,23 @@ export async function snapshotAriaViaPlaywright(opts: {
       cdpUrl: string;
       targetId?: string;
       limit?: number;
    +  ssrfPolicy?: SsrFPolicy;
     }): Promise<{ nodes: AriaSnapshotNode[] }> {
       const limit = Math.max(1, Math.min(2000, Math.floor(opts.limit ?? 500)));
       const page = await getPageForTargetId({
         cdpUrl: opts.cdpUrl,
         targetId: opts.targetId,
       });
       ensurePageState(page);
    +  if (opts.ssrfPolicy) {
    +    await assertPageNavigationCompletedSafely({
    +      cdpUrl: opts.cdpUrl,
    +      page,
    +      response: null,
    +      ssrfPolicy: opts.ssrfPolicy,
    +      targetId: opts.targetId,
    +    });
    +  }
       const res = (await withPageScopedCdpClient({
         cdpUrl: opts.cdpUrl,
         page,
    @@ -52,12 +62,22 @@ export async function snapshotAiViaPlaywright(opts: {
       targetId?: string;
       timeoutMs?: number;
       maxChars?: number;
    +  ssrfPolicy?: SsrFPolicy;
     }): Promise<{ snapshot: string; truncated?: boolean; refs: RoleRefMap }> {
       const page = await getPageForTargetId({
         cdpUrl: opts.cdpUrl,
         targetId: opts.targetId,
       });
       ensurePageState(page);
    +  if (opts.ssrfPolicy) {
    +    await assertPageNavigationCompletedSafely({
    +      cdpUrl: opts.cdpUrl,
    +      page,
    +      response: null,
    +      ssrfPolicy: opts.ssrfPolicy,
    +      targetId: opts.targetId,
    +    });
    +  }
     
       const maybe = page as unknown as WithSnapshotForAI;
       if (!maybe._snapshotForAI) {
    @@ -98,6 +118,7 @@ export async function snapshotRoleViaPlaywright(opts: {
       frameSelector?: string;
       refsMode?: "role" | "aria";
       options?: RoleSnapshotOptions;
    +  ssrfPolicy?: SsrFPolicy;
     }): Promise<{
       snapshot: string;
       refs: Record<string, { role: string; name?: string; nth?: number }>;
    @@ -108,6 +129,15 @@ export async function snapshotRoleViaPlaywright(opts: {
         targetId: opts.targetId,
       });
       ensurePageState(page);
    +  if (opts.ssrfPolicy) {
    +    await assertPageNavigationCompletedSafely({
    +      cdpUrl: opts.cdpUrl,
    +      page,
    +      response: null,
    +      ssrfPolicy: opts.ssrfPolicy,
    +      targetId: opts.targetId,
    +    });
    +  }
     
       if (opts.refsMode === "aria") {
         if (opts.selector?.trim() || opts.frameSelector?.trim()) {
    
  • extensions/browser/src/browser/routes/agent.act.hooks.ts+1 0 modified
    @@ -101,6 +101,7 @@ export function registerBrowserAgentActHookRoutes(
                   cdpUrl,
                   targetId: tab.targetId,
                   ref,
    +              ssrfPolicy: ctx.state().resolved.ssrfPolicy,
                 });
               }
             }
    
  • extensions/browser/src/browser/routes/agent.act.ts+5 0 modified
    @@ -482,6 +482,7 @@ export function registerBrowserAgentActRoutes(
           targetId,
           run: async ({ profileCtx, cdpUrl, tab }) => {
             const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
    +        const ssrfPolicy = ctx.state().resolved.ssrfPolicy;
             const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
             const profileName = profileCtx.profile.name;
     
    @@ -539,6 +540,7 @@ export function registerBrowserAgentActRoutes(
                   cdpUrl,
                   targetId: tab.targetId,
                   doubleClick,
    +              ssrfPolicy,
                 };
                 if (ref) {
                   clickRequest.ref = ref;
    @@ -616,6 +618,7 @@ export function registerBrowserAgentActRoutes(
                   text,
                   submit,
                   slowly,
    +              ssrfPolicy,
                 };
                 if (ref) {
                   typeRequest.ref = ref;
    @@ -656,6 +659,7 @@ export function registerBrowserAgentActRoutes(
                   targetId: tab.targetId,
                   key,
                   delayMs: delayMs ?? undefined,
    +              ssrfPolicy,
                 });
                 return res.json({ ok: true, targetId: tab.targetId });
               }
    @@ -1105,6 +1109,7 @@ export function registerBrowserAgentActRoutes(
                   actions,
                   stopOnError,
                   evaluateEnabled,
    +              ssrfPolicy,
                 });
                 return res.json({ ok: true, targetId: tab.targetId, results: result.results });
               }
    
  • extensions/browser/src/browser/routes/agent.snapshot.ts+3 0 modified
    @@ -498,6 +498,7 @@ export function registerBrowserAgentSnapshotRoutes(
               selector: plan.selectorValue,
               frameSelector: plan.frameSelectorValue,
               refsMode: plan.refsMode,
    +          ssrfPolicy: ctx.state().resolved.ssrfPolicy,
               options: {
                 interactive: plan.interactive ?? undefined,
                 compact: plan.compact ?? undefined,
    @@ -511,6 +512,7 @@ export function registerBrowserAgentSnapshotRoutes(
                   .snapshotAiViaPlaywright({
                     cdpUrl: profileCtx.profile.cdpUrl,
                     targetId: tab.targetId,
    +                ssrfPolicy: ctx.state().resolved.ssrfPolicy,
                     ...(typeof plan.resolvedMaxChars === "number"
                       ? { maxChars: plan.resolvedMaxChars }
                       : {}),
    @@ -579,6 +581,7 @@ export function registerBrowserAgentSnapshotRoutes(
                     cdpUrl: profileCtx.profile.cdpUrl,
                     targetId: tab.targetId,
                     limit: plan.limit,
    +                ssrfPolicy: ctx.state().resolved.ssrfPolicy,
                   });
                 });
               })()
    
  • extensions/browser/src/browser/server.agent-contract-snapshot-endpoints.test.ts+18 0 modified
    @@ -43,6 +43,9 @@ describe("browser control server", () => {
           cdpUrl: state.cdpBaseUrl,
           targetId: "abcd1234",
           maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
    +      ssrfPolicy: {
    +        dangerouslyAllowPrivateNetwork: true,
    +      },
         });
     
         const snapAiZero = (await realFetch(`${base}/snapshot?format=ai&maxChars=0`).then((r) =>
    @@ -54,6 +57,9 @@ describe("browser control server", () => {
         expect(lastCall).toEqual({
           cdpUrl: state.cdpBaseUrl,
           targetId: "abcd1234",
    +      ssrfPolicy: {
    +        dangerouslyAllowPrivateNetwork: true,
    +      },
         });
       });
     
    @@ -91,6 +97,9 @@ describe("browser control server", () => {
           doubleClick: false,
           button: "left",
           modifiers: ["Shift"],
    +      ssrfPolicy: {
    +        dangerouslyAllowPrivateNetwork: true,
    +      },
         });
     
         const clickSelector = await realFetch(`${base}/act`, {
    @@ -105,6 +114,9 @@ describe("browser control server", () => {
           targetId: "abcd1234",
           selector: "button.save",
           doubleClick: false,
    +      ssrfPolicy: {
    +        dangerouslyAllowPrivateNetwork: true,
    +      },
         });
     
         const type = await postJson<{ ok: boolean }>(`${base}/act`, {
    @@ -120,6 +132,9 @@ describe("browser control server", () => {
           text: "",
           submit: false,
           slowly: false,
    +      ssrfPolicy: {
    +        dangerouslyAllowPrivateNetwork: true,
    +      },
         });
     
         const press = await postJson<{ ok: boolean }>(`${base}/act`, {
    @@ -131,6 +146,9 @@ describe("browser control server", () => {
           cdpUrl: state.cdpBaseUrl,
           targetId: "abcd1234",
           key: "Enter",
    +      ssrfPolicy: {
    +        dangerouslyAllowPrivateNetwork: true,
    +      },
         });
     
         const hover = await postJson<{ ok: boolean }>(`${base}/act`, {
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

10

News mentions

0

No linked articles in our index yet.