@browserbridge/bbx 1.11.0 → 1.11.2

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.
@@ -1,9 +1,111 @@
1
1
  // @ts-check
2
2
 
3
- import { once } from 'node:events';
4
-
5
3
  import { MAX_JSON_LINE_BYTES, MAX_NATIVE_MESSAGE_BYTES } from '../../protocol/src/index.js';
6
4
 
5
+ export const MAX_OUTPUT_QUEUE_BYTES = 4 * MAX_JSON_LINE_BYTES;
6
+ export const MAX_OUTPUT_QUEUE_MESSAGES = 128;
7
+ export const OUTPUT_DRAIN_TIMEOUT_MS = 30_000;
8
+
9
+ /** @typedef {NodeJS.WritableStream & { destroyed?: boolean, writableEnded?: boolean, destroy?: () => unknown }} OutputStream */
10
+ /** @typedef {{ parts: Array<string | Buffer>, bytes: number, resolve: () => void, reject: (error: Error) => void }} QueuedWrite */
11
+
12
+ /** @param {OutputStream} stream @returns {(parts: Array<string | Buffer>) => Promise<void>} */
13
+ function createBoundedWriter(stream) {
14
+ /** @type {QueuedWrite[]} */
15
+ const queue = [];
16
+ let bytes = 0;
17
+ let running = false;
18
+ /** @type {Error | null} */
19
+ let failure = null;
20
+ /** @type {((error: Error) => void) | null} */
21
+ let cancelDrain = null;
22
+
23
+ /** @param {Error} error */
24
+ function fail(error) {
25
+ failure ??= error;
26
+ cancelDrain?.(error);
27
+ for (const entry of queue.splice(0)) entry.reject(error);
28
+ bytes = 0;
29
+ stream.destroy?.();
30
+ }
31
+
32
+ async function pump() {
33
+ if (running) return;
34
+ running = true;
35
+ try {
36
+ while (queue.length && !failure) {
37
+ const entry = queue[0];
38
+ for (const part of entry.parts) {
39
+ if (failure) throw failure;
40
+ if (stream.destroyed || stream.writableEnded) throw new Error('Output stream closed.');
41
+ if (!stream.write(part)) {
42
+ await new Promise((resolve, reject) => {
43
+ const timer = setTimeout(
44
+ () => finish(new Error('Output drain timed out.')),
45
+ OUTPUT_DRAIN_TIMEOUT_MS
46
+ );
47
+ timer.unref?.();
48
+ /** @param {Error} [error] */
49
+ function finish(error) {
50
+ clearTimeout(timer);
51
+ stream.removeListener('drain', drained);
52
+ stream.removeListener('close', closed);
53
+ stream.removeListener('error', finish);
54
+ cancelDrain = null;
55
+ if (error) reject(error);
56
+ else resolve(undefined);
57
+ }
58
+ const drained = () => finish();
59
+ const closed = () => finish(new Error('Output stream closed while writing.'));
60
+ cancelDrain = finish;
61
+ stream.once('drain', drained);
62
+ stream.once('close', closed);
63
+ stream.once('error', finish);
64
+ });
65
+ }
66
+ }
67
+ if (failure) break;
68
+ queue.shift();
69
+ bytes -= entry.bytes;
70
+ entry.resolve();
71
+ }
72
+ } catch (error) {
73
+ fail(error instanceof Error ? error : new Error(String(error)));
74
+ } finally {
75
+ running = false;
76
+ }
77
+ }
78
+
79
+ return (parts) => {
80
+ if (failure) return Promise.reject(failure);
81
+ const size = parts.reduce((total, part) => total + Buffer.byteLength(part), 0);
82
+ if (queue.length >= MAX_OUTPUT_QUEUE_MESSAGES || bytes + size > MAX_OUTPUT_QUEUE_BYTES) {
83
+ const error = new Error('Output queue limit exceeded.');
84
+ fail(error);
85
+ return Promise.reject(error);
86
+ }
87
+ const result = new Promise((resolve, reject) => {
88
+ queue.push({ parts, bytes: size, resolve: () => resolve(undefined), reject });
89
+ bytes += size;
90
+ });
91
+ void pump();
92
+ return result;
93
+ };
94
+ }
95
+
96
+ /** @type {WeakMap<OutputStream, ReturnType<typeof createBoundedWriter>>} */
97
+ const outputWriters = new WeakMap();
98
+
99
+ /** @param {OutputStream} stream @param {Array<string | Buffer>} parts */
100
+ function writeParts(stream, parts) {
101
+ let writer = outputWriters.get(stream);
102
+ if (!writer) {
103
+ writer = createBoundedWriter(stream);
104
+ outputWriters.set(stream, writer);
105
+ }
106
+ return writer(parts);
107
+ }
108
+
7
109
  /**
8
110
  * @param {NodeJS.WritableStream} stream
9
111
  * @param {unknown} message
@@ -16,12 +118,7 @@ export async function writeNativeMessage(stream, message) {
16
118
  }
17
119
  const header = Buffer.alloc(4);
18
120
  header.writeUInt32LE(payload.length, 0);
19
- if (!stream.write(header)) {
20
- await once(stream, 'drain');
21
- }
22
- if (!stream.write(payload)) {
23
- await once(stream, 'drain');
24
- }
121
+ await writeParts(stream, [header, payload]);
25
122
  }
26
123
 
27
124
  /**
@@ -29,12 +126,7 @@ export async function writeNativeMessage(stream, message) {
29
126
  * @returns {(message: unknown) => Promise<void>}
30
127
  */
