@browserbridge/bbx 1.11.0 → 1.11.1

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/SECURITY.md CHANGED
@@ -52,8 +52,15 @@ Out of scope:
52
52
 
53
53
  - By default the daemon listens on a user-owned Unix socket (mode `0700` directory) or,
54
54
  on Windows, a named pipe / localhost TCP.
55
- - Any TCP listener requires a random 256-bit auth token stored with mode `0600`;
56
- registration without it is rejected and the comparison is constant-time.
55
+ - Any TCP listener requires random 256-bit credentials stored with mode `0600`.
56
+ Agents and the Chrome native host use distinct credentials, so a remote agent
57
+ token cannot register an extension-role socket. Registration without the
58
+ role-appropriate credential is rejected and comparison is constant-time.
59
+ - Setup mutation is accepted only from the local socket or authenticated native
60
+ host. A TCP agent remains non-local even when its peer address is loopback,
61
+ preventing SSH port forwarding from turning remote access into local control.
62
+ - Daemon connections, registration time, malformed input, pending requests, and
63
+ per-client pending requests are bounded to limit resource exhaustion.
57
64
  - Proxy mode (LAN exposure) is opt-in via `bbx proxy enable` and always provisions a
58
65
  token; an invalid `bindHost` in the config rejects the config rather than widening
59
66
  the bind address.
@@ -62,3 +69,8 @@ Out of scope:
62
69
  - Ordinary Web Storage reads return key/presence metadata only. The separate
63
70
  `sensitive.read` method requires one exact key, is excluded from batches and
64
71
  automatic retries, and returns a value whole or fails atomically.
72
+ - Fetch/XHR and console capture run in the inspected page's main JavaScript
73
+ world. Results are therefore marked `integrity: "untrusted"`, copied through
74
+ strict bounded schemas, and never treated as daemon or extension authority.
75
+ - Request interception rules are bounded per tab; patterns, bodies, and headers
76
+ have protocol-level size limits and URL matchers are compiled once per rule.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserbridge/bbx",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -80,7 +80,7 @@
80
80
  },
