rechrome 1.47.0 → 1.48.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/README.md +4 -0
- package/e2e.js +2 -2
- package/e2e.ts +2 -2
- package/extension-token.js +37 -1
- package/extension-token.ts +37 -1
- package/fill-secret.js +542 -40
- package/fill-secret.ts +293 -33
- package/listeners.js +9 -5
- package/listeners.ts +10 -4
- package/package.json +9 -2
- package/rechrome.js +96 -21
- package/rechrome.ts +96 -21
- package/record.js +281 -0
- package/record.ts +251 -0
- package/rtc-channel.js +303 -0
- package/rtc-channel.ts +130 -0
- package/secret.js +15 -10
- package/secret.ts +2 -70
- package/serve.js +137 -13
- package/serve.ts +137 -13
- package/shares.js +49 -12
- package/shares.ts +39 -6
- package/token.js +88 -0
- package/token.ts +74 -0
- package/webrtc.js +92 -85
- package/webrtc.ts +6 -128
package/fill-secret.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { connect } from "net";
|
|
|
16
16
|
import { readdirSync, readFileSync } from "fs";
|
|
17
17
|
import { homedir } from "os";
|
|
18
18
|
import { join } from "path";
|
|
19
|
+
import { redact } from "./redact.ts";
|
|
19
20
|
|
|
20
21
|
export const SECRET_MASK = "***";
|
|
21
22
|
export const SECRET_MASK_TTL_MS = 15 * 60_000;
|
|
@@ -321,14 +322,14 @@ export function socketRequest(path: string, method: string, params: unknown, tim
|
|
|
321
322
|
}
|
|
322
323
|
|
|
323
324
|
/** Run a run-code function on the session's live browser over its cliDaemon socket (no child argv). */
|
|
324
|
-
export async function runCodeOnSession(opts: { socketRoot: string; session: string; cwd: string; code: string; json?: boolean }): Promise<{ isError: boolean; text: string }> {
|
|
325
|
+
export async function runCodeOnSession(opts: { socketRoot: string; session: string; cwd: string; code: string; json?: boolean; timeoutMs?: number }): Promise<{ isError: boolean; text: string }> {
|
|
325
326
|
const candidates = [...new Set([...sessionSocketCandidates(opts.socketRoot, opts.session), ...daemonSessionSockets(opts.session)])];
|
|
326
327
|
if (!candidates.length) throw new Error("no browser is open in this session; run `rech open <url>` first");
|
|
327
328
|
const errors: string[] = [];
|
|
328
329
|
for (const path of candidates) {
|
|
329
330
|
let reply: { result?: any; error?: string };
|
|
330
331
|
try {
|
|
331
|
-
reply = await socketRequest(path, "run", { args: { _: ["run-code", opts.code] }, cwd: opts.cwd, ...(opts.json ? { json: true } : {}) });
|
|
332
|
+
reply = await socketRequest(path, "run", { args: { _: ["run-code", opts.code] }, cwd: opts.cwd, ...(opts.json ? { json: true } : {}) }, opts.timeoutMs);
|
|
332
333
|
} catch (e) {
|
|
333
334
|
errors.push(e instanceof Error ? e.message : String(e)); // stale socket: try the next one
|
|
334
335
|
continue;
|
|
@@ -340,6 +341,20 @@ export async function runCodeOnSession(opts: { socketRoot: string; session: stri
|
|
|
340
341
|
throw new Error(`could not reach this session's browser (${errors.join("; ")}); run \`rech open <url>\` first`);
|
|
341
342
|
}
|
|
342
343
|
|
|
344
|
+
/** Run a plain CLI command (e.g. tab-list, tab-select) on the session's browser over its socket. */
|
|
345
|
+
export async function runCliOnSession(opts: { socketRoot: string; session: string; cwd: string }, cliArgs: string[]): Promise<{ isError: boolean; text: string }> {
|
|
346
|
+
const candidates = [...new Set([...sessionSocketCandidates(opts.socketRoot, opts.session), ...daemonSessionSockets(opts.session)])];
|
|
347
|
+
for (const path of candidates) {
|
|
348
|
+
let reply: { result?: any; error?: string };
|
|
349
|
+
try { reply = await socketRequest(path, "run", { args: { _: cliArgs }, cwd: opts.cwd }); }
|
|
350
|
+
catch { continue; }
|
|
351
|
+
if (reply.error) throw new Error(reply.error);
|
|
352
|
+
const r = reply.result;
|
|
353
|
+
return { isError: !!r?.isError, text: typeof r === "string" ? r : typeof r?.text === "string" ? r.text : "" };
|
|
354
|
+
}
|
|
355
|
+
throw new Error("no browser is open in this session");
|
|
356
|
+
}
|
|
357
|
+
|
|
343
358
|
/**
|
|
344
359
|
* Fill on the session's live browser. Returns the cliDaemon's reply (NOT yet masked — the
|
|
345
360
|
* caller masks it) or throws an error whose message never contains the value.
|
|
@@ -450,30 +465,44 @@ export async function readSecretOnSession(opts: { socketRoot: string; session: s
|
|
|
450
465
|
// via page-level evaluate, so it works over the extension bridge. localStorage is per-origin, so
|
|
451
466
|
// the session's page must be on the allowed host.
|
|
452
467
|
|
|
453
|
-
export const READ_STORAGE_USAGE = `Usage: rech read-storage <local|session> <key> [json-path] --allow-domain <glob> | <command>
|
|
468
|
+
export const READ_STORAGE_USAGE = `Usage: rech read-storage <local|session> <key> [json-path] --allow-domain <glob> [--goto <url>] | <command>
|
|
454
469
|
Writes the storage value, raw and without a newline, to stdout, which must be a pipe into the
|
|
455
470
|
program that stores it (never a terminal or a file). Never logged; masked in later outputs.
|
|
456
471
|
<local|session> localStorage or sessionStorage
|
|
457
472
|
<key> the storage key, e.g. localConfig_v2
|
|
458
473
|
[json-path] optional: a path into the JSON value, e.g. teams.T0123.token or teams[T0123].token
|
|
459
|
-
--allow-domain GLOB required: the page's host must match, e.g. app.slack.com
|
|
474
|
+
--allow-domain GLOB required: the page's host must match, e.g. app.slack.com
|
|
475
|
+
--goto URL first navigate the attached tab to URL (host must match --allow-domain),
|
|
476
|
+
so the value is read from it, e.g. --goto https://app.slack.com/client/T0`;
|
|
477
|
+
|
|
478
|
+
/** --goto must be http(s) on an --allow-domain host (checked by the client and again by serve). */
|
|
479
|
+
export function checkGotoUrl(gotoUrl: string, allowDomains: string[]): void {
|
|
480
|
+
let u: URL;
|
|
481
|
+
try { u = new URL(gotoUrl); } catch { throw new Error("--goto must be an http(s) URL"); }
|
|
482
|
+
if (!/^https?:$/.test(u.protocol)) throw new Error("--goto must be an http(s) URL");
|
|
483
|
+
if (!allowDomains.some(d => domainGlobToRegExp(d).test(u.hostname.toLowerCase()))) throw new Error("--goto URL host must match --allow-domain");
|
|
484
|
+
}
|
|
460
485
|
|
|
461
486
|
/** Split "teams[T0123].token" / "teams.T0123.token" into ["teams","T0123","token"]. */
|
|
462
487
|
export function parseStoragePath(path: string): string[] {
|
|
463
488
|
return path.match(/[^.[\]]+/g) ?? [];
|
|
464
489
|
}
|
|
465
490
|
|
|
466
|
-
export function parseReadStorageArgs(args: string[]): { area: "local" | "session"; key: string; path: string[]; allowDomains: string[] } {
|
|
491
|
+
export function parseReadStorageArgs(args: string[]): { area: "local" | "session"; key: string; path: string[]; allowDomains: string[]; gotoUrl?: string } {
|
|
467
492
|
const [cmd, ...rest] = args;
|
|
468
493
|
if (cmd !== "read-storage") throw new Error("Expected read-storage");
|
|
469
494
|
const pos: string[] = [];
|
|
470
495
|
const allowDomains: string[] = [];
|
|
496
|
+
let gotoUrl: string | undefined;
|
|
471
497
|
for (let i = 0; i < rest.length; i++) {
|
|
472
498
|
const a = rest[i]!;
|
|
473
499
|
if (a === "--allow-domain" || a.startsWith("--allow-domain=")) {
|
|
474
500
|
const v = a === "--allow-domain" ? rest[++i] : a.slice("--allow-domain=".length);
|
|
475
501
|
if (!v) throw new Error("--allow-domain got an empty host");
|
|
476
502
|
for (const d of v.split(",")) { domainGlobToRegExp(d.trim()); allowDomains.push(d.trim()); }
|
|
503
|
+
} else if (a === "--goto" || a.startsWith("--goto=")) {
|
|
504
|
+
gotoUrl = a === "--goto" ? rest[++i] : a.slice("--goto=".length);
|
|
505
|
+
if (!gotoUrl) throw new Error("--goto got an empty URL");
|
|
477
506
|
} else if (a.startsWith("-")) throw new Error(`read-storage: unknown option ${a}`);
|
|
478
507
|
else pos.push(a);
|
|
479
508
|
}
|
|
@@ -482,25 +511,29 @@ export function parseReadStorageArgs(args: string[]): { area: "local" | "session
|
|
|
482
511
|
if (area !== "local" && area !== "session") throw new Error("read-storage: first argument must be 'local' or 'session'");
|
|
483
512
|
if (!key) throw new Error("read-storage: a storage key is required");
|
|
484
513
|
if (!allowDomains.length) throw new Error("read-storage needs --allow-domain: name the site the value must come from");
|
|
485
|
-
|
|
514
|
+
if (gotoUrl) checkGotoUrl(gotoUrl, allowDomains);
|
|
515
|
+
return { area, key, path: rawPath ? parseStoragePath(rawPath) : [], allowDomains, gotoUrl };
|
|
486
516
|
}
|
|
487
517
|
|
|
488
|
-
export function readStorageWireArgs(req: { area: string; key: string; path: string[]; allowDomains: string[] }): string[] {
|
|
489
|
-
return ["read-storage", req.area, req.key, ...(req.path.length ? [req.path.join(".")] : []), ...req.allowDomains.map(d => `--allow-domain=${d}`)];
|
|
518
|
+
export function readStorageWireArgs(req: { area: string; key: string; path: string[]; allowDomains: string[]; gotoUrl?: string }): string[] {
|
|
519
|
+
return ["read-storage", req.area, req.key, ...(req.path.length ? [req.path.join(".")] : []), ...req.allowDomains.map(d => `--allow-domain=${d}`), ...(req.gotoUrl ? [`--goto=${req.gotoUrl}`] : [])];
|
|
490
520
|
}
|
|
491
521
|
|
|
492
|
-
export function parseReadStorageWire(args: string[]): { area: "local" | "session"; key: string; path: string[]; allowDomains: string[] } {
|
|
522
|
+
export function parseReadStorageWire(args: string[]): { area: "local" | "session"; key: string; path: string[]; allowDomains: string[]; gotoUrl?: string } {
|
|
493
523
|
const [cmd, area, key, ...rest] = args;
|
|
494
524
|
if (cmd !== "read-storage" || (area !== "local" && area !== "session") || !key || key.startsWith("-")) throw new Error("read-storage needs <local|session> <key>");
|
|
495
525
|
const allowDomains: string[] = [];
|
|
496
526
|
let path: string[] = [];
|
|
527
|
+
let gotoUrl: string | undefined;
|
|
497
528
|
for (const a of rest) {
|
|
498
529
|
if (a.startsWith("--allow-domain=")) { const d = a.slice("--allow-domain=".length); domainGlobToRegExp(d); allowDomains.push(d); }
|
|
530
|
+
else if (a.startsWith("--goto=")) { gotoUrl = a.slice("--goto=".length); }
|
|
499
531
|
else if (!a.startsWith("-") && !path.length) path = parseStoragePath(a);
|
|
500
532
|
else throw new Error(`read-storage: unexpected argument ${a.startsWith("-") ? a : "(positional)"}`);
|
|
501
533
|
}
|
|
502
534
|
if (!allowDomains.length) throw new Error("read-storage needs --allow-domain");
|
|
503
|
-
|
|
535
|
+
if (gotoUrl !== undefined) checkGotoUrl(gotoUrl, allowDomains);
|
|
536
|
+
return { area, key, path, allowDomains, gotoUrl };
|
|
504
537
|
}
|
|
505
538
|
|
|
506
539
|
/** run-code reading storage[key], optionally walking a JSON path; returns {host,value} JSON. */
|
|
@@ -508,25 +541,34 @@ export function buildReadStorageCode(area: "local" | "session", key: string, pat
|
|
|
508
541
|
const patterns = allowDomains.map(d => domainGlobToRegExp(d).source);
|
|
509
542
|
const store = area === "local" ? "localStorage" : "sessionStorage";
|
|
510
543
|
return `async page => {
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
|
|
544
|
+
const allowed = ${JSON.stringify(patterns)};
|
|
545
|
+
const hostOf = (u) => { const m = /^[a-z][a-z0-9+.-]*:\\/\\/([^/?#]+)/i.exec(String(u || "")); return m ? m[1].replace(/^[^@]*@/, "").replace(/:\\d+$/, "").toLowerCase() : ""; };
|
|
546
|
+
const ok = (h) => h && allowed.some(p => new RegExp(p, "i").test(h));
|
|
547
|
+
// The caller has foregrounded the --allow-domain tab; read from whichever of this tab's frames
|
|
548
|
+
// is on the allowed host (the top frame can be a blank wrapper over the bridge).
|
|
549
|
+
const frame = page.frames().find(f => ok(hostOf(f.url())));
|
|
550
|
+
const host = frame ? hostOf(frame.url()) : "";
|
|
551
|
+
if (!frame)
|
|
552
|
+
throw new Error("read-storage refused: the foreground tab is not on an --allow-domain host (host " + JSON.stringify(hostOf(page.url())) + ")");
|
|
553
|
+
// Re-check the host inside the same evaluate that reads, so a navigation between the frame
|
|
554
|
+
// pick and the read can't hand over another origin's storage.
|
|
555
|
+
const out = await frame.evaluate(({ key, path, allowed }) => {
|
|
556
|
+
const m = /^[a-z][a-z0-9+.-]*:\\/\\/([^/?#]+)/i.exec(String(document.URL || ""));
|
|
557
|
+
const h = m ? m[1].replace(/^[^@]*@/, "").replace(/:\\d+$/, "").toLowerCase() : "";
|
|
558
|
+
if (!h || !allowed.some(p => new RegExp(p, "i").test(h))) return { moved: true };
|
|
514
559
|
const raw = window.${store}.getItem(key);
|
|
515
|
-
if (raw === null) return {
|
|
516
|
-
if (!path.length) return {
|
|
560
|
+
if (raw === null) return { missing: true };
|
|
561
|
+
if (!path.length) return { value: raw };
|
|
517
562
|
let v;
|
|
518
|
-
try { v = JSON.parse(raw); } catch { return {
|
|
563
|
+
try { v = JSON.parse(raw); } catch { return { notjson: true }; }
|
|
519
564
|
for (const seg of path) {
|
|
520
|
-
if (v == null || typeof v !== "object") return {
|
|
565
|
+
if (v == null || typeof v !== "object") return { nopath: true };
|
|
521
566
|
v = v[seg];
|
|
522
567
|
}
|
|
523
|
-
if (v === undefined) return {
|
|
524
|
-
return {
|
|
525
|
-
}, { key: ${JSON.stringify(key)}, path: ${JSON.stringify(path)} });
|
|
526
|
-
|
|
527
|
-
const allowed = ${JSON.stringify(patterns)};
|
|
528
|
-
if (!allowed.some(p => new RegExp(p, "i").test(host)))
|
|
529
|
-
throw new Error("read-storage refused: the page host " + JSON.stringify(host) + " is not allowed by --allow-domain");
|
|
568
|
+
if (v === undefined) return { nopath: true };
|
|
569
|
+
return { value: typeof v === "string" ? v : JSON.stringify(v) };
|
|
570
|
+
}, { key: ${JSON.stringify(key)}, path: ${JSON.stringify(path)}, allowed });
|
|
571
|
+
if (out.moved) throw new Error("read-storage refused: the foreground tab is not on an --allow-domain host (it navigated away)");
|
|
530
572
|
if (out.missing) throw new Error("read-storage: no ${store} key " + JSON.stringify(${JSON.stringify(key)}) + " on " + host);
|
|
531
573
|
if (out.notjson) throw new Error("read-storage: the value is not JSON, so the path can't apply");
|
|
532
574
|
if (out.nopath) throw new Error("read-storage: the json-path is not present in the value");
|
|
@@ -534,15 +576,33 @@ export function buildReadStorageCode(area: "local" | "session", key: string, pat
|
|
|
534
576
|
}`;
|
|
535
577
|
}
|
|
536
578
|
|
|
537
|
-
export async function readStorageOnSession(opts: { socketRoot: string; session: string; cwd: string; area: "local" | "session"; key: string; path: string[]; allowDomains: string[] }): Promise<{ host: string; value: string }> {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
579
|
+
export async function readStorageOnSession(opts: { socketRoot: string; session: string; cwd: string; area: "local" | "session"; key: string; path: string[]; allowDomains: string[]; gotoUrl?: string }): Promise<{ host: string; value: string }> {
|
|
580
|
+
// Over the extension bridge Playwright reaches only the attached tab, and localStorage is
|
|
581
|
+
// per-origin. With --goto, navigate that tab to the allowed URL in THIS same invocation (so the
|
|
582
|
+
// attachment can't change between navigate and read), then read it.
|
|
583
|
+
if (opts.gotoUrl) {
|
|
584
|
+
await runCliOnSession(opts, ["goto", opts.gotoUrl]).catch(() => {});
|
|
585
|
+
await new Promise(r => setTimeout(r, 2500));
|
|
586
|
+
}
|
|
587
|
+
let last = "";
|
|
588
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
589
|
+
const { isError, text } = await runCodeOnSession({ socketRoot: opts.socketRoot, session: opts.session, cwd: opts.cwd, code: buildReadStorageCode(opts.area, opts.key, opts.path, opts.allowDomains), json: true });
|
|
590
|
+
if (!isError) {
|
|
591
|
+
let raw: unknown;
|
|
592
|
+
try { raw = JSON.parse(text).result; } catch { raw = text.match(/### Result\n([^\n]*)/)?.[1]; }
|
|
593
|
+
let o: any = raw;
|
|
594
|
+
for (let i = 0; i < 2 && typeof o === "string"; i++) { try { o = JSON.parse(o); } catch { o = undefined; } }
|
|
595
|
+
if (o && typeof o.host === "string" && typeof o.value === "string") return o;
|
|
596
|
+
throw new Error("read-storage: the page returned no value");
|
|
597
|
+
}
|
|
598
|
+
last = text.match(/read-storage(?: refused)?: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split("\n")[0]! : "read-storage: could not read the value");
|
|
599
|
+
if (!/foreground tab is not on/.test(last)) break;
|
|
600
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
601
|
+
}
|
|
602
|
+
// A clearer hint for the common case: the allowed site isn't the attached tab.
|
|
603
|
+
if (/foreground tab is not on/.test(last))
|
|
604
|
+
last += ". Put the site on the active tab first: rech goto <url on that host>, then rerun read-storage.";
|
|
605
|
+
throw new Error(last || "read-storage: could not read the value");
|
|
546
606
|
}
|
|
547
607
|
|
|
548
608
|
// ---------- read-cookie: a cookie value (incl. HttpOnly) to a pipe ----------
|
|
@@ -637,6 +697,206 @@ export async function readCookieOnSession(opts: { socketRoot: string; session: s
|
|
|
637
697
|
return out;
|
|
638
698
|
}
|
|
639
699
|
|
|
700
|
+
// ---------- read-headers: response status + headers (no bodies) to an evidence file ----------
|
|
701
|
+
//
|
|
702
|
+
// Captures the status code and response headers of every request on an --allow-domain host over a
|
|
703
|
+
// capture window, with an optional --goto that navigates the attached tab first. Headers are not
|
|
704
|
+
// secrets in general, but some carry credentials, so their VALUES are redacted (the name is kept), as
|
|
705
|
+
// are URL query values, fragments and userinfo; the file then goes through the secret scanner.
|
|
706
|
+
// Unlike read-secret/read-storage/read-cookie this writes a JSON evidence FILE (an array of
|
|
707
|
+
// {url, status, headers}) to the run's output dir and returns its path; nothing is piped. On a
|
|
708
|
+
// profile-scoped listener it is refused unless the host turned it on (listener.readHeaders).
|
|
709
|
+
|
|
710
|
+
/** Header names whose VALUES carry credentials: keep the name, replace the value. */
|
|
711
|
+
export const REDACTED_HEADERS = [
|
|
712
|
+
"set-cookie", "cookie", "authorization", "proxy-authorization", "www-authenticate", "proxy-authenticate",
|
|
713
|
+
"x-api-key", "x-csrf-token", "x-xsrf-token",
|
|
714
|
+
];
|
|
715
|
+
/** …and any header named like one: `*-token`, `*-secret`, `*-api-key` (also with `_`). */
|
|
716
|
+
export const REDACTED_HEADER_PATTERN = "[-_](?:token|secret|api[-_]?key)$";
|
|
717
|
+
/** Headers whose value is a URL: its query values, fragment and userinfo are redacted. */
|
|
718
|
+
export const URL_HEADERS = ["location", "content-location"];
|
|
719
|
+
export const HEADER_REDACTION = "<redacted>";
|
|
720
|
+
export const READ_HEADERS_DEFAULT_SECONDS = 5;
|
|
721
|
+
// The whole capture (a --goto included) runs inside one /run, and the client gives up at 70 s.
|
|
722
|
+
export const READ_HEADERS_MAX_SECONDS = 50;
|
|
723
|
+
|
|
724
|
+
export const READ_HEADERS_USAGE = `Usage: rech read-headers --allow-domain <host> [--allow-domain <host>]... [--seconds N] [--goto <url>]
|
|
725
|
+
Captures the status code and response headers (NO bodies) of every request on an --allow-domain
|
|
726
|
+
host over a capture window, and writes them as a JSON evidence file in the output dir (an array of
|
|
727
|
+
{url, status, headers}). Credential header values (cookie, set-cookie, authorization,
|
|
728
|
+
*-authenticate, x-api-key, *-token, *-secret, ...) are redacted, as are URL query values,
|
|
729
|
+
fragments and userinfo; then the file goes through the secret scanner.
|
|
730
|
+
--allow-domain GLOB required: capture only responses whose host matches, e.g. app.example.com
|
|
731
|
+
--seconds N capture window in seconds (default ${READ_HEADERS_DEFAULT_SECONDS}, max ${READ_HEADERS_MAX_SECONDS})
|
|
732
|
+
--goto URL first navigate the attached tab to URL (host must match --allow-domain),
|
|
733
|
+
then capture, so a page load's requests are captured (within the window)`;
|
|
734
|
+
|
|
735
|
+
/** The host of a URL string, lowercased, without userinfo or port ("" when it has none). */
|
|
736
|
+
export function hostFromUrl(u: string): string {
|
|
737
|
+
const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i.exec(String(u || ""));
|
|
738
|
+
return m ? m[1]!.replace(/^[^@]*@/, "").replace(/:\d+$/, "").toLowerCase() : "";
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export type CapturedResponse = { url: string; status: number; headers: Record<string, string> };
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* One captured response with its credentials taken out: credential header values, and in the URL
|
|
745
|
+
* and URL-valued headers the query values, fragment and userinfo. Self-contained (only its
|
|
746
|
+
* arguments and builtins), because the browser runs it too via toString(), so raw values never
|
|
747
|
+
* leave the browser; serve runs it again on what comes back.
|
|
748
|
+
*/
|
|
749
|
+
export function redactResponse(e: { url?: unknown; status?: unknown; headers?: unknown }, names: readonly string[], pattern: string, urlHeaders: readonly string[], mask: string): CapturedResponse {
|
|
750
|
+
const maskUrl = (u: string): string => {
|
|
751
|
+
u = u.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#]*@/i, `$1${mask}@`);
|
|
752
|
+
const hash = u.indexOf("#");
|
|
753
|
+
const frag = hash >= 0 ? `#${mask}` : "";
|
|
754
|
+
if (hash >= 0) u = u.slice(0, hash);
|
|
755
|
+
const q = u.indexOf("?");
|
|
756
|
+
if (q < 0) return u + frag;
|
|
757
|
+
const query = u.slice(q + 1).split("&").map(p => (p.includes("=") ? `${p.slice(0, p.indexOf("="))}=${mask}` : p ? mask : p)).join("&");
|
|
758
|
+
return `${u.slice(0, q)}?${query}${frag}`;
|
|
759
|
+
};
|
|
760
|
+
const credential = new RegExp(pattern, "i");
|
|
761
|
+
const raw = e.headers && typeof e.headers === "object" ? (e.headers as Record<string, unknown>) : {};
|
|
762
|
+
const headers: Record<string, string> = {};
|
|
763
|
+
for (const k of Object.keys(raw)) {
|
|
764
|
+
const n = k.toLowerCase();
|
|
765
|
+
headers[k] = names.includes(n) || credential.test(n) ? mask : urlHeaders.includes(n) ? maskUrl(String(raw[k])) : String(raw[k]);
|
|
766
|
+
}
|
|
767
|
+
const status = Number(e.status);
|
|
768
|
+
return { url: maskUrl(String(e.url ?? "")), status: Number.isFinite(status) ? status : 0, headers };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* The evidence file's text: the (already redacted) responses, then anything `mask` knows (filled
|
|
773
|
+
* passwords, TOTPs) and anything the secret scanner recognizes, e.g. a token in an unusual header.
|
|
774
|
+
*/
|
|
775
|
+
export function readHeadersEvidence(captured: CapturedResponse[], mask: (text: string) => string): string {
|
|
776
|
+
return redact(mask(JSON.stringify(captured.map(redactCaptured), null, 2))).text + "\n";
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** redactResponse with this module's lists. */
|
|
780
|
+
export function redactCaptured(e: { url?: unknown; status?: unknown; headers?: unknown }): CapturedResponse {
|
|
781
|
+
return redactResponse(e, REDACTED_HEADERS, REDACTED_HEADER_PATTERN, URL_HEADERS, HEADER_REDACTION);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Keep every header name; replace the value of a credential-bearing header with <redacted>. */
|
|
785
|
+
export function redactHeaders(headers: Record<string, unknown>): Record<string, string> {
|
|
786
|
+
return redactCaptured({ headers }).headers;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function validateSeconds(seconds: number): number {
|
|
790
|
+
if (!Number.isInteger(seconds) || seconds < 1 || seconds > READ_HEADERS_MAX_SECONDS)
|
|
791
|
+
throw new Error(`read-headers: --seconds must be an integer between 1 and ${READ_HEADERS_MAX_SECONDS}`);
|
|
792
|
+
return seconds;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
export function parseReadHeadersArgs(args: string[]): { allowDomains: string[]; seconds: number; gotoUrl?: string } {
|
|
797
|
+
const [cmd, ...rest] = args;
|
|
798
|
+
if (cmd !== "read-headers") throw new Error("Expected read-headers");
|
|
799
|
+
const allowDomains: string[] = [];
|
|
800
|
+
let seconds = READ_HEADERS_DEFAULT_SECONDS;
|
|
801
|
+
let gotoUrl: string | undefined;
|
|
802
|
+
for (let i = 0; i < rest.length; i++) {
|
|
803
|
+
const a = rest[i]!;
|
|
804
|
+
if (a === "--allow-domain" || a.startsWith("--allow-domain=")) {
|
|
805
|
+
const v = a === "--allow-domain" ? rest[++i] : a.slice("--allow-domain=".length);
|
|
806
|
+
if (!v) throw new Error("--allow-domain got an empty host");
|
|
807
|
+
for (const d of v.split(",")) { domainGlobToRegExp(d.trim()); allowDomains.push(d.trim()); }
|
|
808
|
+
} else if (a === "--seconds" || a.startsWith("--seconds=")) {
|
|
809
|
+
const v = a === "--seconds" ? rest[++i] : a.slice("--seconds=".length);
|
|
810
|
+
if (!v) throw new Error("--seconds needs a value");
|
|
811
|
+
seconds = validateSeconds(Number(v));
|
|
812
|
+
} else if (a === "--goto" || a.startsWith("--goto=")) {
|
|
813
|
+
gotoUrl = a === "--goto" ? rest[++i] : a.slice("--goto=".length);
|
|
814
|
+
if (!gotoUrl) throw new Error("--goto got an empty URL");
|
|
815
|
+
} else if (a.startsWith("-")) throw new Error(`read-headers: unknown option ${a}`);
|
|
816
|
+
else throw new Error(`read-headers: unexpected argument ${a}`);
|
|
817
|
+
}
|
|
818
|
+
if (!allowDomains.length) throw new Error("read-headers needs --allow-domain: name the host to capture responses from");
|
|
819
|
+
if (gotoUrl) checkGotoUrl(gotoUrl, allowDomains);
|
|
820
|
+
return { allowDomains, seconds, gotoUrl };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export function readHeadersWireArgs(req: { allowDomains: string[]; seconds: number; gotoUrl?: string }): string[] {
|
|
824
|
+
return ["read-headers", ...req.allowDomains.map(d => `--allow-domain=${d}`), `--seconds=${req.seconds}`, ...(req.gotoUrl ? [`--goto=${req.gotoUrl}`] : [])];
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Parse what the daemon received; exactly the shape readHeadersWireArgs sends. */
|
|
828
|
+
export function parseReadHeadersWire(args: string[]): { allowDomains: string[]; seconds: number; gotoUrl?: string } {
|
|
829
|
+
const [cmd, ...rest] = args;
|
|
830
|
+
if (cmd !== "read-headers") throw new Error("read-headers expected");
|
|
831
|
+
const allowDomains: string[] = [];
|
|
832
|
+
let seconds = READ_HEADERS_DEFAULT_SECONDS;
|
|
833
|
+
let gotoUrl: string | undefined;
|
|
834
|
+
for (const a of rest) {
|
|
835
|
+
if (a.startsWith("--allow-domain=")) { const d = a.slice("--allow-domain=".length); domainGlobToRegExp(d); allowDomains.push(d); }
|
|
836
|
+
else if (a.startsWith("--seconds=")) { seconds = validateSeconds(Number(a.slice("--seconds=".length))); }
|
|
837
|
+
else if (a.startsWith("--goto=")) { gotoUrl = a.slice("--goto=".length); }
|
|
838
|
+
else throw new Error(`read-headers: unexpected argument ${a.startsWith("-") ? a : "(positional)"}`);
|
|
839
|
+
}
|
|
840
|
+
if (!allowDomains.length) throw new Error("read-headers needs --allow-domain");
|
|
841
|
+
if (gotoUrl) checkGotoUrl(gotoUrl, allowDomains);
|
|
842
|
+
return { allowDomains, seconds, gotoUrl };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* run-code that captures responses on the attached page, optionally navigates to gotoUrl first, waits
|
|
847
|
+
* the window, and returns {host, count, captured} JSON. Only responses whose host matches an
|
|
848
|
+
* --allow-domain glob are kept, and credential-bearing header values are redacted IN the browser so
|
|
849
|
+
* their raw values never leave it. Uses page.on("response") (Playwright's view of the CDP Network
|
|
850
|
+
* domain), so it works over the extension bridge the same way read-cookie's run-code does.
|
|
851
|
+
*/
|
|
852
|
+
export function buildReadHeadersCode(allowDomains: string[], seconds: number, gotoUrl?: string): string {
|
|
853
|
+
const patterns = allowDomains.map(d => domainGlobToRegExp(d).source);
|
|
854
|
+
return `async page => {
|
|
855
|
+
const allowed = ${JSON.stringify(patterns)};
|
|
856
|
+
const redact = ${redactResponse.toString()};
|
|
857
|
+
const hostOf = (u) => { const m = /^[a-z][a-z0-9+.-]*:\\/\\/([^/?#]+)/i.exec(String(u || "")); return m ? m[1].replace(/^[^@]*@/, "").replace(/:\\d+$/, "").toLowerCase() : ""; };
|
|
858
|
+
const ok = (h) => h && allowed.some(p => new RegExp(p, "i").test(h));
|
|
859
|
+
const captured = [];
|
|
860
|
+
const onResponse = async (response) => {
|
|
861
|
+
try {
|
|
862
|
+
const url = String(response.url());
|
|
863
|
+
const host = hostOf(url);
|
|
864
|
+
if (!ok(host)) return;
|
|
865
|
+
let headers = {};
|
|
866
|
+
try { headers = await response.allHeaders(); } catch { try { headers = response.headers(); } catch { headers = {}; } }
|
|
867
|
+
captured.push(redact({ url, status: response.status(), headers }, ${JSON.stringify(REDACTED_HEADERS)}, ${JSON.stringify(REDACTED_HEADER_PATTERN)}, ${JSON.stringify(URL_HEADERS)}, ${JSON.stringify(HEADER_REDACTION)}));
|
|
868
|
+
} catch {}
|
|
869
|
+
};
|
|
870
|
+
page.on("response", onResponse);
|
|
871
|
+
// The window includes the --goto: the whole capture fits in the one request.
|
|
872
|
+
const endAt = Date.now() + ${seconds} * 1000;
|
|
873
|
+
try {
|
|
874
|
+
${gotoUrl ? `try { await page.goto(${JSON.stringify(gotoUrl)}, { waitUntil: "load", timeout: ${seconds * 1000} }); } catch {}` : ""}
|
|
875
|
+
await page.waitForTimeout(Math.max(0, endAt - Date.now()));
|
|
876
|
+
} finally {
|
|
877
|
+
try { page.off("response", onResponse); } catch {}
|
|
878
|
+
}
|
|
879
|
+
return JSON.stringify({ host: ${JSON.stringify(allowDomains[0] ?? "")}, count: captured.length, captured });
|
|
880
|
+
}`;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Capture response status + headers on the session's live browser over the window. Returns the
|
|
885
|
+
* captured array (re-redacted daemon-side as defence in depth); the caller writes the evidence file.
|
|
886
|
+
*/
|
|
887
|
+
export async function readHeadersOnSession(opts: { socketRoot: string; session: string; cwd: string; allowDomains: string[]; seconds: number; gotoUrl?: string }): Promise<{ host: string; captured: CapturedResponse[] }> {
|
|
888
|
+
const timeoutMs = opts.seconds * 1000 + 15_000;
|
|
889
|
+
const { isError, text } = await runCodeOnSession({ socketRoot: opts.socketRoot, session: opts.session, cwd: opts.cwd, code: buildReadHeadersCode(opts.allowDomains, opts.seconds, opts.gotoUrl), json: true, timeoutMs });
|
|
890
|
+
if (isError) throw new Error(text.match(/read-headers(?: refused)?: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split("\n")[0]! : "read-headers: could not capture responses"));
|
|
891
|
+
let raw: unknown;
|
|
892
|
+
try { raw = JSON.parse(text).result; } catch { raw = text.match(/### Result\n([^\n]*)/)?.[1]; }
|
|
893
|
+
let out: any = raw;
|
|
894
|
+
for (let i = 0; i < 2 && typeof out === "string"; i++) { try { out = JSON.parse(out); } catch { out = undefined; } }
|
|
895
|
+
if (!out || !Array.isArray(out.captured)) throw new Error("read-headers: the browser returned no capture");
|
|
896
|
+
const captured = out.captured.map((e: any) => redactCaptured(e && typeof e === "object" ? e : {}));
|
|
897
|
+
return { host: typeof out.host === "string" ? out.host : (opts.allowDomains[0] ?? ""), captured };
|
|
898
|
+
}
|
|
899
|
+
|
|
640
900
|
// ---------- password fields in snapshots ----------
|
|
641
901
|
//
|
|
642
902
|
// Snapshots print every input's value, and that includes password fields Chrome autofilled
|
package/listeners.js
CHANGED
|
@@ -29,7 +29,7 @@ import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
|
29
29
|
import { homedir } from "os";
|
|
30
30
|
import { join as join2 } from "path";
|
|
31
31
|
|
|
32
|
-
//
|
|
32
|
+
// token.ts
|
|
33
33
|
var B58 = "[1-9A-HJ-NP-Za-km-z]";
|
|
34
34
|
var BODY_LEN = 22;
|
|
35
35
|
var SUM_LEN = 6;
|
|
@@ -45,7 +45,6 @@ var CRC_TABLE = (() => {
|
|
|
45
45
|
}
|
|
46
46
|
return t;
|
|
47
47
|
})();
|
|
48
|
-
|
|
49
48
|
// shares.ts
|
|
50
49
|
var DEFAULT_SIGNAL = "https://rechrome.pages.dev/";
|
|
51
50
|
var DEFAULT_RTC = "webrtc-default";
|
|
@@ -192,6 +191,8 @@ function validateListeners(value) {
|
|
|
192
191
|
throw new Error("readSecrets must be true or false");
|
|
193
192
|
if (l.readCookies !== undefined && typeof l.readCookies !== "boolean")
|
|
194
193
|
throw new Error("readCookies must be true or false");
|
|
194
|
+
if (l.readHeaders !== undefined && typeof l.readHeaders !== "boolean")
|
|
195
|
+
throw new Error("readHeaders must be true or false");
|
|
195
196
|
if (l.keepDownloads !== undefined && typeof l.keepDownloads !== "boolean")
|
|
196
197
|
throw new Error("keepDownloads must be true or false");
|
|
197
198
|
const address = listenerAddress(l);
|
|
@@ -237,7 +238,8 @@ async function writeListeners(config) {
|
|
|
237
238
|
`, { mode: 384 });
|
|
238
239
|
await rename(temp, LISTENERS_FILE);
|
|
239
240
|
}
|
|
240
|
-
var SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret", "console", "dialog-accept", "dialog-dismiss"]);
|
|
241
|
+
var SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret", "console", "dialog-accept", "dialog-dismiss", "record"]);
|
|
242
|
+
var RECORD_SCOPED_OPTS = ["--seconds", "--max-seconds", "--fps", "--quality", "--filename", "--format"];
|
|
241
243
|
function authorizeProfileRequest(listener, body) {
|
|
242
244
|
if (!body || Array.isArray(body) || !body.identity || typeof body.identity.key !== "string" || !body.identity.key || !Array.isArray(body.args) || body.args.some((a) => typeof a !== "string"))
|
|
243
245
|
throw new Error("Scoped requests require identity.key, identity.profile and string args");
|
|
@@ -255,9 +257,11 @@ function authorizeProfileRequest(listener, body) {
|
|
|
255
257
|
throw new Error(`read-storage is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-secrets`);
|
|
256
258
|
if (args[0] === "read-cookie" && listener.readCookies !== true)
|
|
257
259
|
throw new Error(`read-cookie is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-cookies`);
|
|
258
|
-
if (args[0]
|
|
260
|
+
if (args[0] === "read-headers" && listener.readHeaders !== true)
|
|
261
|
+
throw new Error(`read-headers is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-headers`);
|
|
262
|
+
if (args[0] !== "upload" && args[0] !== "read-secret" && args[0] !== "read-cookie" && args[0] !== "read-storage" && args[0] !== "read-headers" && !SCOPED_COMMANDS.has(args[0]))
|
|
259
263
|
throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
|
|
260
|
-
if (body.args.filter((a) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "console" && a === "--clear") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain="))) && !(args[0] === "read-secret" && a.startsWith("--allow-domain=")) && !(args[0] === "read-cookie" && a.startsWith("--allow-domain=")) && !(args[0] === "read-storage" && a.startsWith("--allow-domain="))))
|
|
264
|
+
if (body.args.filter((a) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "console" && a === "--clear") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain="))) && !(args[0] === "read-secret" && a.startsWith("--allow-domain=")) && !(args[0] === "read-cookie" && a.startsWith("--allow-domain=")) && !(args[0] === "read-storage" && (a.startsWith("--allow-domain=") || a.startsWith("--goto="))) && !(args[0] === "read-headers" && (a.startsWith("--allow-domain=") || a.startsWith("--seconds=") || a.startsWith("--goto="))) && !(args[0] === "record" && RECORD_SCOPED_OPTS.some((o) => a === o || a.startsWith(o + "=")))))
|
|
261
265
|
throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
|
|
262
266
|
if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank")
|
|
263
267
|
throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
|
package/listeners.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
|
7
7
|
import { DEFAULT_RTC, DEFAULT_SIGNAL, normalizeSignal } from "./shares.ts";
|
|
8
8
|
|
|
9
9
|
// publicUrl: where a reverse proxy (Tailscale Serve, Caddy, nginx, ...) exposes this listener.
|
|
10
|
-
export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy | false; readSecrets?: boolean; readCookies?: boolean; keepDownloads?: boolean };
|
|
10
|
+
export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy | false; readSecrets?: boolean; readCookies?: boolean; readHeaders?: boolean; keepDownloads?: boolean };
|
|
11
11
|
// retiredPorts: ports of removed listeners, newest last. New listeners skip them so a link
|
|
12
12
|
// saved for a removed listener gets "connection refused", not some other listener's answer.
|
|
13
13
|
// rtcListeners: WebRTC transports (a signal server, plus optional STUN/TURN). They live under
|
|
@@ -140,6 +140,7 @@ export function validateListeners(value: unknown): ListenerConfig {
|
|
|
140
140
|
if (l.uploads !== undefined && l.uploads !== false) validateUploadPolicy(l.uploads);
|
|
141
141
|
if (l.readSecrets !== undefined && typeof l.readSecrets !== "boolean") throw new Error("readSecrets must be true or false");
|
|
142
142
|
if (l.readCookies !== undefined && typeof l.readCookies !== "boolean") throw new Error("readCookies must be true or false");
|
|
143
|
+
if (l.readHeaders !== undefined && typeof l.readHeaders !== "boolean") throw new Error("readHeaders must be true or false");
|
|
143
144
|
if (l.keepDownloads !== undefined && typeof l.keepDownloads !== "boolean") throw new Error("keepDownloads must be true or false");
|
|
144
145
|
const address = listenerAddress(l);
|
|
145
146
|
if (names.has(l.name) || addresses.has(address) || keys.has(l.key)) throw new Error("Listener names, addresses, and credentials must be unique");
|
|
@@ -179,7 +180,10 @@ export async function writeListeners(config: ListenerConfig): Promise<void> {
|
|
|
179
180
|
|
|
180
181
|
// Restricted listeners deliberately exclude arbitrary code, host filesystem access,
|
|
181
182
|
// attachment/config overrides, and commands that manage all browser sessions.
|
|
182
|
-
const SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret", "console", "dialog-accept", "dialog-dismiss"]);
|
|
183
|
+
const SCOPED_COMMANDS = new Set(["open", "goto", "tab-new", "tab-list", "tab-select", "tab-close", "snapshot", "screenshot", "click", "dblclick", "fill", "type", "press", "hover", "select", "check", "uncheck", "go-back", "go-forward", "reload", "resize", "close", "fill-secret", "console", "dialog-accept", "dialog-dismiss", "record"]);
|
|
184
|
+
// The CLI options each scoped command may carry (everything else is refused). record uses a
|
|
185
|
+
// start/stop subcommand (a positional) plus these capture/output knobs (record.ts RECORD_OPTIONS).
|
|
186
|
+
const RECORD_SCOPED_OPTS = ["--seconds", "--max-seconds", "--fps", "--quality", "--filename", "--format"];
|
|
183
187
|
export function authorizeProfileRequest(listener: Listener, body: any): string {
|
|
184
188
|
if (!body || Array.isArray(body) || !body.identity || typeof body.identity.key !== "string" || !body.identity.key || !Array.isArray(body.args) || body.args.some((a: unknown) => typeof a !== "string")) throw new Error("Scoped requests require identity.key, identity.profile and string args");
|
|
185
189
|
const profile = body.identity.profile;
|
|
@@ -194,8 +198,10 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
|
|
|
194
198
|
throw new Error(`read-storage is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-secrets`);
|
|
195
199
|
if (args[0] === "read-cookie" && listener.readCookies !== true)
|
|
196
200
|
throw new Error(`read-cookie is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-cookies`);
|
|
197
|
-
if (args[0]
|
|
198
|
-
|
|
201
|
+
if (args[0] === "read-headers" && listener.readHeaders !== true)
|
|
202
|
+
throw new Error(`read-headers is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-headers`);
|
|
203
|
+
if (args[0] !== "upload" && args[0] !== "read-secret" && args[0] !== "read-cookie" && args[0] !== "read-storage" && args[0] !== "read-headers" && !SCOPED_COMMANDS.has(args[0])) throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
|
|
204
|
+
if (body.args.filter((a: string) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a: string) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "console" && a === "--clear") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain="))) && !(args[0] === "read-secret" && a.startsWith("--allow-domain=")) && !(args[0] === "read-cookie" && a.startsWith("--allow-domain=")) && !(args[0] === "read-storage" && (a.startsWith("--allow-domain=") || a.startsWith("--goto="))) && !(args[0] === "read-headers" && (a.startsWith("--allow-domain=") || a.startsWith("--seconds=") || a.startsWith("--goto="))) && !(args[0] === "record" && RECORD_SCOPED_OPTS.some(o => a === o || a.startsWith(o + "="))))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
|
|
199
205
|
if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank") throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
|
|
200
206
|
// Upload is handled by the byte-only receiver; the caller cannot supply host paths.
|
|
201
207
|
if (args[0] === "upload" && (args.length !== 1 || !body.upload)) throw new Error("Upload requires stdin bytes; host paths are unavailable");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rechrome",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.48.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/snomiao/rechrome.git"
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
"listeners.js",
|
|
24
24
|
"fill-secret.ts",
|
|
25
25
|
"fill-secret.js",
|
|
26
|
+
"record.ts",
|
|
27
|
+
"record.js",
|
|
26
28
|
"redact.ts",
|
|
27
29
|
"redact.js",
|
|
28
30
|
"secret-rules",
|
|
@@ -36,6 +38,10 @@
|
|
|
36
38
|
"scoped-upload.js",
|
|
37
39
|
"secret.ts",
|
|
38
40
|
"secret.js",
|
|
41
|
+
"token.ts",
|
|
42
|
+
"token.js",
|
|
43
|
+
"rtc-channel.ts",
|
|
44
|
+
"rtc-channel.js",
|
|
39
45
|
"shares.ts",
|
|
40
46
|
"shares.js",
|
|
41
47
|
"e2e.ts",
|
|
@@ -49,11 +55,12 @@
|
|
|
49
55
|
"scripts": {
|
|
50
56
|
"serve": "bun run rechrome.ts serve",
|
|
51
57
|
"test": "bun test ./*.test.ts ./*.spec.ts ./scripts/*.test.ts",
|
|
58
|
+
"build-invite-page": "bun scripts/build-invite-page.ts",
|
|
52
59
|
"build-playwright": "bash scripts/build-playwright.sh",
|
|
53
60
|
"vendor-cli": "bash scripts/vendor-cli.sh",
|
|
54
61
|
"prepare": "bash scripts/vendor-cli.sh --prepare",
|
|
55
62
|
"check-pack": "bash scripts/check-pack.sh",
|
|
56
|
-
"prepublishOnly": "bash scripts/vendor-cli.sh && sed 's/\\.ts/\\.js/g' rechrome.ts > rechrome.js && sed 's/\\.ts/\\.js/g' serve.ts > serve.js && bun build extension-token.ts --target=bun --outfile=extension-token.js && bun build daemon-manager.ts --target=bun --outfile=daemon-manager.js && bun build listeners.ts --target=bun --outfile=listeners.js && bun build fill-secret.ts --target=bun --outfile=fill-secret.js && bun build scoped-upload.ts --target=bun --outfile=scoped-upload.js && bun build secret.ts --target=bun --external node-datachannel --outfile=secret.js && bun build shares.ts --target=bun --external node-datachannel --outfile=shares.js && bun build e2e.ts --target=bun --external node-datachannel --outfile=e2e.js && bun build webrtc.ts --target=bun --external node-datachannel --outfile=webrtc.js && bun build signal-relay.ts --target=bun --external node-datachannel --outfile=signal-relay.js && bun build redact.ts --target=bun --outfile=redact.js && bash scripts/check-pack.sh"
|
|
63
|
+
"prepublishOnly": "bash scripts/vendor-cli.sh && sed 's/\\.ts/\\.js/g' rechrome.ts > rechrome.js && sed 's/\\.ts/\\.js/g' serve.ts > serve.js && bun build extension-token.ts --target=bun --outfile=extension-token.js && bun build daemon-manager.ts --target=bun --outfile=daemon-manager.js && bun build listeners.ts --target=bun --outfile=listeners.js && bun build fill-secret.ts --target=bun --outfile=fill-secret.js && bun build record.ts --target=bun --outfile=record.js && bun build scoped-upload.ts --target=bun --outfile=scoped-upload.js && bun build secret.ts --target=bun --external node-datachannel --outfile=secret.js && bun build token.ts --target=bun --outfile=token.js && bun build rtc-channel.ts --target=bun --outfile=rtc-channel.js && bun build shares.ts --target=bun --external node-datachannel --outfile=shares.js && bun build e2e.ts --target=bun --external node-datachannel --outfile=e2e.js && bun build webrtc.ts --target=bun --external node-datachannel --outfile=webrtc.js && bun build signal-relay.ts --target=bun --external node-datachannel --outfile=signal-relay.js && bun build redact.ts --target=bun --outfile=redact.js && bash scripts/check-pack.sh"
|
|
57
64
|
},
|
|
58
65
|
"devDependencies": {
|
|
59
66
|
"@types/bun": "latest",
|