browser-commander 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +145 -0
- package/README.md +216 -32
- package/bin/browser-commander.js +16 -0
- package/eslint.config.js +10 -0
- package/examples/react-test-app/src/App.jsx +24 -23
- package/extension/background.js +14 -0
- package/extension/manifest.json +12 -0
- package/extension/relay-handler.js +353 -0
- package/package.json +19 -4
- package/src/browser/attach/differences.js +131 -0
- package/src/browser/attach/extension-relay.js +402 -0
- package/src/browser/attach/index.js +81 -0
- package/src/browser/attach/snapshot-launch.js +146 -0
- package/src/browser/attach/snapshot.js +374 -0
- package/src/browser/attach/websocket.js +615 -0
- package/src/browser/browser-cookie-cache.js +12 -17
- package/src/browser/browser-cookie-credentials.js +6 -9
- package/src/browser/browser-cookies.js +5 -2
- package/src/browser/cdp-session.js +94 -0
- package/src/browser/connector.js +42 -4
- package/src/browser/debugging-port.js +155 -0
- package/src/browser/launch-options.js +63 -52
- package/src/browser/launch-restrictions.json +147 -0
- package/src/browser/launcher.js +329 -133
- package/src/browser/media.js +5 -0
- package/src/browser/migration/bookmarks.js +73 -0
- package/src/browser/migration/chromium-crypto.js +89 -0
- package/src/browser/migration/cookies.js +155 -0
- package/src/browser/migration/extensions.js +187 -0
- package/src/browser/migration/firefox-bookmarks.js +144 -0
- package/src/browser/migration/firefox-der.js +84 -0
- package/src/browser/migration/firefox-nss.js +281 -0
- package/src/browser/migration/firefox.js +334 -0
- package/src/browser/migration/fs-utils.js +51 -0
- package/src/browser/migration/history.js +103 -0
- package/src/browser/migration/index.js +307 -0
- package/src/browser/migration/os-crypt-keys.js +106 -0
- package/src/browser/migration/passwords.js +171 -0
- package/src/browser/migration/preferences.js +132 -0
- package/src/browser/migration/sqlite-snapshot.js +138 -0
- package/src/browser/open-in-user-browser.js +112 -0
- package/src/browser/parity.js +439 -0
- package/src/browser/profile-directory.js +102 -0
- package/src/browser/real-browser.js +513 -306
- package/src/browser/restrictions.js +148 -0
- package/src/browser/system-browser.js +232 -0
- package/src/browser/webdriver.js +572 -0
- package/src/cli/args.js +253 -0
- package/src/cli/commands.js +332 -0
- package/src/cli/dispatcher.js +99 -0
- package/src/cli/handle-methods.js +136 -0
- package/src/cli/handles.js +273 -0
- package/src/cli/high-level-methods.js +310 -0
- package/src/cli/main.js +67 -0
- package/src/cli/modules.js +100 -0
- package/src/cli/rpc-error.js +77 -0
- package/src/cli/script.js +77 -0
- package/src/cli/serve.js +144 -0
- package/src/cli/sessions.js +195 -0
- package/src/core/constants.js +7 -1
- package/src/core/engine-adapter.js +107 -1
- package/src/core/engine-detection.js +18 -3
- package/src/core/navigation-safety.js +4 -0
- package/src/core/webdriver-conversions.js +347 -0
- package/src/core/webdriver-events.js +229 -0
- package/src/core/webdriver-page.js +716 -0
- package/src/downloads/attach.js +6 -0
- package/src/elements/selectors.js +3 -2
- package/src/elements/visibility.js +4 -0
- package/src/exports.js +61 -2
- package/src/factory.js +19 -4
- package/src/fingerprint/apply.js +6 -0
- package/src/fingerprint/automation-parity.js +7 -8
- package/src/fingerprint/limitations.js +1 -1
- package/src/fingerprint/limitations.json +45 -2
- package/src/parity/harness.js +303 -0
- package/src/parity/probe.js +838 -0
- package/src/parity/version-page.js +144 -0
- package/src/utilities/subprocess.js +199 -0
- package/tests/e2e/api-coverage.e2e.test.js +174 -0
- package/tests/e2e/cli.e2e.test.js +160 -0
- package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
- package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
- package/tests/e2e/measure-parity.e2e.test.js +118 -0
- package/tests/e2e/playwright.e2e.test.js +25 -17
- package/tests/e2e/puppeteer.e2e.test.js +17 -23
- package/tests/e2e/webdriver.e2e.test.js +239 -0
- package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
- package/tests/helpers/cli-fakes.js +214 -0
- package/tests/helpers/declared-api.js +80 -0
- package/tests/helpers/e2e-browser.js +40 -14
- package/tests/helpers/fake-chrome.js +124 -0
- package/tests/helpers/migration-fixtures.js +242 -0
- package/tests/helpers/temp-directory.js +24 -0
- package/tests/helpers/webdriver-mocks.js +183 -0
- package/tests/unit/browser/attach/extension-relay.test.js +360 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
- package/tests/unit/browser/attach/snapshot.test.js +390 -0
- package/tests/unit/browser/attach/websocket.test.js +240 -0
- package/tests/unit/browser/browser-cookies.test.js +2 -32
- package/tests/unit/browser/cdp-session.test.js +162 -0
- package/tests/unit/browser/connector.test.js +23 -0
- package/tests/unit/browser/launch-building-blocks.test.js +238 -0
- package/tests/unit/browser/launch-options.test.js +41 -22
- package/tests/unit/browser/launcher.test.js +281 -102
- package/tests/unit/browser/media.test.js +13 -1
- package/tests/unit/browser/migration/bookmarks.test.js +69 -0
- package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
- package/tests/unit/browser/migration/cookies.test.js +94 -0
- package/tests/unit/browser/migration/extensions.test.js +104 -0
- package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
- package/tests/unit/browser/migration/firefox.test.js +139 -0
- package/tests/unit/browser/migration/history.test.js +48 -0
- package/tests/unit/browser/migration/index.test.js +117 -0
- package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
- package/tests/unit/browser/migration/passwords.test.js +144 -0
- package/tests/unit/browser/migration/preferences.test.js +77 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
- package/tests/unit/browser/open-in-user-browser.test.js +123 -0
- package/tests/unit/browser/parity.test.js +281 -0
- package/tests/unit/browser/real-browser.test.js +458 -33
- package/tests/unit/browser/webdriver.test.js +473 -0
- package/tests/unit/cli/bin.test.js +69 -0
- package/tests/unit/cli/declared-api.test.js +49 -0
- package/tests/unit/cli/dispatcher.test.js +429 -0
- package/tests/unit/cli/handles.test.js +170 -0
- package/tests/unit/cli/main.test.js +409 -0
- package/tests/unit/cli/serve.test.js +167 -0
- package/tests/unit/core/webdriver-conversions.test.js +235 -0
- package/tests/unit/core/webdriver-page.test.js +534 -0
- package/tests/unit/fingerprint/automation-parity.test.js +17 -4
- package/tests/unit/fingerprint/limitations.test.js +1 -1
- package/tests/unit/parity/harness.test.js +138 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
- package/tests/unit/scripts/feature-parity.test.js +221 -0
- package/tests/unit/traces/reader.test.js +1 -0
- package/tests/unit/traces/recorder.test.js +1 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- package/tests/unit/utilities/subprocess.test.js +80 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { setImmediate } from 'node:timers';
|
|
5
|
+
import { TextDecoder } from 'node:util';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A minimal RFC 6455 WebSocket for the extension relay (issue #102,
|
|
9
|
+
* addendum, mode 2).
|
|
10
|
+
*
|
|
11
|
+
* The relay only exchanges small JSON text messages with one local extension,
|
|
12
|
+
* so this implements exactly that part of the protocol and no more:
|
|
13
|
+
*
|
|
14
|
+
* - the opening handshake on a `node:http` `'upgrade'` event (server) or an
|
|
15
|
+
* `http.request` upgrade (client, used by tests and tools);
|
|
16
|
+
* - text messages, fragmented or not, validated as UTF-8;
|
|
17
|
+
* - ping/pong and the close handshake.
|
|
18
|
+
*
|
|
19
|
+
* Binary messages, extensions (permessage-deflate) and subprotocols are not
|
|
20
|
+
* negotiated. A protocol violation closes the connection with the RFC status
|
|
21
|
+
* code: 1002 (protocol error, for example an unmasked client frame), 1003
|
|
22
|
+
* (binary data), 1007 (invalid UTF-8) or 1009 (message too big).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The GUID RFC 6455 section 1.3 appends to the client key. */
|
|
26
|
+
export const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
27
|
+
|
|
28
|
+
/** Frame opcodes (RFC 6455 section 5.2). */
|
|
29
|
+
export const OPCODES = Object.freeze({
|
|
30
|
+
continuation: 0x0,
|
|
31
|
+
text: 0x1,
|
|
32
|
+
binary: 0x2,
|
|
33
|
+
close: 0x8,
|
|
34
|
+
ping: 0x9,
|
|
35
|
+
pong: 0xa,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Close status codes used here (RFC 6455 section 7.4.1). */
|
|
39
|
+
export const CLOSE_CODES = Object.freeze({
|
|
40
|
+
normal: 1000,
|
|
41
|
+
goingAway: 1001,
|
|
42
|
+
protocolError: 1002,
|
|
43
|
+
unsupportedData: 1003,
|
|
44
|
+
noStatus: 1005,
|
|
45
|
+
abnormal: 1006,
|
|
46
|
+
invalidPayload: 1007,
|
|
47
|
+
policyViolation: 1008,
|
|
48
|
+
messageTooBig: 1009,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** Messages larger than this close the connection with 1009. */
|
|
52
|
+
export const DEFAULT_MAX_PAYLOAD = 64 * 1024 * 1024;
|
|
53
|
+
|
|
54
|
+
const MAX_CONTROL_PAYLOAD = 125;
|
|
55
|
+
|
|
56
|
+
/** A frame or handshake that breaks RFC 6455; `code` is the close code. */
|
|
57
|
+
export class WebSocketProtocolError extends Error {
|
|
58
|
+
constructor(message, code = CLOSE_CODES.protocolError) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = 'WebSocketProtocolError';
|
|
61
|
+
this.code = code;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The `Sec-WebSocket-Accept` value for a client's `Sec-WebSocket-Key`.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} key
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
export function computeAcceptKey(key) {
|
|
72
|
+
return createHash('sha1')
|
|
73
|
+
.update(key + WEBSOCKET_GUID)
|
|
74
|
+
.digest('base64');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Encode one frame.
|
|
79
|
+
*
|
|
80
|
+
* @param {number} opcode - One of {@link OPCODES}
|
|
81
|
+
* @param {Buffer|string} [payload]
|
|
82
|
+
* @param {Object} [options]
|
|
83
|
+
* @param {boolean} [options.mask=false] - Mask the payload (client frames)
|
|
84
|
+
* @param {boolean} [options.fin=true] - Final fragment
|
|
85
|
+
* @returns {Buffer}
|
|
86
|
+
*/
|
|
87
|
+
export function encodeFrame(
|
|
88
|
+
opcode,
|
|
89
|
+
payload = Buffer.alloc(0),
|
|
90
|
+
{ mask = false, fin = true } = {}
|
|
91
|
+
) {
|
|
92
|
+
const data = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
93
|
+
let header;
|
|
94
|
+
if (data.length < 126) {
|
|
95
|
+
header = Buffer.alloc(2);
|
|
96
|
+
header[1] = data.length;
|
|
97
|
+
} else if (data.length < 0x10000) {
|
|
98
|
+
header = Buffer.alloc(4);
|
|
99
|
+
header[1] = 126;
|
|
100
|
+
header.writeUInt16BE(data.length, 2);
|
|
101
|
+
} else {
|
|
102
|
+
header = Buffer.alloc(10);
|
|
103
|
+
header[1] = 127;
|
|
104
|
+
header.writeBigUInt64BE(BigInt(data.length), 2);
|
|
105
|
+
}
|
|
106
|
+
header[0] = (fin ? 0x80 : 0) | opcode;
|
|
107
|
+
if (!mask) {
|
|
108
|
+
return Buffer.concat([header, data]);
|
|
109
|
+
}
|
|
110
|
+
header[1] |= 0x80;
|
|
111
|
+
const maskingKey = randomBytes(4);
|
|
112
|
+
const masked = Buffer.alloc(data.length);
|
|
113
|
+
for (let index = 0; index < data.length; index++) {
|
|
114
|
+
masked[index] = data[index] ^ maskingKey[index % 4];
|
|
115
|
+
}
|
|
116
|
+
return Buffer.concat([header, maskingKey, masked]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Incremental frame parser: feed it socket chunks, get whole frames back.
|
|
121
|
+
*/
|
|
122
|
+
export class FrameDecoder {
|
|
123
|
+
/**
|
|
124
|
+
* @param {Object} [options]
|
|
125
|
+
* @param {boolean} [options.requireMask=false] - Client-to-server frames must be masked
|
|
126
|
+
* @param {boolean} [options.forbidMask=false] - Server-to-client frames must not be masked
|
|
127
|
+
* @param {number} [options.maxPayload=DEFAULT_MAX_PAYLOAD]
|
|
128
|
+
*/
|
|
129
|
+
constructor({
|
|
130
|
+
requireMask = false,
|
|
131
|
+
forbidMask = false,
|
|
132
|
+
maxPayload = DEFAULT_MAX_PAYLOAD,
|
|
133
|
+
} = {}) {
|
|
134
|
+
this.requireMask = requireMask;
|
|
135
|
+
this.forbidMask = forbidMask;
|
|
136
|
+
this.maxPayload = maxPayload;
|
|
137
|
+
this.buffer = Buffer.alloc(0);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {Buffer} chunk
|
|
142
|
+
* @returns {Array<{fin: boolean, opcode: number, payload: Buffer}>}
|
|
143
|
+
* @throws {WebSocketProtocolError}
|
|
144
|
+
*/
|
|
145
|
+
push(chunk) {
|
|
146
|
+
this.buffer =
|
|
147
|
+
this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
|
|
148
|
+
const frames = [];
|
|
149
|
+
for (;;) {
|
|
150
|
+
const frame = this.next();
|
|
151
|
+
if (!frame) {
|
|
152
|
+
return frames;
|
|
153
|
+
}
|
|
154
|
+
frames.push(frame);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
readLength(second) {
|
|
159
|
+
const length = second & 0x7f;
|
|
160
|
+
if (length < 126) {
|
|
161
|
+
return { length, offset: 2 };
|
|
162
|
+
}
|
|
163
|
+
if (length === 126) {
|
|
164
|
+
return this.buffer.length < 4
|
|
165
|
+
? null
|
|
166
|
+
: { length: this.buffer.readUInt16BE(2), offset: 4 };
|
|
167
|
+
}
|
|
168
|
+
if (this.buffer.length < 10) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const long = this.buffer.readBigUInt64BE(2);
|
|
172
|
+
if (long > BigInt(this.maxPayload)) {
|
|
173
|
+
throw new WebSocketProtocolError(
|
|
174
|
+
'Frame too big',
|
|
175
|
+
CLOSE_CODES.messageTooBig
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return { length: Number(long), offset: 10 };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
next() {
|
|
182
|
+
if (this.buffer.length < 2) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const [first, second] = this.buffer;
|
|
186
|
+
const fin = (first & 0x80) !== 0;
|
|
187
|
+
const opcode = first & 0x0f;
|
|
188
|
+
const masked = (second & 0x80) !== 0;
|
|
189
|
+
if ((first & 0x70) !== 0) {
|
|
190
|
+
throw new WebSocketProtocolError('Reserved bits must be 0');
|
|
191
|
+
}
|
|
192
|
+
if (!Object.values(OPCODES).includes(opcode)) {
|
|
193
|
+
throw new WebSocketProtocolError(`Unknown opcode ${opcode}`);
|
|
194
|
+
}
|
|
195
|
+
if (this.requireMask && !masked) {
|
|
196
|
+
throw new WebSocketProtocolError('Client frames must be masked');
|
|
197
|
+
}
|
|
198
|
+
if (this.forbidMask && masked) {
|
|
199
|
+
throw new WebSocketProtocolError('Server frames must not be masked');
|
|
200
|
+
}
|
|
201
|
+
const header = this.readLength(second);
|
|
202
|
+
if (!header) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const { length } = header;
|
|
206
|
+
if (opcode >= OPCODES.close && (length > MAX_CONTROL_PAYLOAD || !fin)) {
|
|
207
|
+
throw new WebSocketProtocolError('Invalid control frame');
|
|
208
|
+
}
|
|
209
|
+
if (length > this.maxPayload) {
|
|
210
|
+
throw new WebSocketProtocolError(
|
|
211
|
+
'Frame too big',
|
|
212
|
+
CLOSE_CODES.messageTooBig
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const maskOffset = header.offset;
|
|
216
|
+
const payloadOffset = maskOffset + (masked ? 4 : 0);
|
|
217
|
+
if (this.buffer.length < payloadOffset + length) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const payload = Buffer.from(
|
|
221
|
+
this.buffer.subarray(payloadOffset, payloadOffset + length)
|
|
222
|
+
);
|
|
223
|
+
if (masked) {
|
|
224
|
+
const maskingKey = this.buffer.subarray(maskOffset, payloadOffset);
|
|
225
|
+
for (let index = 0; index < payload.length; index++) {
|
|
226
|
+
payload[index] ^= maskingKey[index % 4];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.buffer = this.buffer.subarray(payloadOffset + length);
|
|
230
|
+
return { fin, opcode, payload };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
235
|
+
|
|
236
|
+
function decodeText(payload) {
|
|
237
|
+
try {
|
|
238
|
+
return UTF8.decode(payload);
|
|
239
|
+
} catch {
|
|
240
|
+
throw new WebSocketProtocolError(
|
|
241
|
+
'Invalid UTF-8',
|
|
242
|
+
CLOSE_CODES.invalidPayload
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isValidCloseCode(code) {
|
|
248
|
+
return (
|
|
249
|
+
(code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code)) ||
|
|
250
|
+
(code >= 3000 && code <= 4999)
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* One open WebSocket connection over a socket that finished the handshake.
|
|
256
|
+
*
|
|
257
|
+
* Events: `'message'` (string), `'close'` ({code, reason}) once, and
|
|
258
|
+
* `'error'` (Error) for socket errors.
|
|
259
|
+
*/
|
|
260
|
+
export class WebSocketConnection extends EventEmitter {
|
|
261
|
+
/**
|
|
262
|
+
* @param {net.Socket} socket
|
|
263
|
+
* @param {Object} [options]
|
|
264
|
+
* @param {'server'|'client'} [options.role='server']
|
|
265
|
+
* @param {Buffer} [options.head] - Bytes read past the handshake
|
|
266
|
+
* @param {number} [options.maxPayload=DEFAULT_MAX_PAYLOAD]
|
|
267
|
+
*/
|
|
268
|
+
constructor(
|
|
269
|
+
socket,
|
|
270
|
+
{ role = 'server', head, maxPayload = DEFAULT_MAX_PAYLOAD } = {}
|
|
271
|
+
) {
|
|
272
|
+
super();
|
|
273
|
+
this.socket = socket;
|
|
274
|
+
this.role = role;
|
|
275
|
+
this.maxPayload = maxPayload;
|
|
276
|
+
this.decoder = new FrameDecoder({
|
|
277
|
+
requireMask: role === 'server',
|
|
278
|
+
forbidMask: role === 'client',
|
|
279
|
+
maxPayload,
|
|
280
|
+
});
|
|
281
|
+
this.fragments = null;
|
|
282
|
+
this.closeSent = false;
|
|
283
|
+
this.closeReceived = null;
|
|
284
|
+
this.closed = false;
|
|
285
|
+
socket.setNoDelay?.(true);
|
|
286
|
+
socket.on('data', (chunk) => this.receive(chunk));
|
|
287
|
+
socket.on('error', (error) => {
|
|
288
|
+
if (this.listenerCount('error') > 0) {
|
|
289
|
+
this.emit('error', error);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
socket.on('close', () => this.finish());
|
|
293
|
+
if (head?.length) {
|
|
294
|
+
// After the caller had a chance to add its listeners.
|
|
295
|
+
setImmediate(() => this.receive(head));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** True until a close frame is sent or the socket ends. */
|
|
300
|
+
get open() {
|
|
301
|
+
return !this.closed && !this.closeSent;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Send a text message.
|
|
306
|
+
*
|
|
307
|
+
* @param {string} text
|
|
308
|
+
* @returns {boolean} False when the connection is already closing
|
|
309
|
+
*/
|
|
310
|
+
send(text) {
|
|
311
|
+
if (!this.open) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
this.write(OPCODES.text, Buffer.from(String(text), 'utf8'));
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Send a ping; the peer answers with a pong. */
|
|
319
|
+
ping(data = Buffer.alloc(0)) {
|
|
320
|
+
if (this.open) {
|
|
321
|
+
this.write(OPCODES.ping, data);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Start the close handshake. The socket ends when the peer answers, or
|
|
327
|
+
* after `timeoutMs` without an answer.
|
|
328
|
+
*
|
|
329
|
+
* @param {number} [code=1000]
|
|
330
|
+
* @param {string} [reason='']
|
|
331
|
+
* @param {number} [timeoutMs=1000]
|
|
332
|
+
*/
|
|
333
|
+
close(code = CLOSE_CODES.normal, reason = '', timeoutMs = 1_000) {
|
|
334
|
+
if (this.closed) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (!this.closeSent) {
|
|
338
|
+
this.sendClose(code, reason);
|
|
339
|
+
}
|
|
340
|
+
if (this.closeReceived) {
|
|
341
|
+
this.endSocket();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const timer = setTimeout(() => this.socket.destroy(), timeoutMs);
|
|
345
|
+
timer.unref?.();
|
|
346
|
+
this.socket.once('close', () => clearTimeout(timer));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Drop the connection without a close handshake. */
|
|
350
|
+
terminate() {
|
|
351
|
+
this.socket.destroy();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
write(opcode, payload) {
|
|
355
|
+
if (this.socket.writable) {
|
|
356
|
+
this.socket.write(
|
|
357
|
+
encodeFrame(opcode, payload, { mask: this.role === 'client' })
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
sendClose(code, reason) {
|
|
363
|
+
this.closeSent = true;
|
|
364
|
+
const reasonBytes = Buffer.from(reason, 'utf8').subarray(0, 123);
|
|
365
|
+
const payload = Buffer.alloc(2 + reasonBytes.length);
|
|
366
|
+
payload.writeUInt16BE(code, 0);
|
|
367
|
+
reasonBytes.copy(payload, 2);
|
|
368
|
+
this.write(OPCODES.close, payload);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
endSocket() {
|
|
372
|
+
// The server closes the TCP connection first (RFC 6455 section 7.1.1).
|
|
373
|
+
if (this.role === 'server') {
|
|
374
|
+
this.socket.end();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
fail(error) {
|
|
379
|
+
if (!this.closeSent) {
|
|
380
|
+
this.sendClose(error.code ?? CLOSE_CODES.protocolError, error.message);
|
|
381
|
+
}
|
|
382
|
+
this.socket.end();
|
|
383
|
+
const timer = setTimeout(() => this.socket.destroy(), 1_000);
|
|
384
|
+
timer.unref?.();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
receive(chunk) {
|
|
388
|
+
if (this.closed || this.closeReceived) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
for (const frame of this.decoder.push(chunk)) {
|
|
393
|
+
this.handleFrame(frame);
|
|
394
|
+
if (this.closeReceived) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (!(error instanceof WebSocketProtocolError)) {
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
this.fail(error);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
handleFrame({ fin, opcode, payload }) {
|
|
407
|
+
switch (opcode) {
|
|
408
|
+
case OPCODES.ping:
|
|
409
|
+
if (!this.closeSent) {
|
|
410
|
+
this.write(OPCODES.pong, payload);
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
case OPCODES.pong:
|
|
414
|
+
this.emit('pong', payload);
|
|
415
|
+
return;
|
|
416
|
+
case OPCODES.close:
|
|
417
|
+
this.handleClose(payload);
|
|
418
|
+
return;
|
|
419
|
+
case OPCODES.binary:
|
|
420
|
+
throw new WebSocketProtocolError(
|
|
421
|
+
'Binary messages are not supported',
|
|
422
|
+
CLOSE_CODES.unsupportedData
|
|
423
|
+
);
|
|
424
|
+
default:
|
|
425
|
+
this.handleData({ fin, opcode, payload });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
handleData({ fin, opcode, payload }) {
|
|
430
|
+
if (opcode === OPCODES.continuation && !this.fragments) {
|
|
431
|
+
throw new WebSocketProtocolError('Unexpected continuation frame');
|
|
432
|
+
}
|
|
433
|
+
if (opcode === OPCODES.text && this.fragments) {
|
|
434
|
+
throw new WebSocketProtocolError('Expected a continuation frame');
|
|
435
|
+
}
|
|
436
|
+
const parts = [...(this.fragments ?? []), payload];
|
|
437
|
+
const size = parts.reduce((total, part) => total + part.length, 0);
|
|
438
|
+
if (size > this.maxPayload) {
|
|
439
|
+
throw new WebSocketProtocolError(
|
|
440
|
+
'Message too big',
|
|
441
|
+
CLOSE_CODES.messageTooBig
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (!fin) {
|
|
445
|
+
this.fragments = parts;
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
this.fragments = null;
|
|
449
|
+
const text = decodeText(Buffer.concat(parts));
|
|
450
|
+
if (!this.closeSent) {
|
|
451
|
+
this.emit('message', text);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
handleClose(payload) {
|
|
456
|
+
if (payload.length === 1) {
|
|
457
|
+
throw new WebSocketProtocolError('Invalid close frame');
|
|
458
|
+
}
|
|
459
|
+
const code =
|
|
460
|
+
payload.length >= 2 ? payload.readUInt16BE(0) : CLOSE_CODES.noStatus;
|
|
461
|
+
if (payload.length >= 2 && !isValidCloseCode(code)) {
|
|
462
|
+
throw new WebSocketProtocolError(`Invalid close code ${code}`);
|
|
463
|
+
}
|
|
464
|
+
const reason = decodeText(payload.subarray(2));
|
|
465
|
+
this.closeReceived = { code, reason };
|
|
466
|
+
if (!this.closeSent) {
|
|
467
|
+
this.sendClose(
|
|
468
|
+
code === CLOSE_CODES.noStatus ? CLOSE_CODES.normal : code,
|
|
469
|
+
''
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
this.endSocket();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
finish() {
|
|
476
|
+
if (this.closed) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
this.closed = true;
|
|
480
|
+
this.emit(
|
|
481
|
+
'close',
|
|
482
|
+
this.closeReceived ?? { code: CLOSE_CODES.abnormal, reason: '' }
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Answer an HTTP upgrade request with an error status and close the socket.
|
|
489
|
+
*
|
|
490
|
+
* @param {net.Socket} socket
|
|
491
|
+
* @param {number} status - HTTP status code such as 403
|
|
492
|
+
* @param {string} [message] - Plain-text body
|
|
493
|
+
*/
|
|
494
|
+
export function rejectUpgrade(socket, status, message = '') {
|
|
495
|
+
const body = Buffer.from(message, 'utf8');
|
|
496
|
+
const statusText = http.STATUS_CODES[status] ?? 'Error';
|
|
497
|
+
socket.end(
|
|
498
|
+
Buffer.concat([
|
|
499
|
+
Buffer.from(
|
|
500
|
+
`HTTP/1.1 ${status} ${statusText}\r\n` +
|
|
501
|
+
'Connection: close\r\n' +
|
|
502
|
+
'Content-Type: text/plain; charset=utf-8\r\n' +
|
|
503
|
+
`Content-Length: ${body.length}\r\n\r\n`
|
|
504
|
+
),
|
|
505
|
+
body,
|
|
506
|
+
])
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function headerHasToken(value, token) {
|
|
511
|
+
return String(value ?? '')
|
|
512
|
+
.toLowerCase()
|
|
513
|
+
.split(',')
|
|
514
|
+
.some((part) => part.trim() === token);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Complete the server side of the opening handshake for a `node:http`
|
|
519
|
+
* `'upgrade'` event. An invalid handshake is answered with 400 (or 426 for an
|
|
520
|
+
* unsupported version) and null is returned.
|
|
521
|
+
*
|
|
522
|
+
* @param {http.IncomingMessage} request
|
|
523
|
+
* @param {net.Socket} socket
|
|
524
|
+
* @param {Buffer} [head]
|
|
525
|
+
* @param {Object} [options]
|
|
526
|
+
* @param {number} [options.maxPayload]
|
|
527
|
+
* @returns {WebSocketConnection|null}
|
|
528
|
+
*/
|
|
529
|
+
export function acceptUpgrade(request, socket, head, { maxPayload } = {}) {
|
|
530
|
+
const key = request.headers['sec-websocket-key'];
|
|
531
|
+
if (
|
|
532
|
+
request.method !== 'GET' ||
|
|
533
|
+
!headerHasToken(request.headers.upgrade, 'websocket') ||
|
|
534
|
+
!headerHasToken(request.headers.connection, 'upgrade') ||
|
|
535
|
+
typeof key !== 'string' ||
|
|
536
|
+
Buffer.from(key, 'base64').length !== 16
|
|
537
|
+
) {
|
|
538
|
+
rejectUpgrade(socket, 400, 'Invalid WebSocket handshake');
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
if (request.headers['sec-websocket-version'] !== '13') {
|
|
542
|
+
socket.end(
|
|
543
|
+
'HTTP/1.1 426 Upgrade Required\r\n' +
|
|
544
|
+
'Sec-WebSocket-Version: 13\r\n' +
|
|
545
|
+
'Connection: close\r\n' +
|
|
546
|
+
'Content-Length: 0\r\n\r\n'
|
|
547
|
+
);
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
socket.write(
|
|
551
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
552
|
+
'Upgrade: websocket\r\n' +
|
|
553
|
+
'Connection: Upgrade\r\n' +
|
|
554
|
+
`Sec-WebSocket-Accept: ${computeAcceptKey(key)}\r\n\r\n`
|
|
555
|
+
);
|
|
556
|
+
return new WebSocketConnection(socket, { role: 'server', head, maxPayload });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Open a client connection. Used by the tests and by tools that talk to the
|
|
561
|
+
* relay; the extension itself uses the browser's WebSocket.
|
|
562
|
+
*
|
|
563
|
+
* @param {string} url - ws:// URL
|
|
564
|
+
* @param {Object} [options]
|
|
565
|
+
* @param {Object<string,string>} [options.headers] - Extra handshake headers such as Origin
|
|
566
|
+
* @param {number} [options.timeoutMs=5000]
|
|
567
|
+
* @returns {Promise<WebSocketConnection>} Rejects with an Error whose `statusCode` is the HTTP status when the server refuses the upgrade
|
|
568
|
+
*/
|
|
569
|
+
export function connectWebSocket(
|
|
570
|
+
url,
|
|
571
|
+
{ headers = {}, timeoutMs = 5_000 } = {}
|
|
572
|
+
) {
|
|
573
|
+
const target = new URL(url);
|
|
574
|
+
if (target.protocol !== 'ws:') {
|
|
575
|
+
throw new TypeError(`Only ws:// URLs are supported, got ${url}`);
|
|
576
|
+
}
|
|
577
|
+
const key = randomBytes(16).toString('base64');
|
|
578
|
+
return new Promise((resolve, reject) => {
|
|
579
|
+
const request = http.request({
|
|
580
|
+
host: target.hostname.replace(/^\[|\]$/g, ''),
|
|
581
|
+
port: target.port || 80,
|
|
582
|
+
path: `${target.pathname}${target.search}`,
|
|
583
|
+
timeout: timeoutMs,
|
|
584
|
+
headers: {
|
|
585
|
+
...headers,
|
|
586
|
+
Connection: 'Upgrade',
|
|
587
|
+
Upgrade: 'websocket',
|
|
588
|
+
'Sec-WebSocket-Key': key,
|
|
589
|
+
'Sec-WebSocket-Version': '13',
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
request.on('timeout', () =>
|
|
593
|
+
request.destroy(new Error(`WebSocket handshake timed out: ${url}`))
|
|
594
|
+
);
|
|
595
|
+
request.on('error', reject);
|
|
596
|
+
request.on('response', (response) => {
|
|
597
|
+
const error = new Error(
|
|
598
|
+
`WebSocket upgrade refused with HTTP ${response.statusCode}`
|
|
599
|
+
);
|
|
600
|
+
error.statusCode = response.statusCode;
|
|
601
|
+
response.resume();
|
|
602
|
+
reject(error);
|
|
603
|
+
});
|
|
604
|
+
request.on('upgrade', (response, socket, head) => {
|
|
605
|
+
if (response.headers['sec-websocket-accept'] !== computeAcceptKey(key)) {
|
|
606
|
+
socket.destroy();
|
|
607
|
+
reject(new Error('Invalid Sec-WebSocket-Accept'));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
socket.setTimeout(0);
|
|
611
|
+
resolve(new WebSocketConnection(socket, { role: 'client', head }));
|
|
612
|
+
});
|
|
613
|
+
request.end();
|
|
614
|
+
});
|
|
615
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { execFile as execFileCallback } from 'node:child_process';
|
|
3
2
|
import {
|
|
4
3
|
chmod,
|
|
5
4
|
mkdir,
|
|
@@ -10,13 +9,13 @@ import {
|
|
|
10
9
|
stat,
|
|
11
10
|
} from 'node:fs/promises';
|
|
12
11
|
import path from 'node:path';
|
|
13
|
-
|
|
12
|
+
|
|
13
|
+
import { runCommand } from '../utilities/subprocess.js';
|
|
14
14
|
|
|
15
15
|
const DEFAULT_TTL_MINUTES = 60;
|
|
16
16
|
const LOCK_STALE_MILLISECONDS = 30_000;
|
|
17
17
|
const LOCK_WAIT_MILLISECONDS = 30_000;
|
|
18
18
|
const credentialPromises = new Map();
|
|
19
|
-
const execFile = promisify(execFileCallback);
|
|
20
19
|
let windowsPrincipalPromise;
|
|
21
20
|
|
|
22
21
|
function hash(value) {
|
|
@@ -47,9 +46,9 @@ export function normalizeCookieCache(cache, homeDir, ttlMinutes) {
|
|
|
47
46
|
}
|
|
48
47
|
|
|
49
48
|
function windowsPrincipal() {
|
|
50
|
-
windowsPrincipalPromise ??=
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
windowsPrincipalPromise ??= runCommand('whoami').then(({ stdout }) =>
|
|
50
|
+
stdout.trim()
|
|
51
|
+
);
|
|
53
52
|
return windowsPrincipalPromise;
|
|
54
53
|
}
|
|
55
54
|
|
|
@@ -60,17 +59,13 @@ async function restrictOwnerOnly(targetPath, directory) {
|
|
|
60
59
|
throw new Error('Could not identify the current Windows user');
|
|
61
60
|
}
|
|
62
61
|
const permission = directory ? '(OI)(CI)F' : 'F';
|
|
63
|
-
await
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
'/q',
|
|
71
|
-
],
|
|
72
|
-
{ windowsHide: true }
|
|
73
|
-
);
|
|
62
|
+
await runCommand('icacls', [
|
|
63
|
+
targetPath,
|
|
64
|
+
'/inheritance:r',
|
|
65
|
+
'/grant:r',
|
|
66
|
+
`${principal}:${permission}`,
|
|
67
|
+
'/q',
|
|
68
|
+
]);
|
|
74
69
|
return;
|
|
75
70
|
}
|
|
76
71
|
await chmod(targetPath, directory ? 0o700 : 0o600);
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { execFile as execFileCallback } from 'node:child_process';
|
|
2
1
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { promisify } from 'node:util';
|
|
4
2
|
|
|
5
|
-
|
|
3
|
+
import { runCommand as runSubprocess } from '../utilities/subprocess.js';
|
|
6
4
|
|
|
7
5
|
const SAFE_STORAGE = {
|
|
8
6
|
brave: {
|
|
@@ -27,13 +25,12 @@ const SAFE_STORAGE = {
|
|
|
27
25
|
},
|
|
28
26
|
};
|
|
29
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Run a credential tool (`security`, `secret-tool`, `kwallet-query`,
|
|
30
|
+
* `powershell.exe`) through command-stream and return its trimmed output.
|
|
31
|
+
*/
|
|
30
32
|
async function runCredentialCommand(command, args, environment) {
|
|
31
|
-
const { stdout } = await
|
|
32
|
-
encoding: 'utf8',
|
|
33
|
-
env: environment,
|
|
34
|
-
maxBuffer: 1024 * 1024,
|
|
35
|
-
windowsHide: true,
|
|
36
|
-
});
|
|
33
|
+
const { stdout } = await runSubprocess(command, args, { env: environment });
|
|
37
34
|
return stdout.trim();
|
|
38
35
|
}
|
|
39
36
|
|