81
81
  "devDependencies": {
82
82
  "@modelcontextprotocol/client": "^2.0.0",
83
- "@types/chrome": "^0.2.5",
83
+ "@types/chrome": "^0.2.6",
84
84
  "@types/node": "^26.2.0",
85
85
  "ajv": "^8.20.0",
86
86
  "ajv-formats": "^3.0.1",
@@ -17,15 +17,17 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
17
  const repoRoot = path.resolve(__dirname, '../../..');
18
18
 
19
19
  /**
20
- * npm exec may run package lifecycle hooks while resolving the local bin. Keep
21
- * transient `npm exec -- bbx ...` invocations from rewriting host setup or
22
- * replacing a working daemon.
20
+ * Host setup is appropriate for a global CLI install, not when this package is
21
+ * an ordinary project dependency. npm exec is also always non-mutating. The
22
+ * explicit force switch is reserved for controlled packaging/tests.
23
23
  *
24
24
  * @param {NodeJS.ProcessEnv} [env=process.env]
25
25
  * @returns {boolean}
26
26
  */
27
- function shouldSkipPostinstall(env = process.env) {
28
- return env.npm_command === 'exec';
27
+ export function shouldSkipPostinstall(env = process.env) {
28
+ const globalInstall = env.npm_config_global === 'true' || env.npm_config_global === '1';
29
+ const explicitlyEnabled = env.BBX_POSTINSTALL_FORCE === '1';
30
+ return env.npm_command === 'exec' || (!globalInstall && !explicitlyEnabled);
29
31
  }
30
32
 
31
33
  /**
@@ -9,6 +9,7 @@ import { getBridgeDir } from './config.js';
9
9
  export const BRIDGE_AUTH_TOKEN_ENV = 'BBX_AUTH_TOKEN';
10
10
  export const BRIDGE_AUTH_TOKEN_FILE_ENV = 'BBX_AUTH_TOKEN_FILE';
11
11
  const TOKEN_FILENAME = 'daemon.auth';
12
+ const EXTENSION_TOKEN_FILENAME = 'daemon.extension.auth';
12
13
  const TOKEN_BYTES = 32;
13
14
  const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,256}$/u;
14
15
  const UUID_TOKEN_PATTERN =
@@ -22,6 +23,17 @@ export function getBridgeAuthTokenPath(env = process.env) {
22
23
  return path.join(getBridgeDir(env), TOKEN_FILENAME);
23
24
  }
24
25
 
26
+ /**
27
+ * Private credential used only by the Chrome native host when registering the
28
+ * extension role. Remote agent configurations never receive this token.
29
+ *
30
+ * @param {NodeJS.ProcessEnv} [env=process.env]
31
+ * @returns {string}
32
+ */
33
+ export function getBridgeExtensionAuthTokenPath(env = process.env) {
34
+ return path.join(getBridgeDir(env), EXTENSION_TOKEN_FILENAME);
35
+ }
36
+
25
37
  /**
26
38
  * @param {unknown} value
27
39
  * @returns {string | null}
@@ -91,6 +103,25 @@ export async function readBridgeAuthToken(options = {}) {
91
103
  }
92
104
  const tokenPath = options.tokenPath ?? getBridgeAuthTokenPath();
93
105
  const readFile = options.readFile ?? fs.promises.readFile.bind(fs.promises);
106
+ return readStoredBridgeAuthToken(tokenPath, readFile);
107
+ }
108
+
109
+ /**
110
+ * @param {{ tokenPath?: string, readFile?: typeof fs.promises.readFile }} [options={}]
111
+ * @returns {Promise<string | null>}
112
+ */
113
+ export async function readBridgeExtensionAuthToken(options = {}) {
114
+ const tokenPath = options.tokenPath ?? getBridgeExtensionAuthTokenPath();
115
+ const readFile = options.readFile ?? fs.promises.readFile.bind(fs.promises);
116
+ return readStoredBridgeAuthToken(tokenPath, readFile);
117
+ }
118
+
119
+ /**
120
+ * @param {string} tokenPath
121
+ * @param {typeof fs.promises.readFile} readFile
122
+ * @returns {Promise<string | null>}
123
+ */
124
+ async function readStoredBridgeAuthToken(tokenPath, readFile) {
94
125
  try {
95
126
  return normalizeBridgeAuthToken(await readFile(tokenPath, 'utf8'));
96
127
  } catch (error) {
@@ -136,12 +167,43 @@ export async function writeBridgeAuthToken(token, options = {}) {
136
167
  */
137
168
  export async function ensureBridgeAuthToken(options = {}) {
138
169
  const tokenPath = options.tokenPath ?? getBridgeAuthTokenPath();
170
+ return ensureStoredBridgeAuthToken(tokenPath, options);
171
+ }
172
+
173
+ /**
174
+ * @param {{
175
+ * tokenPath?: string,
176
+ * readFile?: typeof fs.promises.readFile,
177
+ * writeFile?: typeof fs.promises.writeFile,
178
+ * mkdir?: typeof fs.promises.mkdir,
179
+ * chmod?: typeof fs.promises.chmod,
180
+ * randomBytesFn?: typeof randomBytes
181
+ * }} [options={}]
182
+ * @returns {Promise<string>}
183
+ */
184
+ export async function ensureBridgeExtensionAuthToken(options = {}) {
185
+ const tokenPath = options.tokenPath ?? getBridgeExtensionAuthTokenPath();
186
+ return ensureStoredBridgeAuthToken(tokenPath, options);
187
+ }
188
+
189
+ /**
190
+ * @param {string} tokenPath
191
+ * @param {{
192
+ * readFile?: typeof fs.promises.readFile,
193
+ * writeFile?: typeof fs.promises.writeFile,
194
+ * mkdir?: typeof fs.promises.mkdir,
195
+ * chmod?: typeof fs.promises.chmod,
196
+ * randomBytesFn?: typeof randomBytes
197
+ * }} options
198
+ * @returns {Promise<string>}
199
+ */
200
+ async function ensureStoredBridgeAuthToken(tokenPath, options) {
139
201
  const readFile = options.readFile ?? fs.promises.readFile.bind(fs.promises);
140
202
  const writeFile = options.writeFile ?? fs.promises.writeFile.bind(fs.promises);
141
203
  const mkdir = options.mkdir ?? fs.promises.mkdir.bind(fs.promises);
142
204
  const chmod = options.chmod ?? fs.promises.chmod.bind(fs.promises);
143
205
  const randomBytesFn = options.randomBytesFn ?? randomBytes;
144
- const existing = await readBridgeAuthToken({ tokenPath, readFile });
206
+ const existing = await readStoredBridgeAuthToken(tokenPath, readFile);
145
207
  if (existing) {
146
208
  return existing;
147
209
  }
@@ -44,7 +44,12 @@ import {
44
44
  getSocketPath,
45
45
  } from './config.js';
46
46
  import { ArtifactStore } from './artifact-store.js';
47
- import { bridgeAuthTokensEqual, ensureBridgeAuthToken, readBridgeAuthToken } from './auth-token.js';
47
+ import {
48
+ bridgeAuthTokensEqual,
49
+ ensureBridgeAuthToken,
50
+ ensureBridgeExtensionAuthToken,
51
+ readBridgeAuthToken,
52
+ } from './auth-token.js';
48
53
  import { normalizeDaemonLogger } from './daemon-logger.js';
49
54
  import { DomBaselineOwnerRegistry, isValidBaselineId } from './dom-baseline-owners.js';
50
55
  import { writeJsonLine } from './framing.js';
@@ -59,7 +64,7 @@ setProtocolPackageVersion(DAEMON_VERSION);
59
64
  /** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
60
65
  /** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
61
66
  /** @typedef {'agent' | 'extension'} SocketRole */
62
- /** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __browserExtensionId?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
67
+ /** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __localControl?: boolean, __registrationTimeoutId?: NodeJS.Timeout, __invalidLineCount?: number, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __browserExtensionId?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
63
68
  /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, mcpEra?: string, method?: string, protocolVersion?: string, baselineId?: string | null, automaticMcpRetry?: boolean, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
64
69
  /**
65
70
  * @typedef {{
@@ -123,6 +128,13 @@ const DIAGNOSTIC_METHODS = new Set([
123
128
  'setup.get_status',
124
129
  'setup.install',
125
130
  ]);
131
+ export const DAEMON_REGISTRATION_TIMEOUT_MS = 5_000;
132
+ export const MAX_DAEMON_CONNECTIONS = 64;
133
+ export const MAX_DAEMON_PENDING_REQUESTS = 128;
134
+ export const MAX_DAEMON_PENDING_REQUESTS_PER_CLIENT = 16;
135
+ export const MAX_DAEMON_INVALID_LINES = 3;
136
+ const MAX_CLIENT_ID_LENGTH = 128;
137
+ const MAX_ROUTING_LABEL_LENGTH = 128;
126
138
 
127
139
  /**
128
140
  * @param {unknown} value
@@ -134,6 +146,42 @@ function asHealthRecord(value) {
134
146
  : null;
135
147
  }
136
148
 
149
+ /**
150
+ * @param {unknown} value
151
+ * @returns {string | undefined}
152
+ */
153
+ function normalizeRoutingLabel(value) {
154
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ROUTING_LABEL_LENGTH) {
155
+ return undefined;
156
+ }
157
+ return hasAsciiControlCharacters(value) ? undefined : value;
158
+ }
159
+
160
+ /** @param {string} value @returns {boolean} */
161
+ function hasAsciiControlCharacters(value) {
162
+ for (let index = 0; index < value.length; index += 1) {
163
+ const code = value.charCodeAt(index);
164
+ if (code < 32 || code === 127) return true;
165
+ }
166
+ return false;
167
+ }
168
+
169
+ /**
170
+ * @param {unknown} value
171
+ * @returns {string | null}
172
+ */
173
+ function normalizeClientId(value) {
174
+ if (value == null || value === '') return randomUUID();
175
+ if (
176
+ typeof value !== 'string' ||
177
+ value.length > MAX_CLIENT_ID_LENGTH ||
178
+ !/^[A-Za-z0-9_.:-]+$/u.test(value)
179
+ ) {
180
+ return null;
181
+ }
182
+ return value;
183
+ }
184
+
137
185
  /**
138
186
  * @param {unknown} value
139
187
  * @returns {number | undefined}
@@ -383,6 +431,7 @@ export class BridgeDaemon {
383
431
  * setupInstaller?: (params: Record<string, unknown>) => Promise<SetupInstallResult>,
384
432
  * logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>,
385
433
  * authToken?: string | null,
434
+ * extensionAuthToken?: string | null,
386
435
  * artifactStore?: ArtifactStore
387
436
  * }} [options={}]
388
437
  */
@@ -394,6 +443,7 @@ export class BridgeDaemon {
394
443
  setupInstaller = installSetupTarget,
395
444
  logger = undefined,
396
445
  authToken = undefined,
446
+ extensionAuthToken = undefined,
397
447
  artifactStore = new ArtifactStore(getArtifactStorePath()),
398
448
  } = {}) {
399
449
  this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
@@ -437,6 +487,8 @@ export class BridgeDaemon {
437
487
  this.requestStartTimes = new Map();
438
488
  /** @type {string | null | undefined} */
439
489
  this.authToken = authToken;
490
+ /** @type {string | null | undefined} */
491
+ this.extensionAuthToken = extensionAuthToken;
440
492
  this.artifactStore = artifactStore;
441
493
  this.artifactStoreInitialized = false;
442
494
  this.domBaselines = new DomBaselineOwnerRegistry({
@@ -580,26 +632,32 @@ export class BridgeDaemon {
580
632
  return;
581
633
  }
582
634
 
583
- if (this.isAuthRequired() && !bridgeAuthTokensEqual(message.authToken, this.authToken)) {
584
- this.logger.error('socket registration rejected', { role: message.role ?? null });
635
+ if (message.role !== 'extension' && message.role !== 'agent') {
585
636
  void writeJsonLine(socket, {
586
637
  type: 'registration_failed',
587
638
  error: {
588
- code: ERROR_CODES.ACCESS_DENIED,
589
- message: 'Bridge daemon authentication failed.',
639
+ code: ERROR_CODES.INVALID_REQUEST,
640
+ message: 'Socket role must be "agent" or "extension".',
590
641
  },
591
- }).finally(() => socket.destroy());
642
+ });
592
643
  return;
593
644
  }
594
645
 
595
- if (message.role !== 'extension' && message.role !== 'agent') {
646
+ const expectedToken =
647
+ message.role === 'extension'
648
+ ? this.extensionAuthToken
649
+ : this.isAuthRequired()
650
+ ? this.authToken
651
+ : null;
652
+ if (expectedToken && !bridgeAuthTokensEqual(message.authToken, expectedToken)) {
653
+ this.logger.error('socket registration rejected', { role: message.role ?? null });
596
654
  void writeJsonLine(socket, {
597
655
  type: 'registration_failed',
598
656
  error: {
599
- code: ERROR_CODES.INVALID_REQUEST,
600
- message: 'Socket role must be "agent" or "extension".',
657
+ code: ERROR_CODES.ACCESS_DENIED,
658
+ message: 'Bridge daemon authentication failed.',
601
659
  },
602
- });
660
+ }).finally(() => socket.destroy());
603
661
  return;
604
662
  }
605
663
 
@@ -609,13 +667,16 @@ export class BridgeDaemon {
609
667
  configurable: false,
610
668
  writable: false,
611
669
  });
670
+ if (socket.__registrationTimeoutId) {
671
+ clearTimeout(socket.__registrationTimeoutId);
672
+ socket.__registrationTimeoutId = undefined;
673
+ }
612
674
  if (message.role === 'extension') {
675
+ socket.__localControl = true;
613
676
  const extensionId = randomUUID();
614
677
  socket.__extensionId = extensionId;
615
- socket.__browserName =
616
- typeof message.browserName === 'string' ? message.browserName : undefined;
617
- socket.__profileLabel =
618
- typeof message.profileLabel === 'string' ? message.profileLabel : undefined;
678
+ socket.__browserName = normalizeRoutingLabel(message.browserName);
679
+ socket.__profileLabel = normalizeRoutingLabel(message.profileLabel);
619
680
  socket.__browserExtensionId = normalizeBrowserExtensionId(message.browserExtensionId);
620
681
  socket.__lastActiveAt = Date.now();
621
682
  this.extensionSockets.set(extensionId, socket);
@@ -630,7 +691,18 @@ export class BridgeDaemon {
630
691
  }
631
692
 
632
693
  if (message.role === 'agent') {
633
- const clientId = message.clientId || randomUUID();
694
+ socket.__localControl = this.transport.type === 'socket';
695
+ const clientId = normalizeClientId(message.clientId);
696
+ if (!clientId) {
697
+ void writeJsonLine(socket, {
698
+ type: 'registration_failed',
699
+ error: {
700
+ code: ERROR_CODES.INVALID_REQUEST,
701
+ message: 'Agent clientId is invalid.',
702
+ },
703
+ }).finally(() => socket.destroy());
704
+ return;
705
+ }
634
706
  this.agentSockets.set(clientId, socket);
635
707
  socket.__clientId = clientId;
636
708
  this.logger.info('agent registered', { clientId });
@@ -649,6 +721,10 @@ export class BridgeDaemon {
649
721
  if (this.authToken === undefined) {
650
722
  this.authToken = this.transport.type === 'tcp' ? await ensureBridgeAuthToken() : null;
651
723
  }
724
+ if (this.extensionAuthToken === undefined) {
725
+ this.extensionAuthToken =
726
+ this.transport.type === 'tcp' ? await ensureBridgeExtensionAuthToken() : null;
727
+ }
652
728
 
653
729
  if (this.transport.type === 'socket' && !isWindowsNamedPipePath(this.socketPath)) {
654
730
  const socketDir = path.dirname(this.socketPath);
@@ -677,6 +753,14 @@ export class BridgeDaemon {
677
753
 
678
754
  this.server = net.createServer((socket) => {
679
755
  const typedSocket = /** @type {ClientSocket} */ (socket);
756
+ typedSocket.__invalidLineCount = 0;
757
+ typedSocket.__registrationTimeoutId = setTimeout(() => {
758
+ if (!typedSocket.__role) {
759
+ this.logger.warn('unregistered socket timed out');
760
+ typedSocket.destroy();
761
+ }
762
+ }, DAEMON_REGISTRATION_TIMEOUT_MS);
763
+ typedSocket.__registrationTimeoutId.unref?.();
680
764
  typedSocket.on('error', (err) => {
681
765
  this.logger.error('socket error', { message: err.message });
682
766
  });
@@ -694,10 +778,18 @@ export class BridgeDaemon {
694
778
  onProtocolError: (error) => {
695
779
  this.logger.error('socket protocol error', { message: error.message });
696
780
  },
781
+ onInvalidLine: () => {
782
+ typedSocket.__invalidLineCount = (typedSocket.__invalidLineCount ?? 0) + 1;
783
+ if (typedSocket.__invalidLineCount >= MAX_DAEMON_INVALID_LINES) {
784
+ this.logger.warn('socket sent too many malformed JSON lines');
785
+ typedSocket.destroy();
786
+ }
787
+ },
697
788
  }
698
789
  );
699
790
  typedSocket.on('close', () => this.handleSocketClose(typedSocket));
700
791
  });
792
+ this.server.maxConnections = MAX_DAEMON_CONNECTIONS;
701
793
 
702
794
  const server = this.server;
703
795
  await new Promise((resolve, reject) => {
@@ -1076,7 +1168,7 @@ export class BridgeDaemon {
1076
1168
  }
1077
1169
 
1078
1170
  if (request.method === 'setup.install') {
1079
- if (socket.__role === 'agent' && !this.isLocalSocket(socket)) {
1171
+ if (!socket.__localControl) {
1080
1172
  const response = createFailure(
1081
1173
  request.id,
1082
1174
  ERROR_CODES.ACCESS_DENIED,
@@ -1115,6 +1207,26 @@ export class BridgeDaemon {
1115
1207
  typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
1116
1208
  const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
1117
1209
 
1210
+ const ownerPendingCount = this.pendingRequestsByOwnerSocket.get(socket)?.size ?? 0;
1211
+ if (
1212
+ this.pendingRequests.size >= MAX_DAEMON_PENDING_REQUESTS ||
1213
+ ownerPendingCount >= MAX_DAEMON_PENDING_REQUESTS_PER_CLIENT
1214
+ ) {
1215
+ const response = createFailure(
1216
+ request.id,
1217
+ ERROR_CODES.INVALID_REQUEST,
1218
+ 'Daemon pending-request limit reached. Wait for existing requests to finish.',
1219
+ {
1220
+ maxPendingRequests: MAX_DAEMON_PENDING_REQUESTS,
1221
+ maxPendingRequestsPerClient: MAX_DAEMON_PENDING_REQUESTS_PER_CLIENT,
1222
+ },
1223
+ { method: request.method }
1224
+ );
1225
+ await writeJsonLine(socket, { type: 'agent.response', response });
1226
+ this.recordDaemonRequestOutcome(request, false);
1227
+ return;
1228
+ }
1229
+
1118
1230
  const baselineId =
1119
1231
  typeof request.params.baselineId === 'string' ? request.params.baselineId : null;
1120
1232
  const usesBaselineOwner =
@@ -1222,23 +1334,6 @@ export class BridgeDaemon {
1222
1334
  }
1223
1335
  }
1224
1336
 
1225
- /**
1226
- * @param {ClientSocket} socket
1227
- * @returns {boolean}
1228
- */
1229
- isLocalSocket(socket) {
1230
- if (this.transport.type === 'socket') {
1231
- return true;
1232
- }
1233
- const address = socket.remoteAddress ?? '';
1234
- return (
1235
- address === '127.0.0.1' ||
1236
- address === '::1' ||
1237
- address === 'localhost' ||
1238
- address === '::ffff:127.0.0.1'
1239
- );
1240
- }
1241
-
1242
1337
  /**
1243
1338
  * Select from a snapshot so routing neither mutates nor observes mutations to
1244
1339
  * the live extension map while candidates are ordered.
@@ -1348,13 +1443,15 @@ export class BridgeDaemon {
1348
1443
  */
1349
1444
  handleExtensionIdentity(socket, message) {
1350
1445
  let changed = false;
1351
- if (typeof message.browserName === 'string') {
1352
- changed = changed || socket.__browserName !== message.browserName;
1353
- socket.__browserName = message.browserName;
1446
+ const browserName = normalizeRoutingLabel(message.browserName);
1447
+ if (browserName) {
1448
+ changed = changed || socket.__browserName !== browserName;
1449
+ socket.__browserName = browserName;
1354
1450
  }
1355
- if (typeof message.profileLabel === 'string') {
1356
- changed = changed || socket.__profileLabel !== message.profileLabel;
1357
- socket.__profileLabel = message.profileLabel;
1451
+ const profileLabel = normalizeRoutingLabel(message.profileLabel);
1452
+ if (profileLabel) {
1453
+ changed = changed || socket.__profileLabel !== profileLabel;
1454
+ socket.__profileLabel = profileLabel;
1358
1455
  }
1359
1456
  if (typeof message.browserExtensionId === 'string') {
1360
1457
  const browserExtensionId = normalizeBrowserExtensionId(message.browserExtensionId);
@@ -1457,12 +1554,11 @@ export class BridgeDaemon {
1457
1554
 
1458
1555
  /**
1459
1556
  * @param {ClientSocket} socket
1460
- * @param {DaemonMessage} message
1557
+ * @param {DaemonMessage} _message
1461
1558
  * @returns {void}
1462
1559
  */
1463
- handleExtensionActivity(socket, message) {
1464
- socket.__lastActiveAt =
1465
- typeof message.at === 'number' && Number.isFinite(message.at) ? message.at : Date.now();
1560
+ handleExtensionActivity(socket, _message) {
1561
+ socket.__lastActiveAt = Date.now();
1466
1562
  }
1467
1563
 
1468
1564
  /**
@@ -1623,6 +1719,10 @@ export class BridgeDaemon {
1623
1719
  * @returns {void}
1624
1720
  */
1625
1721
  handleSocketClose(socket) {
1722
+ if (socket.__registrationTimeoutId) {
1723
+ clearTimeout(socket.__registrationTimeoutId);
1724
+ socket.__registrationTimeoutId = undefined;
1725
+ }
1626
1726
  if (socket.__extensionId) {
1627
1727
  this.artifactStore.deleteByExtension(socket.__extensionId);
1628
1728
  this.domBaselines.clearForSocket(socket);
@@ -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,11 +163,11 @@ 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(
@@ -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;