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.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.4.10 | 2026.4.10 |
Affected products
2Patches
3e0b8ddc1a551fix(browser): apply three-phase interaction navigation guard to pressKey and type(submit) [AI-assisted] (#63889)
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, });
5f5b3d733bddfix(browser): re-check interaction-driven navigations (#63226)
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 }); }
049acf23cb03fix(browser): guard interaction-driven navigations
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- github.com/openclaw/openclaw/commit/049acf23cb03e1b92f5c71cd99c6ec5f35cc56fenvdPatchWEB
- github.com/openclaw/openclaw/commit/5f5b3d733bdd791cb457f838514179e1288b10b3nvdPatchWEB
- github.com/openclaw/openclaw/commit/e0b8ddc1a55185aff1cf9e0e095014d2e4f1d894nvdPatchWEB
- github.com/advisories/GHSA-536q-mj95-h29hghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-536q-mj95-h29hnvdMitigationVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-43580ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-incomplete-navigation-guard-coverage-in-browser-interactionsnvdThird Party AdvisoryWEB
- github.com/openclaw/openclaw/pull/62023ghsaWEB
- github.com/openclaw/openclaw/pull/63226ghsaWEB
- github.com/openclaw/openclaw/pull/63889ghsaWEB
News mentions
0No linked articles in our index yet.