Skip to content

[Security] Browser interaction-driven navigation bypasses browser.ssrfPolicy and reaches loopback/private targets #562

Description

@YLChen-007

Advisory Details

Title: Browser interaction-driven navigation bypasses browser.ssrfPolicy and reaches loopback/private targets

Description:

Summary

The authenticated browser-control HTTP API in openclaw-cn enforces browser.ssrfPolicy.allowPrivateNetwork=false for direct /navigate requests, but it does not re-validate the final URL after a navigation-capable /act click. An attacker who can call the browser-control API can first load attacker-controlled HTML, then use /snapshot to obtain a clickable ref and /act to click a link to a loopback/private address. The browser lands on the blocked target and the content can then be read back through the same API.

Details

The issue is a post-navigation guard gap between the explicit navigation path and the interaction path.

Direct navigation is protected in src/browser/pw-tools-core.snapshot.ts:

export async function navigateViaPlaywright(opts: {
  cdpUrl: string;
  targetId?: string;
  url: string;
  timeoutMs?: number;
  ssrfPolicy?: SsrFPolicy;
}): Promise<{ url: string }> {
  const url = String(opts.url ?? "").trim();
  if (!url) throw new Error("url is required");
  await assertBrowserNavigationAllowed({
    url,
    ssrfPolicy: opts.ssrfPolicy,
  });
  const page = await getPageForTargetId(opts);
  ensurePageState(page);
  await page.goto(url, {
    timeout: Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000)),
  });
  return { url: page.url() };
}

By contrast, the /act route builds a click request and forwards it to clickViaPlaywright() without any ssrfPolicy plumbing:

const clickRequest: Parameters<typeof pw.clickViaPlaywright>[0] = {
  cdpUrl,
  targetId: tab.targetId,
  ref,
  doubleClick,
};
if (button) clickRequest.button = button;
if (modifiers) clickRequest.modifiers = modifiers;
if (timeoutMs) clickRequest.timeoutMs = timeoutMs;
await pw.clickViaPlaywright(clickRequest);

clickViaPlaywright() then resolves the ref and performs the browser click directly:

const locator = refLocator(page, ref);
const timeout = Math.max(500, Math.min(60_000, Math.floor(opts.timeoutMs ?? 8000)));
try {
  if (opts.doubleClick) {
    await locator.dblclick({
      timeout,
      button: opts.button,
      modifiers: opts.modifiers,
    });
  } else {
    await locator.click({
      timeout,
      button: opts.button,
      modifiers: opts.modifiers,
    });
  }
} catch (err) {
  throw toAIFriendlyError(err, ref);
}

This means the security boundary is inconsistent:

  1. POST /navigate to http://127.0.0.1:<port>/ is rejected with Blocked: private/internal IP address.
  2. POST /navigate to a benign data: page is allowed.
  3. GET /snapshot?format=ai&interactive=1&compact=1 returns a link ref from that page.
  4. POST /act {"kind":"click","ref":"e1"} clicks the link and lets the browser navigate to the same blocked loopback target.
  5. GET /snapshot?format=ai then exposes the loopback page content.

The root cause is not missing auth. It is missing post-action navigation validation in the interaction path.

PoC

Prerequisites

  • A deployment that enables the browser-control HTTP API.
  • Valid authentication for the browser-control API.
  • browser.ssrfPolicy.allowPrivateNetwork=false.
  • A local/browser-reachable loopback or private HTTP target.
  • google-chrome available on the host used for verification.

Reproduction Steps

  1. Download the exploit driver from: verification_test.py

  2. Download the shared environment helper from: common.py

  3. Optionally download the baseline control from: control-policy_enforced.py

  4. Place the scripts in the same directory and run:

    python3 verification_test.py
  5. The script will:

    • start the real browser-control server from repository code,
    • start a real headless Chrome instance,
    • start a loopback canary HTTP server,
    • verify that direct POST /navigate to the canary URL is blocked,
    • navigate to a data: page containing a malicious link to the same loopback canary,
    • call GET /snapshot?format=ai&interactive=1&compact=1,
    • click the returned ref through POST /act,
    • and finally confirm the browser landed on the blocked loopback URL and exposed the canary content.
  6. Run the control script to confirm the baseline behavior:

    python3 control-policy_enforced.py

Log of Evidence

Exploit log excerpt:

