Skip to content

[Security] Authenticated /act evaluation bypasses the browser private-network navigation guard #569

Description

@YLChen-007

Advisory Details

Title: Authenticated /act evaluation bypasses the browser private-network navigation guard

Description:

The browser control HTTP API in openclaw-cn allows an authenticated caller to use POST /act with kind:"evaluate" to navigate an existing tab to a loopback or private-network URL even when browser.ssrfPolicy.allowPrivateNetwork=false. A follow-up /act request can then read the landed page body, bypassing the configured navigation guard for direct /navigate requests.

Summary

openclaw-cn exposes a browser-control HTTP surface that is protected by token authentication and a private-network navigation policy. Direct POST /navigate requests correctly reject loopback destinations with Blocked: private/internal IP address, but POST /act kind:"evaluate" can still assign location.href inside the page context and complete the same navigation. Because the interaction flow does not propagate ssrfPolicy into the evaluate helper and does not re-check the final URL after the action, an authenticated browser-control caller can pivot the managed browser into loopback/private HTTP resources and read the response body back through a second /act evaluation.

Details

The issue is in the difference between the protected direct-navigation path and the unprotected interaction path.

The direct /navigate route explicitly passes the resolved SSRF policy into the Playwright navigation helper:

const ssrfPolicy = ctx.state().resolved.ssrfPolicy;
const result = await pw.navigateViaPlaywright({
  cdpUrl: profileCtx.profile.cdpUrl,
  targetId: tab.targetId,
  url,
  ...(ssrfPolicy ? { ssrfPolicy } : {}),
});

That helper calls assertBrowserNavigationAllowed(...) before page.goto(...), so a direct request to http://127.0.0.1:<port>/secret is rejected.

The /act evaluate route does not carry the same protection:

