@melaya/runner 1.0.117 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assistantHost.py +159 -13
- package/dist/browserAuthz.d.ts +78 -0
- package/dist/browserAuthz.js +504 -0
- package/dist/browserBridge.d.ts +50 -0
- package/dist/browserBridge.js +1021 -0
- package/dist/browserGrantVerify.d.ts +91 -0
- package/dist/browserGrantVerify.js +353 -0
- package/dist/browserProvisioner.d.ts +33 -0
- package/dist/browserProvisioner.js +266 -0
- package/dist/codeWorker.d.ts +49 -0
- package/dist/codeWorker.js +538 -0
- package/dist/connection.js +270 -3
- package/dist/sessionManager.d.ts +108 -0
- package/dist/sessionManager.js +290 -0
- package/package.json +4 -2
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
// packages/runner/src/browserAuthz.ts
|
|
2
|
+
//
|
|
3
|
+
// Melaya Browser, Phase 1 (plan Sections 6 and 10): the RUNNER-SIDE second
|
|
4
|
+
// gate. The server is the first gate; this module re-enforces, immediately
|
|
5
|
+
// before every consequential facade operation, the origin / scheme /
|
|
6
|
+
// private-network policy and the grant's effect ceiling. It is wired in at
|
|
7
|
+
// THREE boundaries, not just tool arguments:
|
|
8
|
+
//
|
|
9
|
+
// 1. BrowserContext routing (context.route / page.route): every HTTP/S
|
|
10
|
+
// request, redirect, subframe, popup document load, and download
|
|
11
|
+
// attempt is checked against the policy. Arg-only checks are
|
|
12
|
+
// bypassable (a page can redirect, open popups, or fetch cross-origin);
|
|
13
|
+
// routing is not.
|
|
14
|
+
// 2. CDP target creation (new pages / popups observed on the context):
|
|
15
|
+
// a page whose destination URL is denied is closed before the agent
|
|
16
|
+
// can observe or act on it.
|
|
17
|
+
// 3. WebSocket interception + WebRTC neutralisation (plan Section 10,
|
|
18
|
+
// egress extension):
|
|
19
|
+
// WebSocket: Playwright's `page.routeWebSocket(...)` intercepts
|
|
20
|
+
// ws:// and wss:// upgrade handshakes at the Playwright layer before
|
|
21
|
+
// the browser establishes the connection. Any ws/wss URL that fails
|
|
22
|
+
// the same origin / private-network policy check is aborted. This
|
|
23
|
+
// prevents page JS from reaching ws://169.254.169.254 or any other
|
|
24
|
+
// forbidden endpoint via a WebSocket.
|
|
25
|
+
// WebRTC: RTCPeerConnection is disabled via a page-init script that
|
|
26
|
+
// removes RTCPeerConnection, RTCSessionDescription, and
|
|
27
|
+
// RTCIceCandidate from the window and forces WebRTC IP handling
|
|
28
|
+
// mode via the CDP Network domain (disableWebRtc). This prevents
|
|
29
|
+
// data-channel exfiltration and IP leakage even when network*
|
|
30
|
+
// rules would otherwise allow them.
|
|
31
|
+
// COVERAGE NOTE: routeWebSocket is available in Playwright >= 1.48.
|
|
32
|
+
// If the pinned version is older, WebSocket blocking degrades to a
|
|
33
|
+
// best-effort page-init script that overrides window.WebSocket.
|
|
34
|
+
// WebRTC neutralisation is purely init-script-based and works on
|
|
35
|
+
// all Playwright versions.
|
|
36
|
+
//
|
|
37
|
+
// Default-deny (plan Section 10): localhost and loopback, RFC1918 and
|
|
38
|
+
// CGNAT, link-local, cloud metadata IPs, unspecified addresses, unexpected
|
|
39
|
+
// ports, DNS rebinding (every resolved A/AAAA record is re-checked, not
|
|
40
|
+
// just the hostname literal), file: / data: / javascript: / blob: /
|
|
41
|
+
// about: (except about:blank), extension and browser-internal pages.
|
|
42
|
+
// Enterprise intranet targets are reachable only when an origin scope
|
|
43
|
+
// explicitly names them (org policy is expressed through originScopes).
|
|
44
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
45
|
+
import { isIP } from "node:net";
|
|
46
|
+
import { BROWSER_EFFECT_RANK } from "./browserGrantVerify.js";
|
|
47
|
+
export const deny = (code, message) => ({
|
|
48
|
+
allowed: false,
|
|
49
|
+
code,
|
|
50
|
+
message,
|
|
51
|
+
});
|
|
52
|
+
const ALLOW = { allowed: true };
|
|
53
|
+
// ---------------------------------------------------------------------
|
|
54
|
+
// Scheme policy
|
|
55
|
+
// ---------------------------------------------------------------------
|
|
56
|
+
const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
|
|
57
|
+
/** Schemes that are never navigable by the agent, listed for clearer
|
|
58
|
+
* error messages (everything not in ALLOWED_SCHEMES denies anyway). */
|
|
59
|
+
const BROWSER_INTERNAL_SCHEMES = new Set([
|
|
60
|
+
"chrome:", "edge:", "brave:", "about:", "chrome-extension:",
|
|
61
|
+
"moz-extension:", "extension:", "devtools:", "view-source:",
|
|
62
|
+
"chrome-untrusted:", "chrome-search:",
|
|
63
|
+
]);
|
|
64
|
+
// ---------------------------------------------------------------------
|
|
65
|
+
// Address classification
|
|
66
|
+
// ---------------------------------------------------------------------
|
|
67
|
+
/** Cloud metadata endpoints (AWS/GCP/Azure 169.254.169.254 is also
|
|
68
|
+
* link-local, but keep the explicit list for non-link-local providers). */
|
|
69
|
+
const METADATA_HOSTS = new Set([
|
|
70
|
+
"169.254.169.254", // AWS / GCP / Azure IMDS
|
|
71
|
+
"fd00:ec2::254", // AWS IMDSv6
|
|
72
|
+
"100.100.100.200", // Alibaba Cloud
|
|
73
|
+
"192.0.0.192", // Oracle Cloud legacy
|
|
74
|
+
"metadata.google.internal",
|
|
75
|
+
"metadata.goog",
|
|
76
|
+
]);
|
|
77
|
+
function ipv4ToInt(ip) {
|
|
78
|
+
const p = ip.split(".").map(Number);
|
|
79
|
+
return ((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0;
|
|
80
|
+
}
|
|
81
|
+
function inCidr4(ip, base, bits) {
|
|
82
|
+
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
|
|
83
|
+
return (ip & mask) === (ipv4ToInt(base) & mask);
|
|
84
|
+
}
|
|
85
|
+
/** True when the literal IP is loopback, private, link-local, CGNAT,
|
|
86
|
+
* unspecified, broadcast, or a metadata address. Conservative: any
|
|
87
|
+
* parse failure classifies as forbidden (fail closed). */
|
|
88
|
+
export function isForbiddenIp(ip) {
|
|
89
|
+
const family = isIP(ip);
|
|
90
|
+
if (family === 4) {
|
|
91
|
+
const n = ipv4ToInt(ip);
|
|
92
|
+
return (inCidr4(n, "127.0.0.0", 8) || // loopback
|
|
93
|
+
inCidr4(n, "10.0.0.0", 8) || // RFC1918
|
|
94
|
+
inCidr4(n, "172.16.0.0", 12) || // RFC1918
|
|
95
|
+
inCidr4(n, "192.168.0.0", 16) || // RFC1918
|
|
96
|
+
inCidr4(n, "169.254.0.0", 16) || // link-local (incl. IMDS)
|
|
97
|
+
inCidr4(n, "100.64.0.0", 10) || // CGNAT
|
|
98
|
+
inCidr4(n, "0.0.0.0", 8) || // unspecified
|
|
99
|
+
inCidr4(n, "192.0.0.0", 24) || // IETF protocol assignments
|
|
100
|
+
inCidr4(n, "198.18.0.0", 15) || // benchmarking
|
|
101
|
+
n === 0xffffffff // broadcast
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (family === 6) {
|
|
105
|
+
const low = ip.toLowerCase();
|
|
106
|
+
// Normalize a v4-mapped literal (::ffff:1.2.3.4) to its v4 rules.
|
|
107
|
+
const v4m = low.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
108
|
+
if (v4m)
|
|
109
|
+
return isForbiddenIp(v4m[1]);
|
|
110
|
+
return (low === "::" || low === "::1" ||
|
|
111
|
+
low.startsWith("fe80:") || // link-local
|
|
112
|
+
low.startsWith("fc") || low.startsWith("fd") || // ULA
|
|
113
|
+
METADATA_HOSTS.has(low));
|
|
114
|
+
}
|
|
115
|
+
// Not an IP literal at all — caller handles hostnames separately.
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const LOCALHOST_NAMES = new Set(["localhost", "localhost.localdomain", "ip6-localhost"]);
|
|
119
|
+
function isLocalhostName(host) {
|
|
120
|
+
const h = host.toLowerCase().replace(/\.$/, "");
|
|
121
|
+
return LOCALHOST_NAMES.has(h) || h.endsWith(".localhost") || h.endsWith(".local");
|
|
122
|
+
}
|
|
123
|
+
export function parseOriginScope(raw) {
|
|
124
|
+
if (raw === "*") {
|
|
125
|
+
return { raw, scheme: "", wildcardHost: true, host: "", port: -1, any: true };
|
|
126
|
+
}
|
|
127
|
+
const m = raw.match(/^(https?):\/\/(\*\.)?([^/:*\s]+)(?::(\d+))?$/i);
|
|
128
|
+
if (!m)
|
|
129
|
+
return null;
|
|
130
|
+
const scheme = `${m[1].toLowerCase()}:`;
|
|
131
|
+
const port = m[4] ? Number(m[4]) : scheme === "https:" ? 443 : 80;
|
|
132
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
133
|
+
return null;
|
|
134
|
+
return {
|
|
135
|
+
raw,
|
|
136
|
+
scheme,
|
|
137
|
+
wildcardHost: Boolean(m[2]),
|
|
138
|
+
host: m[3].toLowerCase(),
|
|
139
|
+
port,
|
|
140
|
+
any: false,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
export function buildOriginPolicy(originScopes) {
|
|
144
|
+
const scopes = [];
|
|
145
|
+
for (const raw of originScopes) {
|
|
146
|
+
const p = parseOriginScope(raw);
|
|
147
|
+
if (p)
|
|
148
|
+
scopes.push(p);
|
|
149
|
+
// Unparseable scopes are DROPPED, never widened (fail closed).
|
|
150
|
+
}
|
|
151
|
+
return { scopes, hasScopes: scopes.length > 0 };
|
|
152
|
+
}
|
|
153
|
+
function urlPort(u) {
|
|
154
|
+
if (u.port)
|
|
155
|
+
return Number(u.port);
|
|
156
|
+
return u.protocol === "https:" ? 443 : 80;
|
|
157
|
+
}
|
|
158
|
+
/** Standard web ports plus common dev-server ports are still gated by
|
|
159
|
+
* scopes; a scope with an explicit port is required for ANY port other
|
|
160
|
+
* than the scheme default (unexpected-port rule, plan Section 10). */
|
|
161
|
+
function portAllowed(u, scope) {
|
|
162
|
+
const p = urlPort(u);
|
|
163
|
+
if (scope.any)
|
|
164
|
+
return p === 443 || p === 80; // "*" never grants odd ports
|
|
165
|
+
return p === scope.port;
|
|
166
|
+
}
|
|
167
|
+
function hostMatches(host, scope) {
|
|
168
|
+
const h = host.toLowerCase().replace(/\.$/, "");
|
|
169
|
+
if (scope.any)
|
|
170
|
+
return true;
|
|
171
|
+
if (scope.wildcardHost)
|
|
172
|
+
return h !== scope.host && h.endsWith(`.${scope.host}`);
|
|
173
|
+
return h === scope.host;
|
|
174
|
+
}
|
|
175
|
+
// ---------------------------------------------------------------------
|
|
176
|
+
// URL evaluation
|
|
177
|
+
// ---------------------------------------------------------------------
|
|
178
|
+
/** Synchronous structural checks: scheme, browser pages, localhost names,
|
|
179
|
+
* IP-literal private ranges, metadata hosts, origin-scope + port match.
|
|
180
|
+
* Does NOT resolve DNS — call evaluateUrlResolved for the full gate. */
|
|
181
|
+
export function evaluateUrlSync(rawUrl, policy) {
|
|
182
|
+
let u;
|
|
183
|
+
try {
|
|
184
|
+
u = new URL(rawUrl);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return deny("scheme_forbidden", `not a valid absolute URL: ${truncate(rawUrl)}`);
|
|
188
|
+
}
|
|
189
|
+
if (u.protocol === "about:" && rawUrl === "about:blank")
|
|
190
|
+
return ALLOW; // inert
|
|
191
|
+
if (BROWSER_INTERNAL_SCHEMES.has(u.protocol)) {
|
|
192
|
+
return deny("browser_internal_page", `browser-internal page '${u.protocol}' is not navigable by the agent`);
|
|
193
|
+
}
|
|
194
|
+
if (!ALLOWED_SCHEMES.has(u.protocol)) {
|
|
195
|
+
return deny("scheme_forbidden", `scheme '${u.protocol}' is denied (only http/https)`);
|
|
196
|
+
}
|
|
197
|
+
const host = u.hostname.replace(/^\[|\]$/g, "");
|
|
198
|
+
if (isLocalhostName(host)) {
|
|
199
|
+
return deny("private_network", `localhost destination '${host}' is denied by default`);
|
|
200
|
+
}
|
|
201
|
+
if (METADATA_HOSTS.has(host.toLowerCase())) {
|
|
202
|
+
return deny("metadata_endpoint", `cloud metadata endpoint '${host}' is denied`);
|
|
203
|
+
}
|
|
204
|
+
if (isIP(host) && isForbiddenIp(host)) {
|
|
205
|
+
return deny("private_network", `private/link-local/metadata address '${host}' is denied`);
|
|
206
|
+
}
|
|
207
|
+
if (!policy.hasScopes) {
|
|
208
|
+
return deny("origin_not_in_scope", "grant carries no parseable origin scopes; denying all origins");
|
|
209
|
+
}
|
|
210
|
+
const matched = policy.scopes.find((s) => (s.any || s.scheme === u.protocol) && hostMatches(host, s));
|
|
211
|
+
if (!matched) {
|
|
212
|
+
return deny("origin_not_in_scope", `origin '${u.protocol}//${host}' is not in the grant's origin scopes`);
|
|
213
|
+
}
|
|
214
|
+
if (!portAllowed(u, matched)) {
|
|
215
|
+
return deny("port_forbidden", `port ${urlPort(u)} is not allowed for scope '${matched.raw}'`);
|
|
216
|
+
}
|
|
217
|
+
return ALLOW;
|
|
218
|
+
}
|
|
219
|
+
/** Full gate: structural checks PLUS DNS-rebind protection. Every
|
|
220
|
+
* resolved address for the hostname must be public; one private record
|
|
221
|
+
* denies the whole navigation. Resolution failure denies (fail closed).
|
|
222
|
+
* NOTE: a TOCTOU window between this lookup and Chromium's own lookup
|
|
223
|
+
* remains (documented residual risk); the request-level route handler
|
|
224
|
+
* re-runs this on every request which keeps the window per-request
|
|
225
|
+
* rather than per-session. */
|
|
226
|
+
export async function evaluateUrlResolved(rawUrl, policy) {
|
|
227
|
+
const structural = evaluateUrlSync(rawUrl, policy);
|
|
228
|
+
if (!structural.allowed)
|
|
229
|
+
return structural;
|
|
230
|
+
let host;
|
|
231
|
+
try {
|
|
232
|
+
host = new URL(rawUrl).hostname.replace(/^\[|\]$/g, "");
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return deny("scheme_forbidden", "unparseable URL");
|
|
236
|
+
}
|
|
237
|
+
if (rawUrl === "about:blank")
|
|
238
|
+
return ALLOW;
|
|
239
|
+
if (isIP(host))
|
|
240
|
+
return ALLOW; // literal already vetted structurally
|
|
241
|
+
try {
|
|
242
|
+
const records = await dnsLookup(host, { all: true, verbatim: true });
|
|
243
|
+
if (!records.length)
|
|
244
|
+
return deny("dns_rebind", `hostname '${host}' resolved to no addresses`);
|
|
245
|
+
for (const r of records) {
|
|
246
|
+
if (isForbiddenIp(r.address)) {
|
|
247
|
+
return deny("dns_rebind", `hostname '${host}' resolves to forbidden address ${r.address}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return ALLOW;
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
return deny("dns_rebind", `DNS resolution failed for '${host}': ${e?.message || e} (fail closed)`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// ---------------------------------------------------------------------
|
|
257
|
+
// Effect ceiling (grant second gate)
|
|
258
|
+
// ---------------------------------------------------------------------
|
|
259
|
+
/** An operation's declared effect must (a) be inside the grant's
|
|
260
|
+
* actionScopes and (b) not exceed the effect ceiling. Unknown effect
|
|
261
|
+
* strings deny. */
|
|
262
|
+
export function checkEffect(effect, grant) {
|
|
263
|
+
if (!(effect in BROWSER_EFFECT_RANK)) {
|
|
264
|
+
return deny("effect_not_granted", `unknown effect class '${effect}'`);
|
|
265
|
+
}
|
|
266
|
+
const e = effect;
|
|
267
|
+
if (BROWSER_EFFECT_RANK[e] > BROWSER_EFFECT_RANK[grant.effectCeiling]) {
|
|
268
|
+
return deny("effect_over_ceiling", `effect '${e}' exceeds grant ceiling '${grant.effectCeiling}'`);
|
|
269
|
+
}
|
|
270
|
+
if (!grant.actionScopes.includes(e)) {
|
|
271
|
+
return deny("effect_not_granted", `effect '${e}' is not in the grant's actionScopes`);
|
|
272
|
+
}
|
|
273
|
+
return ALLOW;
|
|
274
|
+
}
|
|
275
|
+
// ---------------------------------------------------------------------
|
|
276
|
+
// WebSocket interception (plan Section 10 egress extension)
|
|
277
|
+
// ---------------------------------------------------------------------
|
|
278
|
+
/** Intercept a WebSocket handshake on a page and close it if the
|
|
279
|
+
* destination URL is forbidden. Uses Playwright's routeWebSocket API
|
|
280
|
+
* (available from Playwright 1.48). On older versions the call is a
|
|
281
|
+
* no-op and the init-script fallback (installed in installPageGuards)
|
|
282
|
+
* blocks the connection at the JS layer instead.
|
|
283
|
+
*
|
|
284
|
+
* ws:// and wss:// URLs are evaluated with the same origin / private-
|
|
285
|
+
* network / metadata checks as HTTP requests. Forbidden connections are
|
|
286
|
+
* closed before the server handshake completes. */
|
|
287
|
+
async function interceptWebSockets(page, policy, hooks) {
|
|
288
|
+
try {
|
|
289
|
+
if (typeof page["routeWebSocket"] !== "function")
|
|
290
|
+
return;
|
|
291
|
+
await page.routeWebSocket("**", (ws) => {
|
|
292
|
+
const wsAny = ws;
|
|
293
|
+
const url = wsAny.url();
|
|
294
|
+
// Evaluate synchronously — the WS URL is known at handshake time.
|
|
295
|
+
// Rewrite ws:// -> http:// and wss:// -> https:// so the URL parser
|
|
296
|
+
// and the origin-scope matcher treat them identically to HTTP(S).
|
|
297
|
+
const httpUrl = url.replace(/^wss?:\/\//, (m) => (m === "wss://" ? "https://" : "http://"));
|
|
298
|
+
const d = evaluateUrlSync(httpUrl, policy);
|
|
299
|
+
if (d.allowed) {
|
|
300
|
+
wsAny.connect?.();
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
hooks.onViolation({ url, code: d.code, message: d.message, surface: "websocket_intercept" });
|
|
304
|
+
wsAny.close();
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// routeWebSocket unavailable on this Playwright build; init-script
|
|
310
|
+
// fallback covers window.WebSocket in installPageGuards below.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// ---------------------------------------------------------------------
|
|
314
|
+
// Page init scripts (WebSocket + WebRTC neutralisation)
|
|
315
|
+
// ---------------------------------------------------------------------
|
|
316
|
+
/** Init script injected into EVERY new document on governed pages.
|
|
317
|
+
*
|
|
318
|
+
* Covers two egress surfaces that bypass the context.route/page.route
|
|
319
|
+
* handler (which only sees HTTP(S) requests):
|
|
320
|
+
*
|
|
321
|
+
* 1. WebSocket fallback: overrides window.WebSocket with a wrapper that
|
|
322
|
+
* blocks ws:// URLs whose hostname matches any forbidden address. This
|
|
323
|
+
* runs BEFORE page JS, so even inline scripts cannot open a raw
|
|
324
|
+
* WebSocket to 169.254.169.254 before the routeWebSocket intercept
|
|
325
|
+
* fires. The list of forbidden prefixes mirrors isForbiddenIp and the
|
|
326
|
+
* private-network ranges in browserAuthz. Allowed wss?:// connections
|
|
327
|
+
* still use the native WebSocket (the wrapper delegates).
|
|
328
|
+
*
|
|
329
|
+
* 2. WebRTC neutralisation: removes RTCPeerConnection, RTCSessionDescription,
|
|
330
|
+
* and RTCIceCandidate from the window object so page JS cannot create a
|
|
331
|
+
* peer connection at all. This prevents data-channel exfiltration and
|
|
332
|
+
* IP leakage via STUN/TURN. Combined with the CDP Network.enable /
|
|
333
|
+
* setRTCConfiguration override (applied separately via CDP below), this
|
|
334
|
+
* provides layered defence. */
|
|
335
|
+
const PAGE_GUARD_INIT_SCRIPT = `(function() {
|
|
336
|
+
// WebRTC neutralisation: remove peer connection constructors.
|
|
337
|
+
try { delete window.RTCPeerConnection; } catch (_) {}
|
|
338
|
+
try { delete window.RTCSessionDescription; } catch (_) {}
|
|
339
|
+
try { delete window.RTCIceCandidate; } catch (_) {}
|
|
340
|
+
// Also zero out webkit/moz-prefixed variants some legacy code still uses.
|
|
341
|
+
try { delete window.webkitRTCPeerConnection; } catch (_) {}
|
|
342
|
+
try { delete window.mozRTCPeerConnection; } catch (_) {}
|
|
343
|
+
// Override navigator.mediaDevices.getUserMedia to prevent media/camera
|
|
344
|
+
// capture that could be combined with a data channel.
|
|
345
|
+
try {
|
|
346
|
+
if (navigator.mediaDevices && typeof navigator.mediaDevices === 'object') {
|
|
347
|
+
Object.defineProperty(navigator, 'mediaDevices', { value: Object.assign({}, navigator.mediaDevices, { getUserMedia: () => Promise.reject(new DOMException('NotAllowedError')) }), configurable: false, writable: false });
|
|
348
|
+
}
|
|
349
|
+
} catch (_) {}
|
|
350
|
+
// WebSocket URL-blocking fallback (defence-in-depth, supplements
|
|
351
|
+
// routeWebSocket interception which fires at the Playwright layer).
|
|
352
|
+
var _NativeWS = window.WebSocket;
|
|
353
|
+
function _isForbiddenWsHost(url) {
|
|
354
|
+
try {
|
|
355
|
+
var u = new URL(url);
|
|
356
|
+
var h = u.hostname.replace(/^\\[|\\]$/g,'');
|
|
357
|
+
// Loopback / localhost
|
|
358
|
+
if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
|
|
359
|
+
// Link-local (169.254.x.x) — cloud metadata endpoints live here
|
|
360
|
+
if (/^169\\.254\\./.test(h)) return true;
|
|
361
|
+
// RFC1918
|
|
362
|
+
if (/^10\\./.test(h)) return true;
|
|
363
|
+
if (/^172\\.(1[6-9]|2[0-9]|3[01])\\./.test(h)) return true;
|
|
364
|
+
if (/^192\\.168\\./.test(h)) return true;
|
|
365
|
+
// CGNAT
|
|
366
|
+
if (/^100\\.(6[4-9]|[7-9][0-9]|1([01][0-9]|2[0-7]))\\./.test(h)) return true;
|
|
367
|
+
// Explicit metadata hosts
|
|
368
|
+
if (h === '100.100.100.200' || h === 'metadata.google.internal' || h === 'metadata.goog') return true;
|
|
369
|
+
} catch (_) {}
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
window.WebSocket = function WrappedWebSocket(url, protocols) {
|
|
373
|
+
if (_isForbiddenWsHost(String(url))) {
|
|
374
|
+
throw new DOMException('WebSocket connection to ' + url + ' is blocked by Melaya egress policy', 'SecurityError');
|
|
375
|
+
}
|
|
376
|
+
return protocols !== undefined ? new _NativeWS(url, protocols) : new _NativeWS(url);
|
|
377
|
+
};
|
|
378
|
+
window.WebSocket.prototype = _NativeWS.prototype;
|
|
379
|
+
window.WebSocket.CONNECTING = _NativeWS.CONNECTING;
|
|
380
|
+
window.WebSocket.OPEN = _NativeWS.OPEN;
|
|
381
|
+
window.WebSocket.CLOSING = _NativeWS.CLOSING;
|
|
382
|
+
window.WebSocket.CLOSED = _NativeWS.CLOSED;
|
|
383
|
+
})();`;
|
|
384
|
+
/** Disable WebRTC at the CDP Network domain level in addition to the
|
|
385
|
+
* init-script layer (belt-and-suspenders). Silently skips if CDP
|
|
386
|
+
* session creation fails (e.g. attached browser, same-process frame). */
|
|
387
|
+
export async function disableWebRtcViaCdp(cdpSession) {
|
|
388
|
+
try {
|
|
389
|
+
const cdp = cdpSession;
|
|
390
|
+
await cdp.send("Network.enable").catch(() => { });
|
|
391
|
+
// Force webRtcIpHandlingPolicy to disable_non_proxied_udp, which
|
|
392
|
+
// prevents IP leakage via STUN even if RTCPeerConnection is somehow
|
|
393
|
+
// re-instated by a polyfill.
|
|
394
|
+
await cdp.send("Page.setWebRTCIPHandlingPolicy", {
|
|
395
|
+
policy: "disable_non_proxied_udp",
|
|
396
|
+
}).catch(() => { });
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// CDP domain unavailable on this browser build — init script alone.
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/** Install request-level enforcement on a BrowserContext we OWN (launch
|
|
403
|
+
* mode). Every document, subresource, XHR, redirect hop, and worker
|
|
404
|
+
* script fetch flows through here; disallowed ones are aborted.
|
|
405
|
+
* Downloads are default-denied via the page download handler.
|
|
406
|
+
* WebSocket connections are intercepted via routeWebSocket (Playwright
|
|
407
|
+
* 1.48+) with a page-init-script fallback for older builds.
|
|
408
|
+
* WebRTC is neutralised via an init script on every new page. */
|
|
409
|
+
export async function enforceOnContext(context, policy, hooks) {
|
|
410
|
+
await context.route("**/*", async (route) => {
|
|
411
|
+
const url = route.request().url();
|
|
412
|
+
if (hooks.isCancelled?.()) {
|
|
413
|
+
await route.abort("blockedbyclient").catch(() => { });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const d = await evaluateUrlResolved(url, policy);
|
|
417
|
+
if (d.allowed) {
|
|
418
|
+
await route.continue().catch(() => { });
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
hooks.onViolation({ url, code: d.code, message: d.message, surface: "context_route" });
|
|
422
|
+
await route.abort("blockedbyclient").catch(() => { });
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
// Inject the WebSocket + WebRTC guard script into every new document
|
|
426
|
+
// BEFORE page JS runs.
|
|
427
|
+
await context.addInitScript({ content: PAGE_GUARD_INIT_SCRIPT }).catch(() => { });
|
|
428
|
+
context.on("page", (page) => {
|
|
429
|
+
void interceptWebSockets(page, policy, hooks);
|
|
430
|
+
void guardPage(page, policy, hooks, /*closeOnDeny*/ true);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
/** Install enforcement on a SINGLE page (attach mode: we never take over
|
|
434
|
+
* routing for the user's whole externally-owned browser context, only
|
|
435
|
+
* the leased target the run operates on; navigation of the leased page
|
|
436
|
+
* and its popups is still fully gated).
|
|
437
|
+
* WebSocket interception and WebRTC neutralisation are applied to this
|
|
438
|
+
* page and its popups in the same way as the owned-context path, scoped
|
|
439
|
+
* to the single leased target. */
|
|
440
|
+
export async function enforceOnPage(page, policy, hooks) {
|
|
441
|
+
await page.route("**/*", async (route) => {
|
|
442
|
+
const url = route.request().url();
|
|
443
|
+
if (hooks.isCancelled?.()) {
|
|
444
|
+
await route.abort("blockedbyclient").catch(() => { });
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const d = await evaluateUrlResolved(url, policy);
|
|
448
|
+
if (d.allowed) {
|
|
449
|
+
await route.continue().catch(() => { });
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
hooks.onViolation({ url, code: d.code, message: d.message, surface: "page_route" });
|
|
453
|
+
await route.abort("blockedbyclient").catch(() => { });
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
// WebSocket + WebRTC for the leased page.
|
|
457
|
+
await page.addInitScript({ content: PAGE_GUARD_INIT_SCRIPT }).catch(() => { });
|
|
458
|
+
await interceptWebSockets(page, policy, hooks);
|
|
459
|
+
await guardPage(page, policy, hooks, /*closeOnDeny*/ false);
|
|
460
|
+
page.on("popup", (popup) => {
|
|
461
|
+
void popup.addInitScript({ content: PAGE_GUARD_INIT_SCRIPT }).catch(() => { });
|
|
462
|
+
void interceptWebSockets(popup, policy, hooks);
|
|
463
|
+
void guardPage(popup, policy, hooks, /*closeOnDeny*/ true);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
/** CDP-target-creation gate: when a page/popup materializes, verify its
|
|
467
|
+
* destination; deny -> close before the agent can observe or act on it.
|
|
468
|
+
* Also default-denies automatic downloads (plan Section 10). */
|
|
469
|
+
async function guardPage(page, policy, hooks, closeOnDeny) {
|
|
470
|
+
page.on("download", (download) => {
|
|
471
|
+
hooks.onViolation({
|
|
472
|
+
url: download.url(),
|
|
473
|
+
code: "effect_not_granted",
|
|
474
|
+
message: "automatic downloads are default-denied in Phase 1",
|
|
475
|
+
surface: "download",
|
|
476
|
+
});
|
|
477
|
+
void download.cancel().catch(() => { });
|
|
478
|
+
});
|
|
479
|
+
page.on("framenavigated", (frame) => {
|
|
480
|
+
const url = frame.url();
|
|
481
|
+
if (!url || url === "about:blank")
|
|
482
|
+
return;
|
|
483
|
+
const d = evaluateUrlSync(url, policy);
|
|
484
|
+
if (!d.allowed) {
|
|
485
|
+
hooks.onViolation({ url, code: d.code, message: d.message, surface: "frame_navigated" });
|
|
486
|
+
if (closeOnDeny && frame === page.mainFrame()) {
|
|
487
|
+
void page.close({ runBeforeUnload: false }).catch(() => { });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
// Initial URL check for popups that spawn pre-navigated.
|
|
492
|
+
const url = page.url();
|
|
493
|
+
if (url && url !== "about:blank") {
|
|
494
|
+
const d = evaluateUrlSync(url, policy);
|
|
495
|
+
if (!d.allowed) {
|
|
496
|
+
hooks.onViolation({ url, code: d.code, message: d.message, surface: "target_created" });
|
|
497
|
+
if (closeOnDeny)
|
|
498
|
+
await page.close({ runBeforeUnload: false }).catch(() => { });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
function truncate(s, n = 160) {
|
|
503
|
+
return s.length > n ? `${s.slice(0, n)}…` : s;
|
|
504
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { BrowserGrant } from "./browserGrantVerify.js";
|
|
2
|
+
import { type SpaceSpec } from "./sessionManager.js";
|
|
3
|
+
import { type BrowserEngineId } from "./browserProvisioner.js";
|
|
4
|
+
export interface BrowserRunSpec {
|
|
5
|
+
runId: string;
|
|
6
|
+
grant: BrowserGrant;
|
|
7
|
+
mode: "launch" | "attach";
|
|
8
|
+
engine?: BrowserEngineId;
|
|
9
|
+
cdpWsEndpoint?: string;
|
|
10
|
+
space?: SpaceSpec;
|
|
11
|
+
codeMode?: boolean;
|
|
12
|
+
headless?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface BrowserBridge {
|
|
15
|
+
url: string;
|
|
16
|
+
port: number;
|
|
17
|
+
/** Register a verified run. Returns the per-run bearer token to inject
|
|
18
|
+
* as MEL_BROWSER_TOKEN. The grant itself NEVER leaves this process. */
|
|
19
|
+
registerRun(spec: BrowserRunSpec): {
|
|
20
|
+
token: string;
|
|
21
|
+
};
|
|
22
|
+
/** Idempotent full teardown for one run: cancels in-flight ops,
|
|
23
|
+
* releases leases, closes ONLY owned contexts, forgets the grant. */
|
|
24
|
+
teardownRun(runId: string, reason: string): Promise<void>;
|
|
25
|
+
teardownAll(reason: string): Promise<void>;
|
|
26
|
+
hasRun(runId: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Set the watch-lease state for a session (plan Section 10.f).
|
|
29
|
+
*
|
|
30
|
+
* Called by the connection layer when the server emits a
|
|
31
|
+
* `browser:watch` socket event `{ sessionId, active }`. When
|
|
32
|
+
* active=true, the bridge begins capturing frames at an adaptive FPS
|
|
33
|
+
* (change-detection, capped at 2-4 fps) and POSTing each JPEG frame
|
|
34
|
+
* to `framePostUrl` as `{ sessionId, runId, jpegB64 }` authenticated
|
|
35
|
+
* with the runner's relay nonce. When active=false, capturing stops.
|
|
36
|
+
*
|
|
37
|
+
* The producer runs only while a viewer is watching and stops
|
|
38
|
+
* automatically when the run is torn down.
|
|
39
|
+
*/
|
|
40
|
+
setWatchLease(sessionId: string, active: boolean, framePostUrl: string, runAuthHeader: string): void;
|
|
41
|
+
shutdown(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
export declare function startBrowserBridge(opts: {
|
|
44
|
+
log: (msg: string) => void;
|
|
45
|
+
verbose?: boolean;
|
|
46
|
+
}): Promise<BrowserBridge>;
|
|
47
|
+
export declare class BridgeError extends Error {
|
|
48
|
+
readonly code: string;
|
|
49
|
+
constructor(code: string, message: string);
|
|
50
|
+
}
|