31
128
  export function createNativeMessageWriter(stream) {
32
- let queue = Promise.resolve();
33
- return (message) => {
34
- const writePromise = queue.then(() => writeNativeMessage(stream, message));
35
- queue = writePromise.catch(() => {});
36
- return writePromise;
37
- };
129
+ return (message) => writeNativeMessage(stream, message);
38
130
  }
39
131
 
40
132
  /**
@@ -198,7 +290,5 @@ export async function writeJsonLine(socket, message) {
198
290
  if (byteLength > MAX_JSON_LINE_BYTES) {
199
291
  throw new Error(`JSON line exceeds ${MAX_JSON_LINE_BYTES} bytes: ${byteLength}`);
200
292
  }
201
- if (!socket.write(line)) {
202
- await once(socket, 'drain');
203
- }
293
+ await writeParts(socket, [line]);
204
294
  }
@@ -184,7 +184,8 @@ export function getDefaultExtensionId(env = process.env) {
184
184
 
185
185
  /**
186
186
  * Build the allowed_origins list.
187
- * If an extension ID was provided, ensures its origin is present.
187
+ * If an extension ID was provided, it is the complete allowlist so stale
188
+ * origins from an earlier installation do not retain native-host access.
188
189
  * Otherwise falls back to existing origins or a placeholder.
189
190
  *
190
191
  * @param {{allowed_origins?: string[]} | null} existingManifest
@@ -199,14 +200,7 @@ export function getAllowedOrigins(existingManifest, extensionId) {
199
200
 
200
201
  if (extensionId) {
201
202
  const origin = `chrome-extension://${extensionId}/`;
202
- const merged = new Set(existing);
203
- merged.add(origin);
204
- for (const item of [...merged]) {
205
- if (item.includes('__REPLACE_WITH_EXTENSION_ID__')) {
206
- merged.delete(item);
207
- }
208
- }
209
- return [...merged];
203
+ return [origin];
210
204
  }
211
205
 
212
206
  if (existing.length > 0) return existing;
@@ -8,7 +8,7 @@ import {
8
8
  parseJsonLines,
9
9
  sanitizeIncidentalText,
10
10
  } from '../../protocol/src/index.js';
11
- import { readBridgeAuthToken } from './auth-token.js';
11
+ import { readBridgeExtensionAuthToken } from './auth-token.js';
12
12
  import { createSocketBridgeTransport, getBridgeTransport } from './config.js';
13
13
  import { spawnBridgeDaemonProcess } from './daemon-process.js';
14
14
  import { createNativeMessageReader, createNativeMessageWriter, writeJsonLine } from './framing.js';
@@ -163,16 +163,17 @@ export async function runNativeHost({
163
163
  socket.once('close', cleanupStdinEndListener);
164
164
  socket.once('end', cleanupStdinEndListener);
165
165
  socket.once('error', cleanupStdinEndListener);
166
- const authToken = resolvedTransport.type === 'tcp' ? await readBridgeAuthToken() : null;
166
+ const authToken = await readBridgeExtensionAuthToken();
167
167
  await writeJsonLine(socket, {
168
168
  type: 'register',
169
169
  role: 'extension',
170
- ...(authToken ? { authToken } : {}),
170
+ authToken,
171
171
  });
172
172
 
173
173
  parseJsonLines(
174
174
  socket,
175
175
  (raw) => {
176
+ if (socket.destroyed) return;
176
177
  const message = /** @type {Record<string, unknown>} */ (raw);
