Skip to content

Commit 411ea98

Browse files
committed
Auto-merge upstream openclaw/openclaw
2 parents 411fcab + f64c84a commit 411ea98

38 files changed

Lines changed: 1453 additions & 513 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ Docs: https://docs.openclaw.ai
108108
- Config/plugins: use plugin-owned command alias metadata when `plugins.allow` contains runtime command names like `dreaming`, and point users at the owning plugin instead of stale plugin-not-found guidance. (#64242) Thanks @feiskyer.
109109
- Agents/Gemini: strip orphaned `required` entries from Gemini tool schemas so provider validation no longer rejects tools after schema cleanup or union flattening. (#64284) Thanks @xxxxxmax.
110110
- Assistant text: strip Qwen-style XML tool call payloads from visible replies so web and channel messages no longer show raw `<tool_call><function=...>` output. (#64214) Thanks @MoerAI.
111+
- Daemon/gateway: prevent systemd restart storms on configuration errors by exiting with `EX_CONFIG` and adding generated unit restart-prevention guards. (#63913) Thanks @neo1027144-creator.
112+
- Agents/exec: prevent gateway crash ("Agent listener invoked outside active run") when a subagent exec tool produces stdout/stderr after the agent run has ended or been aborted. (#62821) Thanks @openperf.
113+
- Browser/tabs: route `/tabs/action` close/select through the same browser endpoint reachability and policy checks as list/new (including Playwright-backed remote tab operations), reject CDP HTTP redirects on probe requests, and sanitize blocked-endpoint error responses so tab list/focus/close flows fail closed without echoing raw policy details back to callers. (#63332)
111114

112115
## 2026.4.9
113116

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { SsrFBlockedError } from "../infra/net/ssrf.js";
3+
4+
vi.mock("./cdp-proxy-bypass.js", () => ({
5+
getDirectAgentForCdp: vi.fn(() => null),
6+
withNoProxyForCdpUrl: vi.fn(async (_url: string, fn: () => Promise<unknown>) => await fn()),
7+
}));
8+
9+
const { assertCdpEndpointAllowed, fetchCdpChecked } = await import("./cdp.helpers.js");
10+
const { BrowserCdpEndpointBlockedError } = await import("./errors.js");
11+
12+
describe("fetchCdpChecked", () => {
13+
afterEach(() => {
14+
vi.unstubAllGlobals();
15+
});
16+
17+
it("disables automatic redirect following for CDP HTTP probes", async () => {
18+
const fetchSpy = vi.fn().mockResolvedValue(
19+
new Response(null, {
20+
status: 302,
21+
headers: { Location: "http://127.0.0.1:9222/json/version" },
22+
}),
23+
);
24+
vi.stubGlobal("fetch", fetchSpy);
25+
26+
await expect(fetchCdpChecked("https://browser.example/json/version", 50)).rejects.toThrow(
27+
"CDP endpoint redirects are not allowed",
28+
);
29+
30+
const init = fetchSpy.mock.calls[0]?.[1];
31+
expect(init?.redirect).toBe("manual");
32+
});
33+
});
34+
35+
describe("assertCdpEndpointAllowed", () => {
36+
it("rethrows SSRF policy failures as BrowserCdpEndpointBlockedError so mapping can distinguish endpoint vs navigation", async () => {
37+
await expect(
38+
assertCdpEndpointAllowed("http://10.0.0.42:9222", { dangerouslyAllowPrivateNetwork: false }),
39+
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
40+
});
41+
42+
it("does not wrap non-SSRF failures", async () => {
43+
await expect(
44+
assertCdpEndpointAllowed("file:///etc/passwd", { dangerouslyAllowPrivateNetwork: false }),
45+
).rejects.not.toBeInstanceOf(BrowserCdpEndpointBlockedError);
46+
});
47+
48+
it("leaves navigation-target SsrFBlockedError alone for callers that never hit the endpoint helper", () => {
49+
// Sanity check that raw SsrFBlockedError is still its own class and is not
50+
// accidentally converted by the endpoint helper import.
51+
expect(new SsrFBlockedError("blocked")).toBeInstanceOf(SsrFBlockedError);
52+
});
53+
});

extensions/browser/src/browser/cdp.helpers.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
22
import WebSocket from "ws";
33
import { isLoopbackHost } from "../gateway/net.js";
4-
import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js";
4+
import {
5+
SsrFBlockedError,
6+
type SsrFPolicy,
7+
resolvePinnedHostnameWithPolicy,
8+
} from "../infra/net/ssrf.js";
59
import { rawDataToString } from "../infra/ws.js";
610
import { redactSensitiveText } from "../logging/redact.js";
711
import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
812
import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
13+
import { BrowserCdpEndpointBlockedError } from "./errors.js";
914
import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js";
1015

1116
export { isLoopbackHost };
@@ -62,9 +67,19 @@ export async function assertCdpEndpointAllowed(
6267
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
6368
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
6469
}
65-
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
66-
policy: ssrfPolicy,
67-
});
70+
try {
71+
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
72+
policy: ssrfPolicy,
73+
});
74+
} catch (err) {
75+
// Rethrow SSRF policy failures against the CDP endpoint itself as a
76+
// browser-endpoint-scoped error so the route mapping does not confuse
77+
// them with navigation-target policy blocks.
78+
if (err instanceof SsrFBlockedError) {
79+
throw new BrowserCdpEndpointBlockedError({ cause: err });
80+
}
81+
throw err;
82+
}
6883
}
6984

7085
export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined {
@@ -231,9 +246,15 @@ export async function fetchCdpChecked(
231246
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
232247
try {
233248
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
249+
// Block redirects on all CDP HTTP paths (not just probes) because a
250+
// redirect to an internal host is an SSRF vector regardless of whether
251+
// the call is /json/version, /json/list, /json/activate, or /json/close.
234252
const res = await withNoProxyForCdpUrl(url, () =>
235-
fetch(url, { ...init, headers, signal: ctrl.signal }),
253+
fetch(url, { ...init, headers, redirect: "manual", signal: ctrl.signal }),
236254
);
255+
if (res.status >= 300 && res.status < 400) {
256+
throw new Error("CDP endpoint redirects are not allowed");
257+
}
237258
if (!res.ok) {
238259
if (res.status === 429) {
239260
// Do not reflect upstream response text into the error surface (log/agent injection risk)

extensions/browser/src/browser/errors.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { describe, expect, it } from "vitest";
2-
import { BrowserValidationError, toBrowserErrorResponse } from "./errors.js";
2+
import { SsrFBlockedError } from "../infra/net/ssrf.js";
3+
import {
4+
BROWSER_ENDPOINT_BLOCKED_MESSAGE,
5+
BROWSER_NAVIGATION_BLOCKED_MESSAGE,
6+
BrowserCdpEndpointBlockedError,
7+
BrowserValidationError,
8+
toBrowserErrorResponse,
9+
} from "./errors.js";
310

411
describe("browser error mapping", () => {
512
it("maps blocked browser targets to conflict responses", () => {
@@ -20,4 +27,22 @@ describe("browser error mapping", () => {
2027
message: "bad input",
2128
});
2229
});
30+
31+
it("sanitizes navigation-target SSRF policy errors without leaking raw policy details", () => {
32+
expect(
33+
toBrowserErrorResponse(
34+
new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address"),
35+
),
36+
).toEqual({
37+
status: 400,
38+
message: BROWSER_NAVIGATION_BLOCKED_MESSAGE,
39+
});
40+
});
41+
42+
it("maps CDP endpoint policy blocks to a distinct endpoint-scoped message", () => {
43+
expect(toBrowserErrorResponse(new BrowserCdpEndpointBlockedError())).toEqual({
44+
status: 400,
45+
message: BROWSER_ENDPOINT_BLOCKED_MESSAGE,
46+
});
47+
});
2348
});

extensions/browser/src/browser/errors.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { SsrFBlockedError } from "../infra/net/ssrf.js";
22
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
33

4+
export const BROWSER_ENDPOINT_BLOCKED_MESSAGE = "browser endpoint blocked by policy";
5+
export const BROWSER_NAVIGATION_BLOCKED_MESSAGE = "browser navigation blocked by policy";
6+
47
export class BrowserError extends Error {
58
status: number;
69

@@ -11,6 +14,18 @@ export class BrowserError extends Error {
1114
}
1215
}
1316

17+
/**
18+
* Raised when a browser CDP endpoint (the cdpUrl itself) fails the
19+
* configured SSRF policy. Distinct from a blocked navigation target so
20+
* callers see "fix your browser endpoint config" rather than "fix your
21+
* navigation URL".
22+
*/
23+
export class BrowserCdpEndpointBlockedError extends BrowserError {
24+
constructor(options?: ErrorOptions) {
25+
super(BROWSER_ENDPOINT_BLOCKED_MESSAGE, 400, options);
26+
}
27+
}
28+
1429
export class BrowserValidationError extends BrowserError {
1530
constructor(message: string, options?: ErrorOptions) {
1631
super(message, 400, options);
@@ -76,7 +91,12 @@ export function toBrowserErrorResponse(err: unknown): {
7691
return { status: 409, message: err.message };
7792
}
7893
if (err instanceof SsrFBlockedError) {
79-
return { status: 400, message: err.message };
94+
// SsrFBlockedError from this point is from a navigation-target check
95+
// (assertBrowserNavigationAllowed / resolvePinnedHostnameWithPolicy on a
96+
// requested URL). CDP endpoint blocks are rethrown as
97+
// BrowserCdpEndpointBlockedError by assertCdpEndpointAllowed and handled
98+
// by the BrowserError branch above.
99+
return { status: 400, message: BROWSER_NAVIGATION_BLOCKED_MESSAGE };
80100
}
81101
if (
82102
err instanceof InvalidBrowserNavigationUrlError ||

extensions/browser/src/browser/pw-session.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { SsrFBlockedError, type SsrFPolicy } from "../infra/net/ssrf.js";
1414
import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
1515
import {
1616
appendCdpPath,
17+
assertCdpEndpointAllowed,
1718
fetchJson,
1819
getHeadersWithAuth,
1920
normalizeCdpHttpBaseForJsonEndpoints,
@@ -424,12 +425,15 @@ function observeBrowser(browser: Browser) {
424425
}
425426
}
426427

427-
async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
428+
async function connectBrowser(cdpUrl: string, ssrfPolicy?: SsrFPolicy): Promise<ConnectedBrowser> {
428429
const normalized = normalizeCdpUrl(cdpUrl);
429430
const cached = cachedByCdpUrl.get(normalized);
430431
if (cached) {
431432
return cached;
432433
}
434+
// Run SSRF policy check only on cache miss so transient DNS failures
435+
// do not break active sessions that already hold a live CDP connection.
436+
await assertCdpEndpointAllowed(normalized, ssrfPolicy);
433437
const connecting = connectingByCdpUrl.get(normalized);
434438
if (connecting) {
435439
return await connecting;
@@ -440,7 +444,9 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
440444
for (let attempt = 0; attempt < 3; attempt += 1) {
441445
try {
442446
const timeout = 5000 + attempt * 2000;
443-
const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null);
447+
const wsUrl = await getChromeWebSocketUrl(normalized, timeout, ssrfPolicy).catch(
448+
() => null,
449+
);
444450
const endpoint = wsUrl ?? normalized;
445451
const headers = getHeadersWithAuth(endpoint);
446452
// Bypass proxy for loopback CDP connections (#31219)
@@ -562,8 +568,10 @@ async function findPageByTargetIdViaTargetList(
562568
pages: Page[],
563569
targetId: string,
564570
cdpUrl: string,
571+
ssrfPolicy?: SsrFPolicy,
565572
): Promise<Page | null> {
566573
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl);
574+
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
567575
const targets = await fetchJson<
568576
Array<{
569577
id: string;
@@ -578,6 +586,7 @@ async function findPageByTargetId(
578586
browser: Browser,
579587
targetId: string,
580588
cdpUrl?: string,
589+
ssrfPolicy?: SsrFPolicy,
581590
): Promise<Page | null> {
582591
const pages = await getAllPages(browser);
583592
let resolvedViaCdp = false;
@@ -595,7 +604,7 @@ async function findPageByTargetId(
595604
}
596605
if (cdpUrl) {
597606
try {
598-
return await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl);
607+
return await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl, ssrfPolicy);
599608
} catch {
600609
// Ignore fetch errors and fall through to return null.
601610
}
@@ -609,12 +618,13 @@ async function findPageByTargetId(
609618
async function resolvePageByTargetIdOrThrow(opts: {
610619
cdpUrl: string;
611620
targetId: string;
621+
ssrfPolicy?: SsrFPolicy;
612622
}): Promise<Page> {
613623
if (isBlockedTarget(opts.cdpUrl, opts.targetId)) {
614624
throw new BlockedBrowserTargetError();
615625
}
616-
const { browser } = await connectBrowser(opts.cdpUrl);
617-
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
626+
const { browser } = await connectBrowser(opts.cdpUrl, opts.ssrfPolicy);
627+
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl, opts.ssrfPolicy);
618628
if (!page) {
619629
throw new BrowserTabNotFoundError();
620630
}
@@ -624,11 +634,12 @@ async function resolvePageByTargetIdOrThrow(opts: {
624634
export async function getPageForTargetId(opts: {
625635
cdpUrl: string;
626636
targetId?: string;
637+
ssrfPolicy?: SsrFPolicy;
627638
}): Promise<Page> {
628639
if (opts.targetId && isBlockedTarget(opts.cdpUrl, opts.targetId)) {
629640
throw new BlockedBrowserTargetError();
630641
}
631-
const { browser } = await connectBrowser(opts.cdpUrl);
642+
const { browser } = await connectBrowser(opts.cdpUrl, opts.ssrfPolicy);
632643
const pages = await getAllPages(browser);
633644
if (!pages.length) {
634645
throw new Error("No pages available in the connected browser.");
@@ -648,7 +659,7 @@ export async function getPageForTargetId(opts: {
648659
if (!opts.targetId) {
649660
return first;
650661
}
651-
const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
662+
const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl, opts.ssrfPolicy);
652663
if (found) {
653664
if (isBlockedPageRef(opts.cdpUrl, found)) {
654665
throw new BlockedBrowserTargetError();
@@ -887,7 +898,9 @@ function cdpSocketNeedsAttach(wsUrl: string): boolean {
887898
async function tryTerminateExecutionViaCdp(opts: {
888899
cdpUrl: string;
889900
targetId: string;
901+
ssrfPolicy?: SsrFPolicy;
890902
}): Promise<void> {
903+
await assertCdpEndpointAllowed(opts.cdpUrl, opts.ssrfPolicy);
891904
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(opts.cdpUrl);
892905
const listUrl = appendCdpPath(cdpHttpBase, "/json/list");
893906

@@ -976,6 +989,7 @@ export async function forceDisconnectPlaywrightForTarget(opts: {
976989
cdpUrl: string;
977990
targetId?: string;
978991
reason?: string;
992+
ssrfPolicy?: SsrFPolicy;
979993
}): Promise<void> {
980994
const normalized = normalizeCdpUrl(opts.cdpUrl);
981995
const cur = cachedByCdpUrl.get(normalized);
@@ -996,7 +1010,7 @@ export async function forceDisconnectPlaywrightForTarget(opts: {
9961010
// disconnect Playwright's CDP connection.
9971011
const targetId = normalizeOptionalString(opts.targetId) ?? "";
9981012
if (targetId) {
999-
await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {});
1013+
await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId, ssrfPolicy: opts.ssrfPolicy }).catch(() => {});
10001014
}
10011015

10021016
// Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe.
@@ -1007,15 +1021,18 @@ export async function forceDisconnectPlaywrightForTarget(opts: {
10071021
* List all pages/tabs from the persistent Playwright connection.
10081022
* Used for remote profiles where HTTP-based /json/list is ephemeral.
10091023
*/
1010-
export async function listPagesViaPlaywright(opts: { cdpUrl: string }): Promise<
1024+
export async function listPagesViaPlaywright(opts: {
1025+
cdpUrl: string;
1026+
ssrfPolicy?: SsrFPolicy;
1027+
}): Promise<
10111028
Array<{
10121029
targetId: string;
10131030
title: string;
10141031
url: string;
10151032
type: string;
10161033
}>
10171034
> {
1018-
const { browser } = await connectBrowser(opts.cdpUrl);
1035+
const { browser } = await connectBrowser(opts.cdpUrl, opts.ssrfPolicy);
10191036
const pages = await getAllPages(browser);
10201037
const results: Array<{
10211038
targetId: string;
@@ -1056,7 +1073,7 @@ export async function createPageViaPlaywright(opts: {
10561073
url: string;
10571074
type: string;
10581075
}> {
1059-
const { browser } = await connectBrowser(opts.cdpUrl);
1076+
const { browser } = await connectBrowser(opts.cdpUrl, opts.ssrfPolicy);
10601077
const context = browser.contexts()[0] ?? (await browser.newContext());
10611078
ensureContextState(context);
10621079

@@ -1119,6 +1136,7 @@ export async function createPageViaPlaywright(opts: {
11191136
export async function closePageByTargetIdViaPlaywright(opts: {
11201137
cdpUrl: string;
11211138
targetId: string;
1139+
ssrfPolicy?: SsrFPolicy;
11221140
}): Promise<void> {
11231141
const page = await resolvePageByTargetIdOrThrow(opts);
11241142
await page.close();
@@ -1131,6 +1149,7 @@ export async function closePageByTargetIdViaPlaywright(opts: {
11311149
export async function focusPageByTargetIdViaPlaywright(opts: {
11321150
cdpUrl: string;
11331151
targetId: string;
1152+
ssrfPolicy?: SsrFPolicy;
11341153
}): Promise<void> {
11351154
const page = await resolvePageByTargetIdOrThrow(opts);
11361155
try {

0 commit comments

Comments
 (0)