mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
fix(browser): tighten strict browser hostname navigation (#64367)
* fix(browser): tighten strict browser hostname navigation * fix(browser): address review follow-ups * chore(changelog): add strict browser hostname navigation entry * fix(browser): remove stale state prop from SelectionDeps call site The PR's SelectionDeps uses getSsrFPolicy instead of the full state object; the state property was leftover from an earlier iteration. --------- Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
@@ -133,6 +133,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/security: apply SSRF navigation policy to subframe document navigations so iframe-targeted private-network hops are blocked without quarantining the parent page. (#64371) Thanks @eleqtrizit.
|
||||
- Hooks/security: mark agent hook system events as untrusted and sanitize hook display names before cron metadata reuse. (#64372) Thanks @eleqtrizit.
|
||||
- Media/security: honor sender-scoped `toolsBySender` policy for outbound host-media reads so denied senders cannot trigger host file disclosure via attachment hydration. (#64459) Thanks @eleqtrizit.
|
||||
- Browser/security: reject strict-policy hostname navigation unless the hostname is an explicit allowlist exception or IP literal, and route CDP HTTP discovery through the pinned SSRF fetch path. (#64367) Thanks @eleqtrizit.
|
||||
## 2026.4.9
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,53 +1,65 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
||||
|
||||
vi.mock("./cdp-proxy-bypass.js", () => ({
|
||||
getDirectAgentForCdp: vi.fn(() => null),
|
||||
withNoProxyForCdpUrl: vi.fn(async (_url: string, fn: () => Promise<unknown>) => await fn()),
|
||||
}));
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const { assertCdpEndpointAllowed, fetchCdpChecked } = await import("./cdp.helpers.js");
|
||||
const { BrowserCdpEndpointBlockedError } = await import("./errors.js");
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe("fetchCdpChecked", () => {
|
||||
import { fetchJson, fetchOk } from "./cdp.helpers.js";
|
||||
|
||||
describe("cdp helpers", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
});
|
||||
|
||||
it("disables automatic redirect following for CDP HTTP probes", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: "http://127.0.0.1:9222/json/version" },
|
||||
it("releases guarded CDP fetches after the response body is consumed", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const json = vi.fn(async () => {
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
return { ok: true };
|
||||
});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json,
|
||||
},
|
||||
release,
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchJson("http://127.0.0.1:9222/json/version", 250, undefined, {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
allowedHostnames: ["127.0.0.1"],
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
await expect(fetchCdpChecked("https://example.com/json/version", 50)).rejects.toThrow(
|
||||
"CDP endpoint redirects are not allowed",
|
||||
);
|
||||
expect(json).toHaveBeenCalledTimes(1);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const init = fetchSpy.mock.calls[0]?.[1];
|
||||
expect(init?.redirect).toBe("manual");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertCdpEndpointAllowed", () => {
|
||||
it("rethrows SSRF policy failures as BrowserCdpEndpointBlockedError so mapping can distinguish endpoint vs navigation", async () => {
|
||||
await expect(
|
||||
assertCdpEndpointAllowed("http://10.0.0.42:9222", { dangerouslyAllowPrivateNetwork: false }),
|
||||
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
|
||||
});
|
||||
|
||||
it("does not wrap non-SSRF failures", async () => {
|
||||
await expect(
|
||||
assertCdpEndpointAllowed("file:///etc/passwd", { dangerouslyAllowPrivateNetwork: false }),
|
||||
).rejects.not.toBeInstanceOf(BrowserCdpEndpointBlockedError);
|
||||
});
|
||||
|
||||
it("leaves navigation-target SsrFBlockedError alone for callers that never hit the endpoint helper", () => {
|
||||
// Sanity check that raw SsrFBlockedError is still its own class and is not
|
||||
// accidentally converted by the endpoint helper import.
|
||||
expect(new SsrFBlockedError("blocked")).toBeInstanceOf(SsrFBlockedError);
|
||||
it("releases guarded CDP fetches for bodyless requests", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: {
|
||||
ok: true,
|
||||
status: 200,
|
||||
},
|
||||
release,
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchOk("http://127.0.0.1:9222/json/close/TARGET_1", 250, undefined, {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
allowedHostnames: ["127.0.0.1"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,11 @@ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import WebSocket from "ws";
|
||||
import { isLoopbackHost } from "../gateway/net.js";
|
||||
import {
|
||||
SsrFBlockedError,
|
||||
type SsrFPolicy,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
} from "../infra/net/ssrf.js";
|
||||
import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { redactSensitiveText } from "../logging/redact.js";
|
||||
import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
|
||||
import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
|
||||
import { BrowserCdpEndpointBlockedError } from "./errors.js";
|
||||
import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js";
|
||||
|
||||
export { isLoopbackHost };
|
||||
@@ -68,19 +63,9 @@ export async function assertCdpEndpointAllowed(
|
||||
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
|
||||
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
|
||||
}
|
||||
try {
|
||||
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
||||
policy: ssrfPolicy,
|
||||
});
|
||||
} catch (err) {
|
||||
// Rethrow SSRF policy failures against the CDP endpoint itself as a
|
||||
// browser-endpoint-scoped error so the route mapping does not confuse
|
||||
// them with navigation-target policy blocks.
|
||||
if (err instanceof SsrFBlockedError) {
|
||||
throw new BrowserCdpEndpointBlockedError({ cause: err });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
||||
policy: ssrfPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined {
|
||||
@@ -168,6 +153,11 @@ export function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
type CdpFetchResult = {
|
||||
response: Response;
|
||||
release: () => Promise<void>;
|
||||
};
|
||||
|
||||
function createCdpSender(ws: WebSocket) {
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, Pending>();
|
||||
@@ -233,39 +223,50 @@ export async function fetchJson<T>(
|
||||
url: string,
|
||||
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
|
||||
init?: RequestInit,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): Promise<T> {
|
||||
const res = await fetchCdpChecked(url, timeoutMs, init);
|
||||
return (await res.json()) as T;
|
||||
const { response, release } = await fetchCdpChecked(url, timeoutMs, init, ssrfPolicy);
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCdpChecked(
|
||||
url: string,
|
||||
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): Promise<CdpFetchResult> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
let guardedRelease: (() => Promise<void>) | undefined;
|
||||
let released = false;
|
||||
const release = async () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
clearTimeout(t);
|
||||
await guardedRelease?.();
|
||||
};
|
||||
try {
|
||||
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
||||
// Block redirects on all CDP HTTP paths (not just probes) because a
|
||||
// redirect to an internal host is an SSRF vector regardless of whether
|
||||
// the call is /json/version, /json/list, /json/activate, or /json/close.
|
||||
const guarded = await withNoProxyForCdpUrl(url, () =>
|
||||
fetchWithSsrFGuard({
|
||||
url,
|
||||
init: { ...init, headers },
|
||||
maxRedirects: 0,
|
||||
policy: { allowPrivateNetwork: true },
|
||||
signal: ctrl.signal,
|
||||
auditContext: "browser-cdp",
|
||||
}),
|
||||
);
|
||||
release = guarded.release;
|
||||
const res = guarded.response;
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
throw new Error("CDP endpoint redirects are not allowed");
|
||||
}
|
||||
const res = await withNoProxyForCdpUrl(url, async () => {
|
||||
if (ssrfPolicy) {
|
||||
const guarded = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: { ...init, headers },
|
||||
signal: ctrl.signal,
|
||||
policy: ssrfPolicy,
|
||||
auditContext: "browser-cdp",
|
||||
});
|
||||
guardedRelease = guarded.release;
|
||||
return guarded.response;
|
||||
}
|
||||
return await fetch(url, { ...init, headers, signal: ctrl.signal });
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 429) {
|
||||
// Do not reflect upstream response text into the error surface (log/agent injection risk)
|
||||
@@ -273,23 +274,10 @@ export async function fetchCdpChecked(
|
||||
}
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
if (typeof res.arrayBuffer !== "function") {
|
||||
return res;
|
||||
}
|
||||
const body = await res.arrayBuffer();
|
||||
return new Response(body, {
|
||||
headers: res.headers,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
});
|
||||
return { response: res, release };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Too many redirects")) {
|
||||
throw new Error("CDP endpoint redirects are not allowed", { cause: error });
|
||||
}
|
||||
await release();
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
await release?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,8 +285,10 @@ export async function fetchOk(
|
||||
url: string,
|
||||
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
|
||||
init?: RequestInit,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): Promise<void> {
|
||||
await fetchCdpChecked(url, timeoutMs, init);
|
||||
const { release } = await fetchCdpChecked(url, timeoutMs, init, ssrfPolicy);
|
||||
await release();
|
||||
}
|
||||
|
||||
export function openCdpWebSocket(
|
||||
|
||||
@@ -186,6 +186,22 @@ describe("cdp", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks hostname navigation targets when strict SSRF policy is configured", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
try {
|
||||
await expect(
|
||||
createTargetViaCdp({
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
url: "https://example.com",
|
||||
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks unsupported non-network navigation URLs", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
try {
|
||||
@@ -236,7 +252,7 @@ describe("cdp", () => {
|
||||
await expect(
|
||||
createTargetViaCdp({
|
||||
cdpUrl: `http://127.0.0.1:${httpPort}`,
|
||||
url: "https://example.com",
|
||||
url: "https://93.184.216.34",
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
allowedHostnames: ["127.0.0.1"],
|
||||
@@ -249,7 +265,7 @@ describe("cdp", () => {
|
||||
await expect(
|
||||
createTargetViaCdp({
|
||||
cdpUrl: "http://169.254.169.254:9222",
|
||||
url: "https://example.com",
|
||||
url: "https://93.184.216.34",
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
allowedHostnames: ["127.0.0.1"],
|
||||
@@ -262,7 +278,7 @@ describe("cdp", () => {
|
||||
await expect(
|
||||
createTargetViaCdp({
|
||||
cdpUrl: "ws://169.254.169.254:9222/devtools/browser/PIVOT",
|
||||
url: "https://example.com",
|
||||
url: "https://93.184.216.34",
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
allowedHostnames: ["127.0.0.1"],
|
||||
|
||||
@@ -187,10 +187,11 @@ export async function createTargetViaCdp(opts: {
|
||||
wsUrl = opts.cdpUrl;
|
||||
} else {
|
||||
// Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version.
|
||||
await assertCdpEndpointAllowed(opts.cdpUrl, opts.ssrfPolicy);
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||
appendCdpPath(opts.cdpUrl, "/json/version"),
|
||||
1500,
|
||||
undefined,
|
||||
opts.ssrfPolicy,
|
||||
);
|
||||
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
|
||||
wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
|
||||
|
||||
@@ -171,14 +171,22 @@ async function fetchChromeVersion(
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
|
||||
try {
|
||||
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
|
||||
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
|
||||
const res = await fetchCdpChecked(versionUrl, timeoutMs, { signal: ctrl.signal });
|
||||
const data = (await res.json()) as ChromeVersion;
|
||||
if (!data || typeof data !== "object") {
|
||||
return null;
|
||||
const { response, release } = await fetchCdpChecked(
|
||||
versionUrl,
|
||||
timeoutMs,
|
||||
{ signal: ctrl.signal },
|
||||
ssrfPolicy,
|
||||
);
|
||||
try {
|
||||
const data = (await response.json()) as ChromeVersion;
|
||||
if (!data || typeof data !== "object") {
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -116,6 +116,85 @@ describe("browser navigation guard", () => {
|
||||
expect(lookupFn).toHaveBeenCalledWith("example.com", { all: true });
|
||||
});
|
||||
|
||||
it("blocks hostname navigation when strict SSRF policy is explicitly configured", async () => {
|
||||
const lookupFn = createLookupFn("93.184.216.34");
|
||||
await expect(
|
||||
assertBrowserNavigationAllowed({
|
||||
url: "https://example.com",
|
||||
lookupFn,
|
||||
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
|
||||
}),
|
||||
).rejects.toThrow(/dns rebinding protections are unavailable/i);
|
||||
expect(lookupFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows explicitly allowed hostnames in strict mode", async () => {
|
||||
const lookupFn = createLookupFn("93.184.216.34");
|
||||
await expect(
|
||||
assertBrowserNavigationAllowed({
|
||||
url: "https://agent.internal",
|
||||
lookupFn,
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
allowedHostnames: ["agent.internal"],
|
||||
},
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows wildcard-allowlisted hostnames in strict mode", async () => {
|
||||
const lookupFn = createLookupFn("93.184.216.34");
|
||||
await expect(
|
||||
assertBrowserNavigationAllowed({
|
||||
url: "https://sub.example.com",
|
||||
lookupFn,
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
hostnameAllowlist: ["*.example.com"],
|
||||
},
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not treat the bare suffix as matching a wildcard allowlist entry", async () => {
|
||||
const lookupFn = createLookupFn("93.184.216.34");
|
||||
await expect(
|
||||
assertBrowserNavigationAllowed({
|
||||
url: "https://example.com",
|
||||
lookupFn,
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
hostnameAllowlist: ["*.example.com"],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/dns rebinding protections are unavailable/i);
|
||||
expect(lookupFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not match sibling domains against wildcard allowlist entries", async () => {
|
||||
const lookupFn = createLookupFn("93.184.216.34");
|
||||
await expect(
|
||||
assertBrowserNavigationAllowed({
|
||||
url: "https://evil-example.com",
|
||||
lookupFn,
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
hostnameAllowlist: ["*.example.com"],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/dns rebinding protections are unavailable/i);
|
||||
expect(lookupFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats bracketed IPv6 URL hostnames as IP literals in strict mode", async () => {
|
||||
await expect(
|
||||
assertBrowserNavigationAllowed({
|
||||
url: "https://[2606:4700:4700::1111]/",
|
||||
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks strict policy navigation when env proxy is configured", async () => {
|
||||
vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890");
|
||||
const lookupFn = createLookupFn("93.184.216.34");
|
||||
@@ -165,6 +244,15 @@ describe("browser navigation guard", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks final hostname URLs in strict mode after navigation", async () => {
|
||||
await expect(
|
||||
assertBrowserNavigationResultAllowed({
|
||||
url: "https://example.com/final",
|
||||
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
|
||||
});
|
||||
|
||||
it("blocks private intermediate redirect hops", async () => {
|
||||
const publicLookup = createLookupFn("93.184.216.34");
|
||||
const privateLookup = createLookupFn("127.0.0.1");
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { isIP } from "node:net";
|
||||
import {
|
||||
matchesHostnameAllowlist,
|
||||
normalizeHostname,
|
||||
} from "openclaw/plugin-sdk/browser-security-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js";
|
||||
import {
|
||||
@@ -41,6 +46,24 @@ export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFP
|
||||
return !isPrivateNetworkAllowedByPolicy(ssrfPolicy);
|
||||
}
|
||||
|
||||
function isIpLiteralHostname(hostname: string): boolean {
|
||||
return isIP(normalizeHostname(hostname)) !== 0;
|
||||
}
|
||||
|
||||
function isExplicitlyAllowedBrowserHostname(hostname: string, ssrfPolicy?: SsrFPolicy): boolean {
|
||||
const normalizedHostname = normalizeHostname(hostname);
|
||||
const exactMatches = ssrfPolicy?.allowedHostnames ?? [];
|
||||
if (exactMatches.some((value) => normalizeHostname(value) === normalizedHostname)) {
|
||||
return true;
|
||||
}
|
||||
const hostnameAllowlist = (ssrfPolicy?.hostnameAllowlist ?? [])
|
||||
.map((pattern) => normalizeHostname(pattern))
|
||||
.filter(Boolean);
|
||||
return hostnameAllowlist.length > 0
|
||||
? matchesHostnameAllowlist(normalizedHostname, hostnameAllowlist)
|
||||
: false;
|
||||
}
|
||||
|
||||
export async function assertBrowserNavigationAllowed(
|
||||
opts: {
|
||||
url: string;
|
||||
@@ -78,6 +101,21 @@ export async function assertBrowserNavigationAllowed(
|
||||
);
|
||||
}
|
||||
|
||||
// Browser navigations happen in Chromium's network stack, not Node's. In
|
||||
// strict mode, a hostname-based URL would be resolved twice by different
|
||||
// resolvers, so Node-side pinning cannot guarantee the browser connects to
|
||||
// the same address that passed policy checks.
|
||||
if (
|
||||
opts.ssrfPolicy &&
|
||||
!isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy) &&
|
||||
!isIpLiteralHostname(parsed.hostname) &&
|
||||
!isExplicitlyAllowedBrowserHostname(parsed.hostname, opts.ssrfPolicy)
|
||||
) {
|
||||
throw new InvalidBrowserNavigationUrlError(
|
||||
"Navigation blocked: strict browser SSRF policy requires an IP-literal URL because browser DNS rebinding protections are unavailable for hostname-based navigation",
|
||||
);
|
||||
}
|
||||
|
||||
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
||||
lookupFn: opts.lookupFn,
|
||||
policy: opts.ssrfPolicy,
|
||||
@@ -87,7 +125,8 @@ export async function assertBrowserNavigationAllowed(
|
||||
/**
|
||||
* Best-effort post-navigation guard for final page URLs.
|
||||
* Only validates network URLs (http/https) and about:blank to avoid false
|
||||
* positives on browser-internal error pages (e.g. chrome-error://).
|
||||
* positives on browser-internal error pages (e.g. chrome-error://). In strict
|
||||
* mode this intentionally re-applies the hostname gate after redirects.
|
||||
*/
|
||||
export async function assertBrowserNavigationResultAllowed(
|
||||
opts: {
|
||||
|
||||
@@ -202,6 +202,20 @@ describe("pw-session createPageViaPlaywright navigation guard", () => {
|
||||
expect(pageGoto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks hostname navigation when strict SSRF policy is configured", async () => {
|
||||
const { pageGoto } = installBrowserMocks();
|
||||
|
||||
await expect(
|
||||
createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://example.com",
|
||||
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
|
||||
|
||||
expect(pageGoto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks private intermediate redirect hops", async () => {
|
||||
const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks();
|
||||
mockBlockedRedirectNavigation({ pageGoto, getRouteHandler, mainFrame });
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
assertCdpEndpointAllowed,
|
||||
fetchOk,
|
||||
normalizeCdpHttpBaseForJsonEndpoints,
|
||||
} from "./cdp.helpers.js";
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
|
||||
import { appendCdpPath } from "./cdp.js";
|
||||
import { closeChromeMcpTab, focusChromeMcpTab } from "./chrome-mcp.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
@@ -11,17 +8,13 @@ import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.j
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
import type { PwAiModule } from "./pw-ai-module.js";
|
||||
import { getPwAiModule } from "./pw-ai-module.js";
|
||||
import type {
|
||||
BrowserServerState,
|
||||
BrowserTab,
|
||||
ProfileRuntimeState,
|
||||
} from "./server-context.types.js";
|
||||
import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js";
|
||||
import { resolveTargetIdFromTabs } from "./target-id.js";
|
||||
|
||||
type SelectionDeps = {
|
||||
profile: ResolvedBrowserProfile;
|
||||
state: () => BrowserServerState;
|
||||
getProfileState: () => ProfileRuntimeState;
|
||||
getSsrFPolicy: () => SsrFPolicy | undefined;
|
||||
ensureBrowserAvailable: () => Promise<void>;
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
openTab: (url: string) => Promise<BrowserTab>;
|
||||
@@ -35,27 +28,17 @@ type SelectionOps = {
|
||||
|
||||
export function createProfileSelectionOps({
|
||||
profile,
|
||||
state,
|
||||
getProfileState,
|
||||
getSsrFPolicy,
|
||||
ensureBrowserAvailable,
|
||||
listTabs,
|
||||
openTab,
|
||||
}: SelectionDeps): SelectionOps {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
const assertProfileCdpEndpointAllowed = async (): Promise<void> => {
|
||||
await assertCdpEndpointAllowed(profile.cdpUrl, state().resolved.ssrfPolicy);
|
||||
};
|
||||
const assertSelectableCdpEndpointAllowed = async (): Promise<void> => {
|
||||
if (capabilities.usesChromeMcp || !profile.cdpUrl) {
|
||||
return;
|
||||
}
|
||||
await assertProfileCdpEndpointAllowed();
|
||||
};
|
||||
|
||||
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
||||
await ensureBrowserAvailable();
|
||||
await assertSelectableCdpEndpointAllowed();
|
||||
const profileState = getProfileState();
|
||||
const tabs1 = await listTabs();
|
||||
if (tabs1.length === 0) {
|
||||
@@ -100,7 +83,6 @@ export function createProfileSelectionOps({
|
||||
};
|
||||
|
||||
const resolveTargetIdOrThrow = async (targetId: string): Promise<string> => {
|
||||
await assertSelectableCdpEndpointAllowed();
|
||||
const tabs = await listTabs();
|
||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||
if (!resolved.ok) {
|
||||
@@ -127,11 +109,9 @@ export function createProfileSelectionOps({
|
||||
const focusPageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
||||
?.focusPageByTargetIdViaPlaywright;
|
||||
if (typeof focusPageByTargetIdViaPlaywright === "function") {
|
||||
// SSRF check runs inside connectBrowser on cache miss.
|
||||
await focusPageByTargetIdViaPlaywright({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
targetId: resolvedTargetId,
|
||||
ssrfPolicy: state().resolved.ssrfPolicy,
|
||||
});
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = resolvedTargetId;
|
||||
@@ -139,8 +119,12 @@ export function createProfileSelectionOps({
|
||||
}
|
||||
}
|
||||
|
||||
await assertProfileCdpEndpointAllowed();
|
||||
await fetchOk(appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`));
|
||||
await fetchOk(
|
||||
appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`),
|
||||
undefined,
|
||||
undefined,
|
||||
getSsrFPolicy(),
|
||||
);
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = resolvedTargetId;
|
||||
};
|
||||
@@ -159,18 +143,20 @@ export function createProfileSelectionOps({
|
||||
const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
||||
?.closePageByTargetIdViaPlaywright;
|
||||
if (typeof closePageByTargetIdViaPlaywright === "function") {
|
||||
// SSRF check runs inside connectBrowser on cache miss.
|
||||
await closePageByTargetIdViaPlaywright({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
targetId: resolvedTargetId,
|
||||
ssrfPolicy: state().resolved.ssrfPolicy,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await assertProfileCdpEndpointAllowed();
|
||||
await fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`));
|
||||
await fetchOk(
|
||||
appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`),
|
||||
undefined,
|
||||
undefined,
|
||||
getSsrFPolicy(),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js";
|
||||
import {
|
||||
assertCdpEndpointAllowed,
|
||||
fetchJson,
|
||||
fetchOk,
|
||||
normalizeCdpHttpBaseForJsonEndpoints,
|
||||
} from "./cdp.helpers.js";
|
||||
import { fetchJson, fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
|
||||
import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
|
||||
import { listChromeMcpTabs, openChromeMcpTab } from "./chrome-mcp.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
@@ -69,9 +64,7 @@ export function createProfileTabOps({
|
||||
}: TabOpsDeps): ProfileTabOps {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
const assertProfileCdpEndpointAllowed = async (): Promise<void> => {
|
||||
await assertCdpEndpointAllowed(profile.cdpUrl, state().resolved.ssrfPolicy);
|
||||
};
|
||||
const getSsrFPolicy = () => state().resolved.ssrfPolicy;
|
||||
|
||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||
if (capabilities.usesChromeMcp) {
|
||||
@@ -82,11 +75,7 @@ export function createProfileTabOps({
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright;
|
||||
if (typeof listPagesViaPlaywright === "function") {
|
||||
await assertProfileCdpEndpointAllowed();
|
||||
const pages = await listPagesViaPlaywright({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
ssrfPolicy: state().resolved.ssrfPolicy,
|
||||
});
|
||||
const pages = await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl });
|
||||
return pages.map((p) => ({
|
||||
targetId: p.targetId,
|
||||
title: p.title,
|
||||
@@ -96,7 +85,6 @@ export function createProfileTabOps({
|
||||
}
|
||||
}
|
||||
|
||||
await assertProfileCdpEndpointAllowed();
|
||||
const raw = await fetchJson<
|
||||
Array<{
|
||||
id?: string;
|
||||
@@ -105,7 +93,7 @@ export function createProfileTabOps({
|
||||
webSocketDebuggerUrl?: string;
|
||||
type?: string;
|
||||
}>
|
||||
>(appendCdpPath(cdpHttpBase, "/json/list"));
|
||||
>(appendCdpPath(cdpHttpBase, "/json/list"), undefined, undefined, getSsrFPolicy());
|
||||
return raw
|
||||
.map((t) => ({
|
||||
targetId: t.id ?? "",
|
||||
@@ -136,9 +124,13 @@ export function createProfileTabOps({
|
||||
|
||||
const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId);
|
||||
const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT;
|
||||
await assertProfileCdpEndpointAllowed();
|
||||
for (const tab of candidates.slice(0, excessCount)) {
|
||||
void fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`)).catch(() => {
|
||||
void fetchOk(
|
||||
appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`),
|
||||
undefined,
|
||||
undefined,
|
||||
getSsrFPolicy(),
|
||||
).catch(() => {
|
||||
// best-effort cleanup only
|
||||
});
|
||||
}
|
||||
@@ -224,12 +216,21 @@ export function createProfileTabOps({
|
||||
return endpointUrl.toString();
|
||||
})()
|
||||
: `${endpointUrl.toString()}?${encoded}`;
|
||||
await assertProfileCdpEndpointAllowed();
|
||||
const created = await fetchJson<CdpTarget>(endpoint, CDP_JSON_NEW_TIMEOUT_MS, {
|
||||
method: "PUT",
|
||||
}).catch(async (err) => {
|
||||
const created = await fetchJson<CdpTarget>(
|
||||
endpoint,
|
||||
CDP_JSON_NEW_TIMEOUT_MS,
|
||||
{
|
||||
method: "PUT",
|
||||
},
|
||||
ssrfPolicyOpts.ssrfPolicy,
|
||||
).catch(async (err) => {
|
||||
if (String(err).includes("HTTP 405")) {
|
||||
return await fetchJson<CdpTarget>(endpoint, CDP_JSON_NEW_TIMEOUT_MS);
|
||||
return await fetchJson<CdpTarget>(
|
||||
endpoint,
|
||||
CDP_JSON_NEW_TIMEOUT_MS,
|
||||
undefined,
|
||||
ssrfPolicyOpts.ssrfPolicy,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
@@ -85,8 +85,8 @@ function createProfileContext(
|
||||
|
||||
const { ensureTabAvailable, focusTab, closeTab } = createProfileSelectionOps({
|
||||
profile,
|
||||
state,
|
||||
getProfileState,
|
||||
getSsrFPolicy: () => state().resolved.ssrfPolicy,
|
||||
ensureBrowserAvailable,
|
||||
listTabs,
|
||||
openTab,
|
||||
|
||||
@@ -57,7 +57,7 @@ function normalizeHostnameSet(values?: string[]): Set<string> {
|
||||
return new Set(values.map((value) => normalizeHostname(value)).filter(Boolean));
|
||||
}
|
||||
|
||||
function normalizeHostnameAllowlist(values?: string[]): string[] {
|
||||
export function normalizeHostnameAllowlist(values?: string[]): string[] {
|
||||
if (!values || values.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -87,7 +87,7 @@ function resolveIpv4SpecialUseBlockOptions(policy?: SsrFPolicy): Ipv4SpecialUseB
|
||||
};
|
||||
}
|
||||
|
||||
function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean {
|
||||
export function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean {
|
||||
if (pattern.startsWith("*.")) {
|
||||
const suffix = pattern.slice(2);
|
||||
if (!suffix || hostname === suffix) {
|
||||
@@ -98,7 +98,7 @@ function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean
|
||||
return hostname === pattern;
|
||||
}
|
||||
|
||||
function matchesHostnameAllowlist(hostname: string, allowlist: string[]): boolean {
|
||||
export function matchesHostnameAllowlist(hostname: string, allowlist: string[]): boolean {
|
||||
if (allowlist.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ export { hasProxyEnvConfigured } from "../infra/net/proxy-env.js";
|
||||
export {
|
||||
SsrFBlockedError,
|
||||
isBlockedHostnameOrIp,
|
||||
matchesHostnameAllowlist,
|
||||
isPrivateNetworkAllowedByPolicy,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
type LookupFn,
|
||||
type SsrFPolicy,
|
||||
} from "../infra/net/ssrf.js";
|
||||
export { normalizeHostname } from "../infra/net/hostname.js";
|
||||
export { isNotFoundPathError, isPathInside } from "../infra/path-guards.js";
|
||||
export { ensurePortAvailable } from "../infra/ports.js";
|
||||
export { generateSecureToken } from "../infra/secure-random.js";
|
||||
|
||||
Reference in New Issue
Block a user