177
178
  void (async () => {
178
179
  if (message.type === 'extension.request') {
@@ -201,6 +202,7 @@ export async function runNativeHost({
201
202
  });
202
203
  }
203
204
  })().catch((err) => {
205
+ socket.destroy();
204
206
  console.error(
205
207
  'native-host: socket message handler failed:',
206
208
  err instanceof Error ? err.message : err
@@ -220,6 +222,7 @@ export async function runNativeHost({
220
222
  createNativeMessageReader(
221
223
  process.stdin,
222
224
  (message) => {
225
+ if (socket.destroyed) return;
223
226
  void (async () => {
224
227
  if (isHostBridgeRequest(message)) {
225
228
  await writeJsonLine(socket, {
@@ -271,6 +274,7 @@ export async function runNativeHost({
271
274
  response: message,
272
275
  });
273
276
  })().catch((err) => {
277
+ socket.destroy();
274
278
  console.error(
275
279
  'native-host: stdin message handler failed:',
276
280
  err instanceof Error ? err.message : err
@@ -45,6 +45,12 @@ export const MAX_BATCH_CALLS = 20;
45
45
 
46
46
  /** Maximum read-only batch calls executing concurrently. */
47
47
  export const MAX_BATCH_CONCURRENCY = 5;
48
+ export const MAX_INTERCEPT_RULES_PER_TAB = 32;
49
+ export const MAX_INTERCEPT_URL_PATTERN_LENGTH = 2_048;
50
+ export const MAX_INTERCEPT_BODY_BYTES = 262_144;
51
+ export const MAX_INTERCEPT_HEADERS = 64;
52
+ export const MAX_INTERCEPT_HEADER_VALUE_BYTES = 8_192;
53
+ export const MAX_INTERCEPT_HEADER_BYTES = 65_536;
48
54
  export const SCREENSHOT_AUTO_INLINE_BYTES = 262_144;
49
55
  export const SCREENSHOT_MAX_INLINE_BYTES = 524_288;
50
56
  export const ARTIFACT_CHUNK_BYTES = 196_608;
@@ -26,6 +26,11 @@ import {
26
26
  MAX_EXTRACT_SETTLE_TIMEOUT_MS,
27
27
  MAX_ARTIFACT_BYTES,
28
28
  MAX_HAR_ENTRIES,
29
+ MAX_INTERCEPT_BODY_BYTES,
30
+ MAX_INTERCEPT_HEADER_BYTES,
31
+ MAX_INTERCEPT_HEADERS,
32
+ MAX_INTERCEPT_HEADER_VALUE_BYTES,
33
+ MAX_INTERCEPT_URL_PATTERN_LENGTH,
29
34
  MAX_SENSITIVE_VALUE_BYTES,
30
35
  } from './defaults.js';
31
36
  import { BridgeError, ERROR_CODES, getErrorRecovery } from './errors.js';
@@ -300,8 +305,16 @@ export function validateBridgeRequest(request) {
300
305
 
301
306
  const candidate = /** @type {Record<string, unknown>} */ (request);
302
307
 
303
- if (typeof candidate.id !== 'string' || !candidate.id.trim()) {
304
- throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'Request id must be a non-empty string.');
308
+ if (
309
+ typeof candidate.id !== 'string' ||
310
+ !candidate.id.trim() ||
311
+ candidate.id.length > 128 ||
312
+ hasAsciiControlCharacters(candidate.id)
313
+ ) {
314
+ throw new BridgeError(
315
+ ERROR_CODES.INVALID_REQUEST,
316
+ 'Request id must be a non-empty string of at most 128 characters without controls.'
317
+ );
305
318
  }
306
319
 
307
320
  if (
@@ -378,6 +391,15 @@ export function validateBridgeRequest(request) {
378
391
  };
379
392
  }
380
393
 
394
+ /** @param {string} value @returns {boolean} */
395
+ function hasAsciiControlCharacters(value) {
396
+ for (let index = 0; index < value.length; index += 1) {
397
+ const code = value.charCodeAt(index);
398
+ if (code < 32 || code === 127) return true;
399
+ }
400
+ return false;
401
+ }
402
+
381
403
  /**
382
404
  * @param {unknown} source
383
405
  * @param {unknown} value
@@ -1503,6 +1525,12 @@ export function normalizeNetworkInterceptAddParams(params = {}) {
1503
1525
  if (typeof params.urlPattern !== 'string' || !params.urlPattern) {
1504
1526
  throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'urlPattern is required.');
1505
1527
  }
1528
+ if (params.urlPattern.length > MAX_INTERCEPT_URL_PATTERN_LENGTH) {
1529
+ throw new BridgeError(
1530
+ ERROR_CODES.INVALID_REQUEST,
1531
+ `urlPattern must not exceed ${MAX_INTERCEPT_URL_PATTERN_LENGTH} characters.`
1532
+ );
1533
+ }
1506
1534
 
1507
1535
  const action = params.action ?? DEFAULT_NETWORK_INTERCEPT_ACTION;
1508
1536
  if (action !== 'fulfill' && action !== 'continue' && action !== 'block') {
@@ -1523,6 +1551,15 @@ export function normalizeNetworkInterceptAddParams(params = {}) {
1523
1551
  if (params.body !== undefined && typeof params.body !== 'string') {
1524
1552
  throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'body must be a string.');
1525
1553
  }
1554
+ if (
1555
+ typeof params.body === 'string' &&
1556
+ new TextEncoder().encode(params.body).byteLength > MAX_INTERCEPT_BODY_BYTES
1557
+ ) {
1558
+ throw new BridgeError(
1559
+ ERROR_CODES.INVALID_REQUEST,
1560
+ `body must not exceed ${MAX_INTERCEPT_BODY_BYTES} UTF-8 bytes.`
1561
+ );
1562
+ }
1526
1563
 
1527
1564
  /** @type {Record<string, string> | undefined} */
1528
1565
  let headers;
@@ -1533,8 +1570,16 @@ export function normalizeNetworkInterceptAddParams(params = {}) {
1533
1570
  'headers must be an object containing string values.'
1534
1571
  );
1535
1572
  }
1573
+ const entries = Object.entries(params.headers);
1574
+ if (entries.length > MAX_INTERCEPT_HEADERS) {
1575
+ throw new BridgeError(
1576
+ ERROR_CODES.INVALID_REQUEST,
1577
+ `headers must contain at most ${MAX_INTERCEPT_HEADERS} entries.`
1578
+ );
1579
+ }
1536
1580
  headers = {};
1537
- for (const [name, value] of Object.entries(params.headers)) {
1581
+ let headerBytes = 0;
1582
+ for (const [name, value] of entries) {
1538
1583
  if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) {
1539
1584
  throw new BridgeError(
1540
1585
  ERROR_CODES.INVALID_REQUEST,
@@ -1547,6 +1592,20 @@ export function normalizeNetworkInterceptAddParams(params = {}) {
1547
1592
  `Header ${name} must have a string value without newlines.`
1548
1593
  );
1549
1594
  }
1595
+ const valueBytes = new TextEncoder().encode(value).byteLength;
1596
+ if (valueBytes > MAX_INTERCEPT_HEADER_VALUE_BYTES) {
1597
+ throw new BridgeError(
1598
+ ERROR_CODES.INVALID_REQUEST,
1599
+ `Header ${name} must not exceed ${MAX_INTERCEPT_HEADER_VALUE_BYTES} UTF-8 bytes.`
1600
+ );
1601
+ }
1602
+ headerBytes += new TextEncoder().encode(name).byteLength + valueBytes;
1603
+ if (headerBytes > MAX_INTERCEPT_HEADER_BYTES) {
1604
+ throw new BridgeError(
1605
+ ERROR_CODES.INVALID_REQUEST,
1606
+ `headers must not exceed ${MAX_INTERCEPT_HEADER_BYTES} UTF-8 bytes in total.`
1607
+ );
1608
+ }
1550
1609
  headers[name] = value;
1551
1610
  }
1552
1611
  }
@@ -1053,6 +1053,8 @@ export interface NetworkResult {
1053
1053
  dropped: number;
1054
1054
  abandoned: number;
1055
1055
  source: NetworkSource;
1056
+ provenance: 'page_main_world' | 'chrome_devtools_protocol';
1057
+ integrity: 'untrusted' | 'browser_observed';
1056
1058
  capture: NetworkCaptureAction | null;
1057
1059
  armed: boolean;
1058
1060
  armedDuringCapture: boolean;