Skip to content

Commit 05079ac

Browse files
[backport release/v0.20.0] fix web clone browser startup without project installs (#7048)
* BACKPORT-CONFLICT * fix: resolve web clone backport conflict * fix(daemon): constrain web clone browser broker --------- Co-authored-by: lefarcen <935902669@qq.com>
1 parent 93f0578 commit 05079ac

16 files changed

Lines changed: 2756 additions & 50 deletions

apps/daemon/src/browser-cdp.ts

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import { WebSocket } from 'undici';
2+
3+
import {
4+
assertBrowserNetworkUrl,
5+
type BrowserNetworkPolicy,
6+
} from './browser-network-policy.js';
7+
8+
const COMMAND_TIMEOUT_MS = 30_000;
9+
const EVENT_BACKLOG_LIMIT = 4_000;
10+
const PAGE_CONTENT_METHODS = new Set([
11+
'Page.captureScreenshot',
12+
'Page.getLayoutMetrics',
13+
'Runtime.evaluate',
14+
]);
15+
16+
export const WEB_CLONE_CDP_METHODS = new Set([
17+
'Emulation.setDeviceMetricsOverride',
18+
'Input.dispatchMouseEvent',
19+
'Network.enable',
20+
'Network.getCookies',
21+
'Network.getResponseBody',
22+
'Page.captureScreenshot',
23+
'Page.enable',
24+
'Page.getLayoutMetrics',
25+
'Page.navigate',
26+
'Runtime.enable',
27+
'Runtime.evaluate',
28+
]);
29+
30+
export interface BrowserCdpEvent {
31+
method: string;
32+
params: Record<string, unknown>;
33+
sequence: number;
34+
}
35+
36+
type PendingCommand = {
37+
reject: (error: Error) => void;
38+
resolve: (result: Record<string, unknown>) => void;
39+
timer: NodeJS.Timeout;
40+
};
41+
42+
function errorMessage(error: unknown): string {
43+
return error instanceof Error ? error.message : String(error);
44+
}
45+
46+
export class BrowserCdpPage {
47+
readonly id: string;
48+
49+
#closed = false;
50+
#events: BrowserCdpEvent[] = [];
51+
#nextCommandId = 1;
52+
#nextEventSequence = 1;
53+
#networkPolicy: BrowserNetworkPolicy;
54+
#networkChecks = new Map<string, Promise<void>>();
55+
#pending = new Map<number, PendingCommand>();
56+
#ready: Promise<void>;
57+
#socket: WebSocket;
58+
#waiters = new Set<() => void>();
59+
#currentUrl = 'about:blank';
60+
61+
private constructor(id: string, websocketUrl: string, networkPolicy: BrowserNetworkPolicy) {
62+
this.id = id;
63+
this.#networkPolicy = networkPolicy;
64+
this.#socket = new WebSocket(websocketUrl);
65+
this.#ready = new Promise<void>((resolve, reject) => {
66+
this.#socket.addEventListener('open', () => resolve(), { once: true });
67+
this.#socket.addEventListener('error', () => reject(new Error('browser CDP connection failed')), { once: true });
68+
});
69+
this.#socket.addEventListener('message', (event) => this.#handleMessage(String(event.data)));
70+
this.#socket.addEventListener('close', () => this.#handleClose());
71+
}
72+
73+
static async connect(
74+
id: string,
75+
websocketUrl: string,
76+
networkPolicy: BrowserNetworkPolicy = {},
77+
): Promise<BrowserCdpPage> {
78+
const page = new BrowserCdpPage(id, websocketUrl, networkPolicy);
79+
try {
80+
await page.#ready;
81+
// Fetch interception is daemon-owned and never exposed through the client
82+
// allowlist. Page scripts therefore cannot disable the private-network
83+
// boundary even when Runtime.evaluate is used for DOM reconnaissance.
84+
await page.#sendRaw('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
85+
return page;
86+
} catch (error) {
87+
await page.close();
88+
throw error;
89+
}
90+
}
91+
92+
async command(method: string, params: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
93+
if (!WEB_CLONE_CDP_METHODS.has(method)) {
94+
throw new Error(`CDP method is not allowed for Website Clone: ${method}`);
95+
}
96+
let navigationUrl: string | null = null;
97+
if (method === 'Page.navigate') {
98+
const url = typeof params.url === 'string' ? params.url : '';
99+
await this.#assertNetworkUrl(url);
100+
navigationUrl = url;
101+
}
102+
if (PAGE_CONTENT_METHODS.has(method)) {
103+
await this.#assertReadablePage();
104+
}
105+
if (method === 'Network.getCookies') {
106+
const urls = Array.isArray(params.urls) ? params.urls : [];
107+
await Promise.all(urls.map((url) => this.#assertNetworkUrl(typeof url === 'string' ? url : '')));
108+
}
109+
const result = await this.#sendRaw(method, params);
110+
if (navigationUrl && typeof result.errorText !== 'string') this.#currentUrl = navigationUrl;
111+
return result;
112+
}
113+
114+
async eventsAfter(after: number, timeoutMs: number): Promise<BrowserCdpEvent[]> {
115+
const available = () => this.#events.filter((event) => event.sequence > after);
116+
const initial = available();
117+
if (initial.length > 0 || this.#closed || timeoutMs <= 0) return initial;
118+
119+
await new Promise<void>((resolve) => {
120+
const finish = () => {
121+
clearTimeout(timer);
122+
this.#waiters.delete(finish);
123+
resolve();
124+
};
125+
const timer = setTimeout(finish, timeoutMs);
126+
timer.unref?.();
127+
this.#waiters.add(finish);
128+
});
129+
return available();
130+
}
131+
132+
async close(): Promise<void> {
133+
if (this.#closed) return;
134+
this.#closed = true;
135+
this.#socket.close();
136+
this.#handleClose();
137+
}
138+
139+
async #sendRaw(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
140+
await this.#ready;
141+
if (this.#closed || this.#socket.readyState !== WebSocket.OPEN) {
142+
throw new Error('browser CDP connection is closed');
143+
}
144+
const id = this.#nextCommandId++;
145+
const response = new Promise<Record<string, unknown>>((resolve, reject) => {
146+
const timer = setTimeout(() => {
147+
this.#pending.delete(id);
148+
reject(new Error(`${method} timed out after ${COMMAND_TIMEOUT_MS}ms`));
149+
}, COMMAND_TIMEOUT_MS);
150+
timer.unref?.();
151+
this.#pending.set(id, { reject, resolve, timer });
152+
});
153+
this.#socket.send(JSON.stringify({ id, method, params }));
154+
return response;
155+
}
156+
157+
#handleMessage(raw: string): void {
158+
let message: {
159+
error?: { message?: string };
160+
id?: number;
161+
method?: string;
162+
params?: Record<string, unknown>;
163+
result?: Record<string, unknown>;
164+
};
165+
try {
166+
message = JSON.parse(raw) as typeof message;
167+
} catch {
168+
return;
169+
}
170+
171+
if (typeof message.id === 'number') {
172+
const pending = this.#pending.get(message.id);
173+
if (!pending) return;
174+
this.#pending.delete(message.id);
175+
clearTimeout(pending.timer);
176+
if (message.error) pending.reject(new Error(message.error.message ?? JSON.stringify(message.error)));
177+
else pending.resolve(message.result ?? {});
178+
return;
179+
}
180+
181+
if (!message.method) return;
182+
const params = message.params ?? {};
183+
if (message.method === 'Fetch.requestPaused') {
184+
void this.#handleRequestPaused(params).catch(() => undefined);
185+
return;
186+
}
187+
if (message.method === 'Page.frameNavigated') {
188+
const frame = params.frame as { parentId?: unknown; url?: unknown } | undefined;
189+
if (frame && !frame.parentId && typeof frame.url === 'string') this.#currentUrl = frame.url;
190+
}
191+
this.#events.push({ method: message.method, params, sequence: this.#nextEventSequence++ });
192+
if (this.#events.length > EVENT_BACKLOG_LIMIT) this.#events.splice(0, this.#events.length - EVENT_BACKLOG_LIMIT);
193+
this.#wakeWaiters();
194+
}
195+
196+
async #handleRequestPaused(params: Record<string, unknown>): Promise<void> {
197+
const requestId = typeof params.requestId === 'string' ? params.requestId : '';
198+
const request = params.request as { url?: unknown } | undefined;
199+
const url = typeof request?.url === 'string' ? request.url : '';
200+
if (!requestId) return;
201+
try {
202+
await this.#assertNetworkUrl(url);
203+
await this.#sendRaw('Fetch.continueRequest', { requestId });
204+
} catch (error) {
205+
await this.#sendRaw('Fetch.failRequest', { errorReason: 'BlockedByClient', requestId }).catch(() => undefined);
206+
this.#events.push({
207+
method: 'OpenDesign.browserRequestBlocked',
208+
params: { error: errorMessage(error), url },
209+
sequence: this.#nextEventSequence++,
210+
});
211+
this.#wakeWaiters();
212+
}
213+
}
214+
215+
async #assertNetworkUrl(url: string): Promise<void> {
216+
let key = url;
217+
try {
218+
const parsed = new URL(url);
219+
key = `${parsed.protocol}//${parsed.host}`;
220+
} catch {
221+
// The validator below owns the user-facing invalid-URL error.
222+
}
223+
const existing = this.#networkChecks.get(key);
224+
if (existing) return existing;
225+
const check = assertBrowserNetworkUrl(url, this.#networkPolicy);
226+
this.#networkChecks.set(key, check);
227+
try {
228+
await check;
229+
} finally {
230+
this.#networkChecks.delete(key);
231+
}
232+
}
233+
234+
async #assertReadablePage(): Promise<void> {
235+
let protocol = '';
236+
try {
237+
protocol = new URL(this.#currentUrl).protocol;
238+
} catch {
239+
// The network validator below returns the canonical invalid-URL error.
240+
}
241+
if (protocol !== 'http:' && protocol !== 'https:') {
242+
throw new Error(`page content is unavailable for privileged URL scheme: ${protocol || 'invalid'}`);
243+
}
244+
await this.#assertNetworkUrl(this.#currentUrl);
245+
}
246+
247+
#handleClose(): void {
248+
if (this.#closed && this.#pending.size === 0) return;
249+
this.#closed = true;
250+
for (const pending of this.#pending.values()) {
251+
clearTimeout(pending.timer);
252+
pending.reject(new Error('browser CDP connection closed'));
253+
}
254+
this.#pending.clear();
255+
this.#wakeWaiters();
256+
}
257+
258+
#wakeWaiters(): void {
259+
for (const wake of [...this.#waiters]) wake();
260+
}
261+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { promises as dns } from 'node:dns';
2+
3+
import { assertSafePublicUrl, isPrivateAddress } from './plugins/plugin-asset-cache.js';
4+
5+
export type BrowserDnsLookup = typeof dns.lookup;
6+
7+
export interface BrowserNetworkPolicy {
8+
allowPrivateNetwork?: boolean;
9+
lookup?: BrowserDnsLookup;
10+
}
11+
12+
export interface BrowserNetworkTarget {
13+
address: string;
14+
family: number;
15+
url: URL;
16+
}
17+
18+
function isBrowserLocalUrl(rawUrl: string): boolean {
19+
if (rawUrl === 'about:blank') return true;
20+
return rawUrl.startsWith('data:') || rawUrl.startsWith('blob:');
21+
}
22+
23+
/**
24+
* Website Clone drives a daemon-owned browser, so every network destination
25+
* must be checked at the privileged boundary. Literal private addresses and
26+
* hostnames resolving to loopback, RFC1918, link-local, metadata, CGNAT, or
27+
* multicast space are refused before Chromium can issue the request.
28+
*/
29+
export async function assertBrowserNetworkUrl(
30+
rawUrl: string,
31+
policy: BrowserNetworkPolicy = {},
32+
): Promise<void> {
33+
if (isBrowserLocalUrl(rawUrl)) return;
34+
35+
await resolveBrowserNetworkTarget(rawUrl, policy);
36+
}
37+
38+
export async function resolveBrowserNetworkTarget(
39+
rawUrl: string,
40+
policy: BrowserNetworkPolicy = {},
41+
): Promise<BrowserNetworkTarget> {
42+
let parsed: URL;
43+
44+
if (policy.allowPrivateNetwork) {
45+
parsed = new URL(rawUrl);
46+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
47+
throw new Error('browser destination must be an HTTP(S) URL without credentials');
48+
}
49+
} else {
50+
parsed = assertSafePublicUrl(rawUrl);
51+
}
52+
53+
const lookup = policy.lookup ?? dns.lookup;
54+
const addresses = await lookup(parsed.hostname, { all: true, family: 0 });
55+
if (addresses.length === 0) {
56+
throw new Error('browser destination did not resolve');
57+
}
58+
for (const { address } of addresses) {
59+
if (!policy.allowPrivateNetwork && isPrivateAddress(address)) {
60+
throw new Error(`browser destination resolves to a private address: ${address}`);
61+
}
62+
}
63+
const selected = addresses[0];
64+
if (!selected) throw new Error('browser destination did not resolve');
65+
return { address: selected.address, family: selected.family, url: parsed };
66+
}

0 commit comments

Comments
 (0)