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:
POST /navigate to http://127.0.0.1:<port>/ is rejected with Blocked: private/internal IP address.
POST /navigate to a benign data: page is allowed.
GET /snapshot?format=ai&interactive=1&compact=1 returns a link ref from that page.
POST /act {"kind":"click","ref":"e1"} clicks the link and lets the browser navigate to the same blocked loopback target.
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
-
Download the exploit driver from: verification_test.py
-
Download the shared environment helper from: common.py
-
Optionally download the baseline control from: control-policy_enforced.py
-
Place the scripts in the same directory and run:
python3 verification_test.py
-
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.
-
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. |
Advisory Details
Title: Browser interaction-driven navigation bypasses
browser.ssrfPolicyand reaches loopback/private targetsDescription:
Summary
The authenticated browser-control HTTP API in
openclaw-cnenforcesbrowser.ssrfPolicy.allowPrivateNetwork=falsefor direct/navigaterequests, but it does not re-validate the final URL after a navigation-capable/actclick. An attacker who can call the browser-control API can first load attacker-controlled HTML, then use/snapshotto obtain a clickable ref and/actto 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:By contrast, the
/actroute builds a click request and forwards it toclickViaPlaywright()without anyssrfPolicyplumbing:clickViaPlaywright()then resolves the ref and performs the browser click directly:This means the security boundary is inconsistent:
POST /navigatetohttp://127.0.0.1:<port>/is rejected withBlocked: private/internal IP address.POST /navigateto a benigndata:page is allowed.GET /snapshot?format=ai&interactive=1&compact=1returns a link ref from that page.POST /act {"kind":"click","ref":"e1"}clicks the link and lets the browser navigate to the same blocked loopback target.GET /snapshot?format=aithen 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
browser.ssrfPolicy.allowPrivateNetwork=false.google-chromeavailable on the host used for verification.Reproduction Steps
Download the exploit driver from: verification_test.py
Download the shared environment helper from: common.py
Optionally download the baseline control from: control-policy_enforced.py
Place the scripts in the same directory and run:
The script will:
POST /navigateto the canary URL is blocked,data:page containing a malicious link to the same loopback canary,GET /snapshot?format=ai&interactive=1&compact=1,POST /act,Run the control script to confirm the baseline behavior:
Log of Evidence
Exploit log excerpt:
Control log excerpt:
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:
browser.ssrfPolicy.allowPrivateNetwork=falseis 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
Severity
Weaknesses
Occurrences
openclaw-cn/src/browser/pw-tools-core.snapshot.ts
Lines 160 to 178 in 558f272
navigateViaPlaywright()enforcesassertBrowserNavigationAllowed()before direct browser navigation, showing the intended SSRF protection on the explicit navigation path.openclaw-cn/src/browser/routes/agent.snapshot.ts
Lines 201 to 227 in 558f272
GET /snapshot?format=ai&interactive=1&compact=1can switch intosnapshotRoleViaPlaywright()and return a reusable clickable ref for attacker-controlled page content.openclaw-cn/src/browser/routes/agent.act.ts
Lines 64 to 80 in 558f272
/actclick route builds a click request and forwards it toclickViaPlaywright()without any SSRF policy field or post-click destination validation.openclaw-cn/src/browser/pw-tools-core.interactions.ts
Lines 26 to 55 in 558f272
clickViaPlaywright()resolves the attacker-controlled ref and callslocator.click()directly, allowing the browser to follow a loopback/private link with no navigation revalidation.