verification_mode=End-to-End
browser_server=http://127.0.0.1:39963
canary_url=http://127.0.0.1:57647/
direct_navigate_status=400
direct_navigate_payload={"error": "Blocked: private/internal IP address"}
pre_click_snapshot={"snapshot": "- link \"go\" [ref=e1]", "refs": {"e1": {"role": "link", "name": "go"}}}
click_ref=e1
post_click_snapshot={"url": "http://127.0.0.1:57647/", "snapshot": "- heading \"openclaw-cn-ghsa-536q-mj95-h29h-canary\" [level=1] [ref=e2]"}
independent_observation_final_url=http://127.0.0.1:57647/
independent_observation_contains_canary=True
result=[DEFECT-CONFIRMED]

Control log excerpt:

verification_mode=End-to-End
browser_server=http://127.0.0.1:59309
blocked_target=http://127.0.0.1:35279/
navigate_status=400
navigate_payload={"error": "Blocked: private/internal IP address"}
result=[CONTROL-BLOCKED-AS-EXPECTED]

Impact

This is an SSRF-style private-network access control bypass in the browser-control subsystem. An authenticated browser-control caller can use the controlled browser as a read-capable pivot into loopback or private HTTP resources that the configured SSRF policy was supposed to block. The immediate assets at risk are:

  • localhost-only admin/debug HTTP endpoints,
  • private-network dashboards or metadata-style services reachable from the browser host,
  • and any HTTP content that should have remained inaccessible when browser.ssrfPolicy.allowPrivateNetwork=false is enabled.

The impact is bounded by the product's trusted-operator model, but it still breaks a documented defense that operators may rely on for browser automation isolation.

Affected products

  • Ecosystem: npm
  • Package name: openclaw-cn
  • Affected versions: <= 0.2.1
  • Patched versions:

Severity

  • Severity: Medium
  • Vector string: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N

Weaknesses

  • CWE: CWE-918: Server-Side Request Forgery (SSRF)

Occurrences

Permalink Description
export async function navigateViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
url: string;
timeoutMs?: number;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ url: string }> {
const url = String(opts.url ?? "").trim();
if (!url) throw new Error("url is required");
await assertBrowserNavigationAllowed({
url,
ssrfPolicy: opts.ssrfPolicy,
});
const page = await getPageForTargetId(opts);
ensurePageState(page);
await page.goto(url, {
timeout: Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000)),
});
return { url: page.url() };
navigateViaPlaywright() enforces assertBrowserNavigationAllowed() before direct browser navigation, showing the intended SSRF protection on the explicit navigation path.
if (format === "ai") {
const pw = await requirePwAi(res, "ai snapshot");
if (!pw) return;
const wantsRoleSnapshot =
labels === true ||
mode === "efficient" ||
interactive === true ||
compact === true ||
depth !== undefined ||
Boolean(selector.trim()) ||
Boolean(frameSelector.trim());
const snap = wantsRoleSnapshot
? await pw.snapshotRoleViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
selector: selector.trim() || undefined,
frameSelector: frameSelector.trim() || undefined,
refsMode,
options: {
interactive: interactive ?? undefined,
compact: compact ?? undefined,
maxDepth: depth ?? undefined,
},
})
: await pw
.snapshotAiViaPlaywright({
GET /snapshot?format=ai&interactive=1&compact=1 can switch into snapshotRoleViaPlaywright() and return a reusable clickable ref for attacker-controlled page content.
const modifiersRaw = toStringArray(body.modifiers) ?? [];
const parsedModifiers = parseClickModifiers(modifiersRaw);
if (parsedModifiers.error) {
return jsonError(res, 400, parsedModifiers.error);
}
const modifiers = parsedModifiers.modifiers;
const clickRequest: Parameters<typeof pw.clickViaPlaywright>[0] = {
cdpUrl,
targetId: tab.targetId,
ref,
doubleClick,
};
if (button) clickRequest.button = button;
if (modifiers) clickRequest.modifiers = modifiers;
if (timeoutMs) clickRequest.timeoutMs = timeoutMs;
await pw.clickViaPlaywright(clickRequest);
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
The /act click route builds a click request and forwards it to clickViaPlaywright() without any SSRF policy field or post-click destination validation.
export async function clickViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ref: string;
doubleClick?: boolean;
button?: "left" | "right" | "middle";
modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">;
timeoutMs?: number;
}): Promise<void> {
const page = await getPageForTargetId({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
});
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
const ref = requireRef(opts.ref);
const locator = refLocator(page, ref);
const timeout = Math.max(500, Math.min(60_000, Math.floor(opts.timeoutMs ?? 8000)));
try {
if (opts.doubleClick) {
await locator.dblclick({
timeout,
button: opts.button,
modifiers: opts.modifiers,
});
} else {
await locator.click({
timeout,
button: opts.button,
modifiers: opts.modifiers,
clickViaPlaywright() resolves the attacker-controlled ref and calls locator.click() directly, allowing the browser to follow a loopback/private link with no navigation revalidation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions