@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 +14 -2
- package/package.json +2 -2
- package/packages/native-host/bin/postinstall.js +7 -5
- package/packages/native-host/src/auth-token.js +63 -1
- package/packages/native-host/src/daemon.js +144 -44
- package/packages/native-host/src/install-manifest.js +3 -9
- package/packages/native-host/src/native-host.js +3 -3
- package/packages/protocol/src/defaults.js +6 -0
- package/packages/protocol/src/protocol.js +62 -3
- package/packages/protocol/src/types.ts +2 -0
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
|
|
56
|
-
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 (
|
|
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.
|
|
589
|
-
message: '
|
|
639
|
+
code: ERROR_CODES.INVALID_REQUEST,
|
|
640
|
+
message: 'Socket role must be "agent" or "extension".',
|
|
590
641
|
},
|
|
591
|
-
})
|
|
642
|
+
});
|
|
592
643
|
return;
|
|
593
644
|
}
|
|
594
645
|
|
|
595
|
-
|
|
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.
|
|
600
|
-
message: '
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1352
|
-
|
|
1353
|
-
socket.__browserName
|
|
1446
|
+
const browserName = normalizeRoutingLabel(message.browserName);
|
|
1447
|
+
if (browserName) {
|
|
1448
|
+
changed = changed || socket.__browserName !== browserName;
|
|
1449
|
+
socket.__browserName = browserName;
|
|
1354
1450
|
}
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
socket.__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}
|
|
1557
|
+
* @param {DaemonMessage} _message
|
|
1461
1558
|
* @returns {void}
|
|
1462
1559
|
*/
|
|
1463
|
-
handleExtensionActivity(socket,
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
166
|
+
const authToken = await readBridgeExtensionAuthToken();
|
|
167
167
|
await writeJsonLine(socket, {
|
|
168
168
|
type: 'register',
|
|
169
169
|
role: 'extension',
|
|
170
|
-
|
|
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 (
|
|
304
|
-
|
|
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
|
-
|
|
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;
|