@activeagents/browser-sidecar 0.0.0-stage → 1.9.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.
@@ -0,0 +1,339 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFile, realpath } from 'node:fs/promises';
3
+ import { sep } from 'node:path';
4
+
5
+ import { TAKEOVER_META } from './control-lock.mjs';
6
+
7
+ // Never offered and never called: browser_run_code_unsafe runs JavaScript in
8
+ // this process, where the tokens are.
9
+ export const DENIED_TOOLS = new Set(['browser_run_code_unsafe']);
10
+
11
+ // The tool argument holding the URL a tool opens, for the tools that open one.
12
+ const NAVIGATION_ARGUMENTS = {
13
+ browser_navigate: () => 'url',
14
+ browser_tabs: (args) => (args.action === 'new' && args.url !== undefined ? 'url' : null),
15
+ };
16
+
17
+ const JSONRPC = '2.0';
18
+ // A page snapshot an action wrote to a file, and how much of one is inlined.
19
+ const SNAPSHOT_LINK = /(?:- )?\[Snapshot\]\(([^)\n]+)\)/g;
20
+ const SNAPSHOT_LIMIT = 256 * 1024;
21
+
22
+ function rpcError(id, code, message) {
23
+ return { jsonrpc: JSONRPC, id: id ?? null, error: { code, message } };
24
+ }
25
+
26
+ function toolError(id, text) {
27
+ return { jsonrpc: JSONRPC, id, result: { content: [{ type: 'text', text: `### Error\n${text}` }], isError: true } };
28
+ }
29
+
30
+ // The answer to an agent's call refused because a person held control for
31
+ // all of `waitedMs`. Its _meta names who, for a client that pauses the run
32
+ // until they hand back rather than reading the error.
33
+ function takeoverError(id, holder, waitedMs) {
34
+ const who = holder.user.name ? `${holder.user.name} is` : 'A person is';
35
+ const text = `${who} driving this browser by hand and did not hand it back within ${Math.round(waitedMs / 1000)} seconds. ` +
36
+ 'Nothing was done. Try again once they hand it back.';
37
+ const result = toolError(id, text);
38
+ result.result._meta = { [TAKEOVER_META]: { held_by: holder.user.name, since: new Date(holder.since).toISOString(), waited_ms: waitedMs } };
39
+ return result;
40
+ }
41
+
42
+ function isRequest(message) {
43
+ return typeof message.method === 'string' && message.id !== undefined && message.id !== null;
44
+ }
45
+
46
+ // The server side of one MCP session, held in memory: a request goes in
47
+ // through `request` and its response comes back through the server's call to
48
+ // `send`.
49
+ // Notifications and requests the server sends on its own are dropped, since
50
+ // a JSON response has nowhere to carry them.
51
+ class MemoryTransport {
52
+ constructor(timeoutMs) {
53
+ this.timeoutMs = timeoutMs;
54
+ this.pending = new Map();
55
+ }
56
+
57
+ async start() {}
58
+
59
+ async send(message) {
60
+ const isResponse = message && message.id !== undefined && ('result' in message || 'error' in message);
61
+ if (!isResponse) return;
62
+
63
+ const waiter = this.pending.get(message.id);
64
+ if (!waiter) return;
65
+ this.pending.delete(message.id);
66
+ clearTimeout(waiter.timer);
67
+ waiter.resolve(message);
68
+ }
69
+
70
+ async close() {
71
+ if (this.closed) return;
72
+ this.closed = true;
73
+ for (const [id, waiter] of this.pending) {
74
+ clearTimeout(waiter.timer);
75
+ waiter.resolve(rpcError(id, -32000, 'The session closed'));
76
+ }
77
+ this.pending.clear();
78
+ this.onclose?.();
79
+ }
80
+
81
+ request(message) {
82
+ if (this.closed) return Promise.resolve(rpcError(message.id, -32000, 'The session closed'));
83
+ if (this.pending.has(message.id)) return Promise.resolve(rpcError(message.id, -32600, 'A request with this id is in flight'));
84
+
85
+ return new Promise((resolve) => {
86
+ const timer = setTimeout(() => {
87
+ this.pending.delete(message.id);
88
+ resolve(rpcError(message.id, -32001, 'The request timed out'));
89
+ }, this.timeoutMs);
90
+ this.pending.set(message.id, { resolve, timer });
91
+ this.onmessage?.(message);
92
+ });
93
+ }
94
+
95
+ notify(message) {
96
+ this.onmessage?.(message);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Used to serve MCP over plain JSON responses: each POST carries one JSON-RPC
102
+ * message and gets the response back as the body.
103
+ *
104
+ * Every session gets its own server from `connect`, all of them driving the
105
+ * same browser. The gateway reads each message on its way in and out, so a
106
+ * denied tool is neither listed nor called, a navigation is checked against
107
+ * the network policy before it reaches the browser, and the client declares
108
+ * no capability the server would call back for (such as roots, which would
109
+ * move where files are read from).
110
+ *
111
+ * Playwright MCP writes the page snapshot an action produces to a file and
112
+ * answers with a link to it, which a client on another machine cannot open.
113
+ * With `snapshotDir` set, a link to a file in that directory is replaced by
114
+ * the snapshot itself.
115
+ *
116
+ * With `guard` set, a tool call during which a page was redirected off the
117
+ * app gets an error instead of its result, which could describe that page.
118
+ *
119
+ * With `lock` set, a tool call that would change the page waits while a
120
+ * person holds control (ControlLock#admit), for up to `agentWaitMs`, and is
121
+ * answered with an error naming them when they have not handed it back by
122
+ * then.
123
+ */
124
+ export class McpGateway {
125
+ /**
126
+ * @param {object} options
127
+ * @param {() => Promise<{ connect(transport: object): Promise<void>, close(): Promise<void> }>} options.connect
128
+ * returns a new MCP server for a session
129
+ * @param {{ navigationTarget(url: string): { url: string, refusal: string | null } }} options.policy
130
+ * @param {Set<string>} [options.deniedTools]
131
+ * @param {number} [options.maxSessions] sessions kept at once; the least recently used idle one is closed for a new one
132
+ * @param {number} [options.idleMs] how long an unused session is kept
133
+ * @param {number} [options.requestTimeoutMs] how long a request may take
134
+ * @param {string} [options.snapshotDir] the real path of the directory snapshots are written to
135
+ * @param {{ escapes: number, refusalSince(since: number): Promise<string | null> }} [options.guard] a NavigationGuard
136
+ * @param {import('./control-lock.mjs').ControlLock} [options.lock]
137
+ * @param {number} [options.agentWaitMs]
138
+ * @param {(name: string, args: object) => void} [options.afterCall] told of each tool call that succeeded
139
+ */
140
+ constructor({
141
+ connect, policy, deniedTools = DENIED_TOOLS, maxSessions = 8, idleMs = 30 * 60_000, requestTimeoutMs = 120_000, snapshotDir = null,
142
+ guard = null, lock = null, agentWaitMs = 0, afterCall = () => {},
143
+ }) {
144
+ this.connect = connect;
145
+ this.policy = policy;
146
+ this.guard = guard;
147
+ this.lock = lock;
148
+ this.agentWaitMs = agentWaitMs;
149
+ this.afterCall = afterCall;
150
+ this.snapshotDir = snapshotDir;
151
+ this.deniedTools = deniedTools;
152
+ this.maxSessions = maxSessions;
153
+ this.idleMs = idleMs;
154
+ this.requestTimeoutMs = requestTimeoutMs;
155
+ this.sessions = new Map();
156
+ this.uses = 0;
157
+ }
158
+
159
+ /**
160
+ * Answers one POSTed message.
161
+ *
162
+ * @param {unknown} message the parsed body
163
+ * @param {string | undefined} sessionId the Mcp-Session-Id header
164
+ * @returns {Promise<{ status: number, headers?: object, body?: object }>}
165
+ */
166
+ async handle(message, sessionId) {
167
+ if (!message || typeof message !== 'object' || Array.isArray(message) || message.jsonrpc !== JSONRPC) {
168
+ return { status: 400, body: rpcError(null, -32600, 'The body must be one JSON-RPC 2.0 message') };
169
+ }
170
+
171
+ if (message.method === 'initialize') return this.initialize(message);
172
+
173
+ if (!sessionId) return { status: 400, body: rpcError(message.id, -32600, 'Mcp-Session-Id header is required') };
174
+ const session = this.sessions.get(sessionId);
175
+ if (!session) return { status: 404, body: rpcError(message.id, -32001, 'Session not found') };
176
+
177
+ if (!isRequest(message)) {
178
+ if (typeof message.method === 'string') session.transport.notify(message);
179
+ return { status: 202 };
180
+ }
181
+
182
+ const inbound = this.inbound(message);
183
+ if (inbound.answer) return { status: 200, body: inbound.answer };
184
+
185
+ this.touch(session);
186
+ session.busy += 1;
187
+ try {
188
+ const call = message.method === 'tools/call' ? inbound.message.params : null;
189
+ if (call && this.lock) {
190
+ const waitStarted = Date.now();
191
+ const refusal = await this.lock.admit(call.name, call.arguments, this.agentWaitMs);
192
+ if (refusal?.stopping) return { status: 200, body: toolError(message.id, 'The browser is stopping. Nothing was done.') };
193
+ if (refusal) return { status: 200, body: takeoverError(message.id, refusal.holder, Date.now() - waitStarted) };
194
+ }
195
+
196
+ const escapes = this.guard?.escapes ?? 0;
197
+ const response = await session.transport.request(inbound.message);
198
+ const refusal = call ? await this.guard?.refusalSince(escapes) : null;
199
+ if (refusal) return { status: 200, body: toolError(message.id, refusal) };
200
+ if (call && response.result && !response.result.isError) this.afterCall(call.name, call.arguments);
201
+
202
+ return { status: 200, body: await this.outbound(message, response) };
203
+ } finally {
204
+ session.busy -= 1;
205
+ this.touch(session);
206
+ }
207
+ }
208
+
209
+ async initialize(message) {
210
+ if (!isRequest(message)) return { status: 400, body: rpcError(null, -32600, 'initialize must be a request') };
211
+ this.closeIdleSessions();
212
+ if (this.sessions.size >= this.maxSessions && !this.closeLeastRecentlyUsed()) {
213
+ return { status: 503, body: rpcError(message.id, -32000, 'Too many sessions are busy; try again shortly') };
214
+ }
215
+
216
+ const server = await this.connect();
217
+ const transport = new MemoryTransport(this.requestTimeoutMs);
218
+ await server.connect(transport);
219
+
220
+ const id = randomUUID();
221
+ const session = { server, transport, busy: 1 };
222
+ this.touch(session);
223
+ this.sessions.set(id, session);
224
+ try {
225
+ const params = { ...(message.params ?? {}), capabilities: {} };
226
+ const response = await transport.request({ ...message, params });
227
+ if (response.error) {
228
+ await this.closeSession(id);
229
+ return { status: 200, body: response };
230
+ }
231
+ return { status: 200, headers: { 'mcp-session-id': id }, body: response };
232
+ } finally {
233
+ session.busy -= 1;
234
+ }
235
+ }
236
+
237
+ // The message as the server should see it, or the answer to give without
238
+ // asking the server at all.
239
+ inbound(message) {
240
+ if (message.method !== 'tools/call') return { message };
241
+
242
+ const name = message.params?.name;
243
+ if (this.deniedTools.has(name)) return { answer: toolError(message.id, `The ${name} tool is not available in this browser`) };
244
+
245
+ const args = { ...(message.params?.arguments ?? {}) };
246
+ // _meta can move the directory file names resolve against.
247
+ delete args._meta;
248
+
249
+ const argument = NAVIGATION_ARGUMENTS[name]?.(args);
250
+ if (argument) {
251
+ const target = this.policy.navigationTarget(args[argument]);
252
+ if (target.refusal) return { answer: toolError(message.id, target.refusal) };
253
+ args[argument] = target.url;
254
+ }
255
+
256
+ return { message: { ...message, params: { ...message.params, arguments: args } } };
257
+ }
258
+
259
+ async outbound(request, response) {
260
+ if (request.method === 'tools/list' && Array.isArray(response.result?.tools)) {
261
+ const tools = response.result.tools.filter((tool) => !this.deniedTools.has(tool.name));
262
+ return { ...response, result: { ...response.result, tools } };
263
+ }
264
+ if (request.method === 'tools/call' && this.snapshotDir && Array.isArray(response.result?.content)) {
265
+ const content = await Promise.all(response.result.content.map((block) => this.inlineSnapshots(block)));
266
+ return { ...response, result: { ...response.result, content } };
267
+ }
268
+ return response;
269
+ }
270
+
271
+ async inlineSnapshots(block) {
272
+ if (block?.type !== 'text' || typeof block.text !== 'string' || !block.text.includes('[Snapshot](')) return block;
273
+
274
+ const snapshots = new Map();
275
+ for (const [, path] of block.text.matchAll(SNAPSHOT_LINK)) snapshots.set(path, await this.readSnapshot(path));
276
+
277
+ const text = block.text.replace(SNAPSHOT_LINK, (link, path) => {
278
+ const snapshot = snapshots.get(path);
279
+ return snapshot === null ? link : `\`\`\`yaml\n${snapshot}\n\`\`\``;
280
+ });
281
+ return { ...block, text };
282
+ }
283
+
284
+ // The snapshot at `path`, cut to SNAPSHOT_LIMIT, or null when the path is
285
+ // not a file in snapshotDir.
286
+ async readSnapshot(path) {
287
+ try {
288
+ const resolved = await realpath(path);
289
+ if (!resolved.startsWith(`${this.snapshotDir}${sep}`)) return null;
290
+
291
+ const text = await readFile(resolved, 'utf8');
292
+ return text.length > SNAPSHOT_LIMIT ? `${text.slice(0, SNAPSHOT_LIMIT)}\n# … (truncated, ${text.length} characters in all)` : text;
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+
298
+ // lastUsed is when, for the idle timeout; use orders sessions by recency,
299
+ // which two uses in the same millisecond still tell apart.
300
+ touch(session) {
301
+ session.lastUsed = Date.now();
302
+ session.use = ++this.uses;
303
+ }
304
+
305
+ closeIdleSessions() {
306
+ const cutoff = Date.now() - this.idleMs;
307
+ for (const [id, session] of this.sessions) {
308
+ if (session.busy === 0 && session.lastUsed < cutoff) void this.closeSession(id);
309
+ }
310
+ }
311
+
312
+ closeLeastRecentlyUsed() {
313
+ const idle = [...this.sessions].filter(([, session]) => session.busy === 0).sort(([, a], [, b]) => a.use - b.use);
314
+ if (idle.length === 0) return false;
315
+
316
+ void this.closeSession(idle[0][0]);
317
+ return true;
318
+ }
319
+
320
+ /**
321
+ * Closes one session's server. The browser stays open.
322
+ *
323
+ * @param {string} id
324
+ * @returns {Promise<boolean>} whether there was such a session
325
+ */
326
+ async closeSession(id) {
327
+ const session = this.sessions.get(id);
328
+ if (!session) return false;
329
+
330
+ this.sessions.delete(id);
331
+ await session.server.close().catch(() => {});
332
+ await session.transport.close();
333
+ return true;
334
+ }
335
+
336
+ async closeAll() {
337
+ await Promise.all([...this.sessions.keys()].map((id) => this.closeSession(id)));
338
+ }
339
+ }
@@ -0,0 +1,87 @@
1
+ // How many of the latest escapes are kept to name in a refusal.
2
+ const KEPT_ESCAPES = 10;
3
+
4
+ /**
5
+ * Used to keep every tab on the sandbox app. A navigation that starts
6
+ * elsewhere never leaves (see NetworkPolicy#allows), but Chromium follows an
7
+ * HTTP redirect from an app page without asking the policy again, and the
8
+ * egress proxy only refuses private addresses. A tab that lands on another
9
+ * origin that way has escaped. It is closed at once, which also discards
10
+ * what Playwright MCP collected from it (its requests and console messages),
11
+ * and the escape is counted so the MCP gateway can withhold the result of
12
+ * any tool call it happened during. When it was the only tab, a blank one is
13
+ * opened first, so the browser keeps a page.
14
+ */
15
+ export class NavigationGuard {
16
+ /**
17
+ * @param {object} options
18
+ * @param {{ onApp(url: string): boolean }} options.policy
19
+ * @param {(message: string) => void} [options.log]
20
+ */
21
+ constructor({ policy, log = () => {} }) {
22
+ this.policy = policy;
23
+ this.log = log;
24
+ this.escapes = 0;
25
+ this.recent = [];
26
+ this.pages = new Set();
27
+ this.closing = new Set();
28
+ }
29
+
30
+ /**
31
+ * Watches every page `context` has and opens.
32
+ *
33
+ * @param {import('playwright').BrowserContext} context
34
+ */
35
+ attach(context) {
36
+ this.context = context;
37
+ for (const page of context.pages()) this.watch(page);
38
+ context.on('page', (page) => this.watch(page));
39
+ }
40
+
41
+ watch(page) {
42
+ this.pages.add(page);
43
+ page.on('close', () => this.pages.delete(page));
44
+ page.on('framenavigated', (frame) => {
45
+ if (frame !== page.mainFrame() || this.policy.onApp(frame.url())) return;
46
+
47
+ const { origin } = new URL(frame.url());
48
+ this.escapes += 1;
49
+ this.recent = [...this.recent, { count: this.escapes, origin }].slice(-KEPT_ESCAPES);
50
+ this.log(`closed a page that was redirected to ${origin}`);
51
+ const closing = this.close(page);
52
+ this.closing.add(closing);
53
+ closing.finally(() => this.closing.delete(closing));
54
+ });
55
+ }
56
+
57
+ async close(page) {
58
+ try {
59
+ if (this.context.pages().every((other) => other === page)) await this.context.newPage();
60
+ await page.close();
61
+ } catch {
62
+ // The browser is closing.
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Why a tool call that began when `escapes` was `since` may not return its
68
+ * result: a page escaped while it ran, or one is still off the app. Null
69
+ * when neither. Resolves once the escaped pages are closed, so the next
70
+ * call finds the browser on a page it may use.
71
+ *
72
+ * @param {number} since
73
+ * @returns {Promise<string | null>}
74
+ */
75
+ async refusalSince(since) {
76
+ await Promise.all(this.closing);
77
+ const origins = new Set(this.recent.filter((escape) => escape.count > since).map((escape) => escape.origin));
78
+ for (const page of this.pages) {
79
+ if (!this.policy.onApp(page.url())) origins.add(new URL(page.url()).origin);
80
+ }
81
+ if (this.escapes === since && origins.size === 0) return null;
82
+
83
+ const where = origins.size > 0 ? ` to ${[...origins].join(', ')}` : '';
84
+ return `A page was redirected${where}, outside the sandbox app, and was closed. ` +
85
+ 'This browser may only show pages of the sandbox app, so the result of this call is withheld.';
86
+ }
87
+ }
@@ -0,0 +1,264 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { isIP } from 'node:net';
3
+
4
+ // IPv4 ranges that are not the public internet: this host, private networks,
5
+ // carrier-grade NAT, loopback, link-local (cloud metadata lives there),
6
+ // benchmarking, multicast and reserved.
7
+ const PRIVATE_IPV4 = [
8
+ ['0.0.0.0', 8],
9
+ ['10.0.0.0', 8],
10
+ ['100.64.0.0', 10],
11
+ ['127.0.0.0', 8],
12
+ ['169.254.0.0', 16],
13
+ ['172.16.0.0', 12],
14
+ ['192.0.0.0', 24],
15
+ ['192.168.0.0', 16],
16
+ ['198.18.0.0', 15],
17
+ ['224.0.0.0', 4],
18
+ ['240.0.0.0', 4],
19
+ ];
20
+
21
+ function ipv4ToInt(address) {
22
+ return address.split('.').reduce((total, part) => total * 256 + Number(part), 0);
23
+ }
24
+
25
+ function inIpv4Range(address, [base, bits]) {
26
+ const size = 2 ** (32 - bits);
27
+ return Math.floor(ipv4ToInt(address) / size) === Math.floor(ipv4ToInt(base) / size);
28
+ }
29
+
30
+ // The eight 16-bit groups of an IPv6 address, an embedded dotted IPv4 tail
31
+ // included, or null when it is not one.
32
+ function ipv6Groups(address) {
33
+ let text = address.toLowerCase().replace(/%.*$/, '');
34
+ const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
35
+ if (dotted) {
36
+ const value = ipv4ToInt(dotted[1]);
37
+ text = `${text.slice(0, -dotted[1].length)}${(value >>> 16).toString(16)}:${(value & 0xffff).toString(16)}`;
38
+ }
39
+
40
+ const halves = text.split('::');
41
+ if (halves.length > 2) return null;
42
+ const head = halves[0] === '' ? [] : halves[0].split(':');
43
+ const tail = halves.length === 2 && halves[1] !== '' ? halves[1].split(':') : [];
44
+ const missing = 8 - head.length - tail.length;
45
+ if (halves.length === 1 ? missing !== 0 : missing < 1) return null;
46
+
47
+ const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...tail].map((group) => parseInt(group, 16));
48
+ return groups.every((group) => Number.isInteger(group) && group >= 0 && group <= 0xffff) ? groups : null;
49
+ }
50
+
51
+ function groupsToIpv4(high, low) {
52
+ return [high >> 8, high & 255, low >> 8, low & 255].join('.');
53
+ }
54
+
55
+ function isPrivateIpv6(address) {
56
+ const groups = ipv6Groups(address);
57
+ if (!groups) return false;
58
+
59
+ const [first] = groups;
60
+ const leadingZeros = groups.findIndex((group) => group !== 0);
61
+ if (leadingZeros === -1) return true; // ::
62
+ if (leadingZeros === 7 && groups[7] === 1) return true; // ::1
63
+ // ::ffff:a.b.c.d (mapped) and ::a.b.c.d (compatible) carry an IPv4 address,
64
+ // and so does 64:ff9b::a.b.c.d (NAT64).
65
+ const mapped = leadingZeros >= 5 && (groups[5] === 0xffff || (leadingZeros >= 6 && groups[5] === 0));
66
+ const nat64 = first === 0x64 && groups[1] === 0xff9b && groups.slice(2, 6).every((group) => group === 0);
67
+ if (mapped || nat64) return isPrivateAddress(groupsToIpv4(groups[6], groups[7]));
68
+
69
+ // fc00::/7 unique local, fe80::/10 link-local, fec0::/10 site-local, ff00::/8 multicast.
70
+ return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80 || (first & 0xffc0) === 0xfec0 || (first & 0xff00) === 0xff00;
71
+ }
72
+
73
+ /**
74
+ * Whether `address`, an IP literal, is loopback, link-local, private or
75
+ * otherwise not on the public internet. A name that is not an IP is false.
76
+ *
77
+ * @param {string} address
78
+ * @returns {boolean}
79
+ */
80
+ export function isPrivateAddress(address) {
81
+ const bare = address.replace(/^\[|\]$/g, '');
82
+ const family = isIP(bare);
83
+ if (family === 4) return PRIVATE_IPV4.some((range) => inIpv4Range(bare, range));
84
+ return family === 6 && isPrivateIpv6(bare);
85
+ }
86
+
87
+ /**
88
+ * Whether `hostname` names this machine or a local network by name alone.
89
+ *
90
+ * @param {string} hostname
91
+ * @returns {boolean}
92
+ */
93
+ export function isLocalName(hostname) {
94
+ const name = hostname.toLowerCase().replace(/\.$/, '');
95
+ return name === 'localhost' || name.endsWith('.localhost') || name.endsWith('.local') || name.endsWith('.internal');
96
+ }
97
+
98
+ async function resolveAll(hostname) {
99
+ const records = await lookup(hostname, { all: true, verbatim: true });
100
+ return records.map((record) => record.address);
101
+ }
102
+
103
+ function bare(hostname) {
104
+ return hostname.toLowerCase().replace(/^\[|\]$/g, '');
105
+ }
106
+
107
+ function defaultPort(protocol) {
108
+ return protocol === 'https:' || protocol === 'wss:' ? 443 : 80;
109
+ }
110
+
111
+ /**
112
+ * Used to decide what the browser may load, at two levels.
113
+ *
114
+ * `allows` judges a request by its URL, before it is sent: a top-level
115
+ * navigation may only open the sandbox app's origin, and no URL may name a
116
+ * loopback, link-local or private address, or a local host name, other than
117
+ * the app's. Chromium follows an HTTP redirect without asking again, so this
118
+ * only sees the first URL of a redirect chain.
119
+ *
120
+ * `destination` judges each connection the browser opens, through the
121
+ * sidecar's egress proxy, whatever URL or redirect led to it. The app's own
122
+ * host and port are always reachable. Anything else must resolve only to
123
+ * public addresses, and the proxy connects to the address checked here, so a
124
+ * name whose answer changes between two lookups cannot move the connection
125
+ * onto a private address.
126
+ */
127
+ export class NetworkPolicy {
128
+ /**
129
+ * @param {object} options
130
+ * @param {string} options.appOrigin the sandbox app's origin
131
+ * @param {(hostname: string) => Promise<string[]>} [options.resolve] how host names are resolved
132
+ * @param {(address: string) => boolean} [options.isPrivate] which IP addresses are refused
133
+ * @param {number} [options.cacheMs] how long a resolution is reused
134
+ */
135
+ constructor({ appOrigin, resolve = resolveAll, isPrivate = isPrivateAddress, cacheMs = 60_000 }) {
136
+ this.appOrigin = appOrigin;
137
+ const app = new URL(appOrigin);
138
+ this.appHost = bare(app.hostname);
139
+ this.appPort = Number(app.port || defaultPort(app.protocol));
140
+ this.resolve = resolve;
141
+ this.isPrivate = isPrivate;
142
+ this.cacheMs = cacheMs;
143
+ this.resolved = new Map();
144
+ }
145
+
146
+ /**
147
+ * Returns `url` as the absolute URL a navigation would open, and why it
148
+ * may not, if it may not. A path ("/login") is resolved against the app.
149
+ *
150
+ * @param {string} url
151
+ * @returns {{ url: string, refusal: string | null }}
152
+ */
153
+ navigationTarget(url) {
154
+ const text = String(url ?? '').trim();
155
+ if (text === 'about:blank') return { url: text, refusal: null };
156
+
157
+ let target;
158
+ try {
159
+ target = text.startsWith('/') && !text.startsWith('//') ? new URL(text, this.appOrigin) : new URL(text);
160
+ } catch {
161
+ return { url: text, refusal: this.refusalMessage(text) };
162
+ }
163
+ if (target.origin !== this.appOrigin) return { url: text, refusal: this.refusalMessage(text) };
164
+
165
+ return { url: target.toString(), refusal: null };
166
+ }
167
+
168
+ refusalMessage(url) {
169
+ return `Navigation to ${url} was refused: this browser may only open pages of the sandbox app at ${this.appOrigin}`;
170
+ }
171
+
172
+ /**
173
+ * Whether a page on `url` is one of the sandbox app's. A page that is not
174
+ * on the web (about:blank, an error page) is not elsewhere either.
175
+ *
176
+ * @param {string} url
177
+ * @returns {boolean}
178
+ */
179
+ onApp(url) {
180
+ let target;
181
+ try {
182
+ target = new URL(url);
183
+ } catch {
184
+ return true;
185
+ }
186
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') return true;
187
+ return target.origin === this.appOrigin;
188
+ }
189
+
190
+ /**
191
+ * Whether the browser may send a request to `url`, judged by the URL alone.
192
+ *
193
+ * @param {string} url
194
+ * @param {{ topLevelNavigation?: boolean }} [options]
195
+ * @returns {boolean}
196
+ */
197
+ allows(url, { topLevelNavigation = false } = {}) {
198
+ let target;
199
+ try {
200
+ target = new URL(url);
201
+ } catch {
202
+ return false;
203
+ }
204
+
205
+ if (target.protocol !== 'http:' && target.protocol !== 'https:' && target.protocol !== 'ws:' && target.protocol !== 'wss:') {
206
+ return !topLevelNavigation || url === 'about:blank';
207
+ }
208
+ if (this.sameOriginAsApp(target)) return true;
209
+ if (topLevelNavigation) return false;
210
+
211
+ const hostname = bare(target.hostname);
212
+ return !this.isPrivate(hostname) && !isLocalName(hostname);
213
+ }
214
+
215
+ // A WebSocket URL is the app's when it names the app's host and port.
216
+ sameOriginAsApp(target) {
217
+ if (target.origin === this.appOrigin) return true;
218
+ if (target.protocol !== 'ws:' && target.protocol !== 'wss:') return false;
219
+
220
+ const http = new URL(target.toString());
221
+ http.protocol = target.protocol === 'ws:' ? 'http:' : 'https:';
222
+ return http.origin === this.appOrigin;
223
+ }
224
+
225
+ /**
226
+ * Returns the address a browser connection to `hostname`:`port` is to be
227
+ * made to, or why it may not be made.
228
+ *
229
+ * @param {string} hostname a host name or IP address, IPv6 with or without brackets
230
+ * @param {number} port
231
+ * @returns {Promise<{ address: string, refusal: null } | { address: null, refusal: string }>}
232
+ */
233
+ async destination(hostname, port) {
234
+ const host = bare(hostname);
235
+ if (this.isApp(host, port)) return { address: host, refusal: null };
236
+
237
+ const refused = (reason) => ({ address: null, refusal: `${host}:${port} ${reason}` });
238
+ if (isLocalName(host)) return refused('is a local host name');
239
+ if (isIP(host)) return this.isPrivate(host) ? refused('is a loopback, link-local or private address') : { address: host, refusal: null };
240
+
241
+ const addresses = await this.lookup(host);
242
+ if (addresses.length === 0) return refused('does not resolve');
243
+ if (addresses.some((address) => this.isPrivate(address))) return refused('resolves to a loopback, link-local or private address');
244
+ return { address: addresses[0], refusal: null };
245
+ }
246
+
247
+ isApp(hostname, port) {
248
+ return bare(hostname) === this.appHost && port === this.appPort;
249
+ }
250
+
251
+ async lookup(hostname) {
252
+ const cached = this.resolved.get(hostname);
253
+ if (cached && cached.until > Date.now()) return cached.addresses;
254
+
255
+ let addresses = [];
256
+ try {
257
+ addresses = await this.resolve(hostname);
258
+ } catch {
259
+ // A name that does not resolve is refused.
260
+ }
261
+ this.resolved.set(hostname, { addresses, until: Date.now() + this.cacheMs });
262
+ return addresses;
263
+ }
264
+ }