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
- Download the verification script from: verification_test.py
- Download the control script from: control-policy-enforced.py
- Download the shared harness from: lab_common.py
- Save the three files in the same directory.
- From the
openclaw-cn repository root, run:
OPENCLAW_REPO_ROOT="$PWD" python3 /path/to/verification_test.py
- 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.
- Observe that the script performs a second
/act readback and prints the canary body from the blocked page.
- Run the control case:
OPENCLAW_REPO_ROOT="$PWD" python3 /path/to/control-policy-enforced.py
- 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. |
Advisory Details
Title: Authenticated
/actevaluation bypasses the browser private-network navigation guardDescription:
The browser control HTTP API in
openclaw-cnallows an authenticated caller to usePOST /actwithkind:"evaluate"to navigate an existing tab to a loopback or private-network URL even whenbrowser.ssrfPolicy.allowPrivateNetwork=false. A follow-up/actrequest can then read the landed page body, bypassing the configured navigation guard for direct/navigaterequests.Summary
openclaw-cnexposes a browser-control HTTP surface that is protected by token authentication and a private-network navigation policy. DirectPOST /navigaterequests correctly reject loopback destinations withBlocked: private/internal IP address, butPOST /actkind:"evaluate"can still assignlocation.hrefinside the page context and complete the same navigation. Because the interaction flow does not propagatessrfPolicyinto 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/actevaluation.Details
The issue is in the difference between the protected direct-navigation path and the unprotected interaction path.
The direct
/navigateroute explicitly passes the resolved SSRF policy into the Playwright navigation helper:That helper calls
assertBrowserNavigationAllowed(...)beforepage.goto(...), so a direct request tohttp://127.0.0.1:<port>/secretis rejected.The
/actevaluateroute does not carry the same protection:evaluateViaPlaywright()resolves the page and executes the attacker-supplied function withpage.evaluate(...):Because no
ssrfPolicyis passed into this path and there is no post-action check onpage.url(), a payload such as the following completes a real main-frame navigation to a blocked loopback destination: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/openopens a safedata:page.POST /navigateto the canary URL returns HTTP 400 withBlocked: private/internal IP address, confirming the policy is enabled.POST /actwith thelocation.hrefpayload returns HTTP 200.POST /actreadingdocument.body.innerTextreturns the canary body.GET /tabsindependently shows that the tab URL has changed to the blocked loopback destination.PoC
Prerequisites
openclaw-cnwith the vulnerable browser control implementation present in tagv0.2.1.pnpm.PATH.The provided PoC launches the browser control server with a temporary token config and
browser.ssrfPolicy.allowPrivateNetwork=false.Reproduction Steps
openclaw-cnrepository root, run:OPENCLAW_REPO_ROOT="$PWD" python3 /path/to/verification_test.pydata:page, confirms direct/navigateis blocked, then usesPOST /actkind:"evaluate"to setlocation.hrefto the blocked loopback URL./actreadback and prints the canary body from the blocked page.OPENCLAW_REPO_ROOT="$PWD" python3 /path/to/control-policy-enforced.py/navigateblock but keeps the tab on the safe page and records zero canary hits.Log of Evidence
Verification run on the current vulnerable code:
Control run under the same setup:
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
/actevaluation.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=falseto 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/actevaluatebypasses that guard and restores forbidden reads.Affected products
Severity
Weaknesses
Occurrences
openclaw-cn/src/browser/routes/agent.act.ts
Lines 253 to 262 in 558f272
/actevaluateroute forwards the caller-controlledfnintoevaluateViaPlaywright()without propagatingssrfPolicyor performing any navigation re-check after the action.openclaw-cn/src/browser/pw-tools-core.interactions.ts
Lines 217 to 257 in 558f272
evaluateViaPlaywright()executes the attacker-supplied function withpage.evaluate(...)and returns without checking whether the action navigated the page to a blocked loopback or private-network URL.