case "evaluate": {
  const result = await pw.evaluateViaPlaywright({
    cdpUrl,
    targetId: tab.targetId,
    fn,
    ref,
  });

evaluateViaPlaywright() resolves the page and executes the attacker-supplied function with page.evaluate(...):

const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
return await page.evaluate(browserEvaluator, fnText);

Because no ssrfPolicy is passed into this path and there is no post-action check on page.url(), a payload such as the following completes a real main-frame navigation to a blocked loopback destination:

() => { location.href = "http://127.0.0.1:<port>/secret"; return "navigating"; }

I verified the bug end-to-end against the real browser control server from this repository, a real headless Chrome instance, and a localhost canary HTTP server. The exploit path and control path use the same public HTTP API:

  • POST /tabs/open opens a safe data: page.
  • POST /navigate to the canary URL returns HTTP 400 with Blocked: private/internal IP address, confirming the policy is enabled.
  • POST /act with the location.href payload returns HTTP 200.
  • A second POST /act reading document.body.innerText returns the canary body.
  • GET /tabs independently shows that the tab URL has changed to the blocked loopback destination.

PoC

Prerequisites

  • openclaw-cn with the vulnerable browser control implementation present in tag v0.2.1.
  • Node.js 22+.
  • Python 3.
  • pnpm.
  • A local Chrome or Chromium binary available in PATH.
  • Ability to run the repository locally.
  • An authenticated browser-control caller.
    The provided PoC launches the browser control server with a temporary token config and browser.ssrfPolicy.allowPrivateNetwork=false.

Reproduction Steps

  1. Download the verification script from: verification_test.py
  2. Download the control script from: control-policy-enforced.py
  3. Download the shared harness from: lab_common.py
  4. Save the three files in the same directory.
  5. From the openclaw-cn repository root, run:
    OPENCLAW_REPO_ROOT="$PWD" python3 /path/to/verification_test.py
  6. Observe that the script starts the real browser control server, opens a safe data: page, confirms direct /navigate is blocked, then uses POST /act kind:"evaluate" to set location.href to the blocked loopback URL.
  7. Observe that the script performs a second /act readback and prints the canary body from the blocked page.
  8. Run the control case:
    OPENCLAW_REPO_ROOT="$PWD" python3 /path/to/control-policy-enforced.py
  9. Observe that the control run still shows the direct /navigate block but keeps the tab on the safe page and records zero canary hits.

Log of Evidence

Verification run on the current vulnerable code:

[node --version] v22.22.0
[python3 --version] Python 3.13.9
[pnpm --version] 10.23.0
POST /navigate status=400 body={"error":"Blocked: private/internal IP address"}
POST /act evaluate-nav status=200 body={"ok":true,...,"result":"navigating"}
POST /act readback status=200 body={"ok":true,...,"url":"http://127.0.0.1:40013/secret","result":"QMwG-QPRG-3J38-CANARY"}
GET /tabs status=200 body={"running":true,"tabs":[{"url":"http://127.0.0.1:40013/secret",...}]}
[canary hits] ['/secret', '/favicon.ico']
[DEFECT-CONFIRMED]

Control run under the same setup:

POST /navigate status=400 body={"error":"Blocked: private/internal IP address"}
POST /act baseline-read status=200 body={"ok":true,...,"result":"SAFE PAGE\ngo"}
GET /tabs status=200 body={"running":true,"tabs":[{"url":"data:text/html,<!doctype html><html><body><div id='safe'>SAFE PAGE</div>..."}]}
[canary hits] []
[CONTROL-PASS]

This differential result shows that the configured navigation guard is active for direct navigation, but the same destination becomes reachable and readable when the navigation is triggered from /act evaluation.

Impact

This is a browser-mediated SSRF and information disclosure issue on the authenticated browser-control HTTP boundary. An authenticated caller can use the managed browser as a read-capable pivot into loopback and private HTTP services reachable from the host or browser environment, even when the deployment explicitly enabled browser.ssrfPolicy.allowPrivateNetwork=false to prevent that. In practice, this can expose internal web dashboards, local callback endpoints, metadata-style services, or other browser-readable HTTP content that should have remained outside the browser-control API's allowed navigation scope.

The impact is bounded by the documented trust model in SECURITY.md: this is not an unauthenticated gateway takeover and not a multi-tenant isolation claim. The defect is that the product advertises a configured private-network navigation block, but /act evaluate bypasses that guard and restores forbidden reads.

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:N/A:N

Weaknesses

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

Occurrences

Permalink Description
case "evaluate": {
const fn = toStringOrEmpty(body.fn);
if (!fn) return jsonError(res, 400, "fn is required");
const ref = toStringOrEmpty(body.ref) || undefined;
const result = await pw.evaluateViaPlaywright({
cdpUrl,
targetId: tab.targetId,
fn,
ref,
});
The /act evaluate route forwards the caller-controlled fn into evaluateViaPlaywright() without propagating ssrfPolicy or performing any navigation re-check after the action.
const fnText = String(opts.fn ?? "").trim();
if (!fnText) throw new Error("function is required");
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
if (opts.ref) {
const locator = refLocator(page, opts.ref);
// Use Function constructor at runtime to avoid esbuild adding __name helper
// which doesn't exist in the browser context
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval
const elementEvaluator = new Function(
"el",
"fnBody",
`
"use strict";
try {
var candidate = eval("(" + fnBody + ")");
return typeof candidate === "function" ? candidate(el) : candidate;
} catch (err) {
throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
}
`,
) as (el: Element, fnBody: string) => unknown;
return await locator.evaluate(elementEvaluator, fnText);
}
// Use Function constructor at runtime to avoid esbuild adding __name helper
// which doesn't exist in the browser context
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval
const browserEvaluator = new Function(
"fnBody",
`
"use strict";
try {
var candidate = eval("(" + fnBody + ")");
return typeof candidate === "function" ? candidate() : candidate;
} catch (err) {
throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
}
`,
) as (fnBody: string) => unknown;
return await page.evaluate(browserEvaluator, fnText);
evaluateViaPlaywright() executes the attacker-supplied function with page.evaluate(...) and returns without checking whether the action navigated the page to a blocked loopback or private-network URL.

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