rechrome 1.43.2 → 1.44.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/fill-secret.js CHANGED
@@ -434,6 +434,123 @@ async function readSecretOnSession(opts) {
434
434
  throw new Error("read-secret: the page returned no value");
435
435
  return out;
436
436
  }
437
+ var READ_STORAGE_USAGE = `Usage: rech read-storage <local|session> <key> [json-path] --allow-domain <glob> | <command>
438
+ Writes the storage value, raw and without a newline, to stdout, which must be a pipe into the
439
+ program that stores it (never a terminal or a file). Never logged; masked in later outputs.
440
+ <local|session> localStorage or sessionStorage
441
+ <key> the storage key, e.g. localConfig_v2
442
+ [json-path] optional: a path into the JSON value, e.g. teams.T0123.token or teams[T0123].token
443
+ --allow-domain GLOB required: the page's host must match, e.g. app.slack.com`;
444
+ function parseStoragePath(path) {
445
+ return path.match(/[^.[\]]+/g) ?? [];
446
+ }
447
+ function parseReadStorageArgs(args) {
448
+ const [cmd, ...rest] = args;
449
+ if (cmd !== "read-storage")
450
+ throw new Error("Expected read-storage");
451
+ const pos = [];
452
+ const allowDomains = [];
453
+ for (let i = 0;i < rest.length; i++) {
454
+ const a = rest[i];
455
+ if (a === "--allow-domain" || a.startsWith("--allow-domain=")) {
456
+ const v = a === "--allow-domain" ? rest[++i] : a.slice("--allow-domain=".length);
457
+ if (!v)
458
+ throw new Error("--allow-domain got an empty host");
459
+ for (const d of v.split(",")) {
460
+ domainGlobToRegExp(d.trim());
461
+ allowDomains.push(d.trim());
462
+ }
463
+ } else if (a.startsWith("-"))
464
+ throw new Error(`read-storage: unknown option ${a}`);
465
+ else
466
+ pos.push(a);
467
+ }
468
+ if (pos.length < 2 || pos.length > 3)
469
+ throw new Error("read-storage needs <local|session> <key> [json-path]");
470
+ const [area, key, rawPath] = pos;
471
+ if (area !== "local" && area !== "session")
472
+ throw new Error("read-storage: first argument must be 'local' or 'session'");
473
+ if (!key)
474
+ throw new Error("read-storage: a storage key is required");
475
+ if (!allowDomains.length)
476
+ throw new Error("read-storage needs --allow-domain: name the site the value must come from");
477
+ return { area, key, path: rawPath ? parseStoragePath(rawPath) : [], allowDomains };
478
+ }
479
+ function readStorageWireArgs(req) {
480
+ return ["read-storage", req.area, req.key, ...req.path.length ? [req.path.join(".")] : [], ...req.allowDomains.map((d) => `--allow-domain=${d}`)];
481
+ }
482
+ function parseReadStorageWire(args) {
483
+ const [cmd, area, key, ...rest] = args;
484
+ if (cmd !== "read-storage" || area !== "local" && area !== "session" || !key || key.startsWith("-"))
485
+ throw new Error("read-storage needs <local|session> <key>");
486
+ const allowDomains = [];
487
+ let path = [];
488
+ for (const a of rest) {
489
+ if (a.startsWith("--allow-domain=")) {
490
+ const d = a.slice("--allow-domain=".length);
491
+ domainGlobToRegExp(d);
492
+ allowDomains.push(d);
493
+ } else if (!a.startsWith("-") && !path.length)
494
+ path = parseStoragePath(a);
495
+ else
496
+ throw new Error(`read-storage: unexpected argument ${a.startsWith("-") ? a : "(positional)"}`);
497
+ }
498
+ if (!allowDomains.length)
499
+ throw new Error("read-storage needs --allow-domain");
500
+ return { area, key, path, allowDomains };
501
+ }
502
+ function buildReadStorageCode(area, key, path, allowDomains) {
503
+ const patterns = allowDomains.map((d) => domainGlobToRegExp(d).source);
504
+ const store = area === "local" ? "localStorage" : "sessionStorage";
505
+ return `async page => {
506
+ // One evaluate: the host checked is the origin read, with no navigation in between.
507
+ const out = await page.evaluate(({ key, path }) => {
508
+ const host = String(location.hostname).toLowerCase();
509
+ const raw = window.${store}.getItem(key);
510
+ if (raw === null) return { host, missing: true };
511
+ if (!path.length) return { host, value: raw };
512
+ let v;
513
+ try { v = JSON.parse(raw); } catch { return { host, notjson: true }; }
514
+ for (const seg of path) {
515
+ if (v == null || typeof v !== "object") return { host, nopath: true };
516
+ v = v[seg];
517
+ }
518
+ if (v === undefined) return { host, nopath: true };
519
+ return { host, value: typeof v === "string" ? v : JSON.stringify(v) };
520
+ }, { key: ${JSON.stringify(key)}, path: ${JSON.stringify(path)} });
521
+ const host = out.host;
522
+ const allowed = ${JSON.stringify(patterns)};
523
+ if (!allowed.some(p => new RegExp(p, "i").test(host)))
524
+ throw new Error("read-storage refused: the page host " + JSON.stringify(host) + " is not allowed by --allow-domain");
525
+ if (out.missing) throw new Error("read-storage: no ${store} key " + JSON.stringify(${JSON.stringify(key)}) + " on " + host);
526
+ if (out.notjson) throw new Error("read-storage: the value is not JSON, so the path can't apply");
527
+ if (out.nopath) throw new Error("read-storage: the json-path is not present in the value");
528
+ return JSON.stringify({ host, value: out.value });
529
+ }`;
530
+ }
531
+ async function readStorageOnSession(opts) {
532
+ const { isError, text } = await runCodeOnSession({ ...opts, code: buildReadStorageCode(opts.area, opts.key, opts.path, opts.allowDomains), json: true });
533
+ if (isError)
534
+ throw new Error(text.match(/read-storage(?: refused)?: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split(`
535
+ `)[0] : "read-storage: could not read the value"));
536
+ let raw;
537
+ try {
538
+ raw = JSON.parse(text).result;
539
+ } catch {
540
+ raw = text.match(/### Result\n([^\n]*)/)?.[1];
541
+ }
542
+ let o = raw;
543
+ for (let i = 0;i < 2 && typeof o === "string"; i++) {
544
+ try {
545
+ o = JSON.parse(o);
546
+ } catch {
547
+ o = undefined;
548
+ }
549
+ }
550
+ if (!o || typeof o.host !== "string" || typeof o.value !== "string")
551
+ throw new Error("read-storage: the page returned no value");
552
+ return o;
553
+ }
437
554
  var READ_COOKIE_USAGE = `Usage: rech read-cookie <name> <domain> --allow-domain <domain> | <command>
438
555
  Writes the cookie's value, raw and without a newline, to stdout, which must be a pipe into the
439
556
  program that stores it (never a terminal or a file). Never logged; masked in later outputs.
@@ -590,6 +707,7 @@ export {
590
707
  MIN_MASK_LENGTH,
591
708
  READ_COOKIE_USAGE,
592
709
  READ_SECRET_USAGE,
710
+ READ_STORAGE_USAGE,
593
711
  SECRET_MASK,
594
712
  SECRET_MASK_TTL_MS,
595
713
  SecretMasker,
@@ -597,6 +715,7 @@ export {
597
715
  buildFillCode,
598
716
  buildReadCode,
599
717
  buildReadCookieCode,
718
+ buildReadStorageCode,
600
719
  daemonSessionSockets,
601
720
  domainGlobToRegExp,
602
721
  fillSecretOnSession,
@@ -612,13 +731,18 @@ export {
612
731
  parseReadCookieWire,
613
732
  parseReadSecretArgs,
614
733
  parseReadSecretWire,
734
+ parseReadStorageArgs,
735
+ parseReadStorageWire,
615
736
  parseScanResult,
737
+ parseStoragePath,
616
738
  passwordValuesOnSession,
617
739
  readCookieOnSession,
618
740
  readCookieWireArgs,
619
741
  readSecretOnSession,
620
742
  readSecretSource,
621
743
  readSecretWireArgs,
744
+ readStorageOnSession,
745
+ readStorageWireArgs,
622
746
  runCodeOnSession,
623
747
  sessionSocketCandidates,
624
748
  socketRequest,
package/fill-secret.ts CHANGED
@@ -443,6 +443,108 @@ export async function readSecretOnSession(opts: { socketRoot: string; session: s
443
443
  return out;
444
444
  }
445
445
 
446
+ // ---------- read-storage: a localStorage/sessionStorage value to a pipe ----------
447
+ //
448
+ // Same contract as read-secret: value only in `secretOut`, never logged, masked later, client
449
+ // pipe-only. For the live app token in storage (Slack's localConfig_v2 teams[T...].token). Reads
450
+ // via page-level evaluate, so it works over the extension bridge. localStorage is per-origin, so
451
+ // the session's page must be on the allowed host.
452
+
453
+ export const READ_STORAGE_USAGE = `Usage: rech read-storage <local|session> <key> [json-path] --allow-domain <glob> | <command>
454
+ Writes the storage value, raw and without a newline, to stdout, which must be a pipe into the
455
+ program that stores it (never a terminal or a file). Never logged; masked in later outputs.
456
+ <local|session> localStorage or sessionStorage
457
+ <key> the storage key, e.g. localConfig_v2
458
+ [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`;
460
+
461
+ /** Split "teams[T0123].token" / "teams.T0123.token" into ["teams","T0123","token"]. */
462
+ export function parseStoragePath(path: string): string[] {
463
+ return path.match(/[^.[\]]+/g) ?? [];
464
+ }
465
+
466
+ export function parseReadStorageArgs(args: string[]): { area: "local" | "session"; key: string; path: string[]; allowDomains: string[] } {
467
+ const [cmd, ...rest] = args;
468
+ if (cmd !== "read-storage") throw new Error("Expected read-storage");
469
+ const pos: string[] = [];
470
+ const allowDomains: string[] = [];
471
+ for (let i = 0; i < rest.length; i++) {
472
+ const a = rest[i]!;
473
+ if (a === "--allow-domain" || a.startsWith("--allow-domain=")) {
474
+ const v = a === "--allow-domain" ? rest[++i] : a.slice("--allow-domain=".length);
475
+ if (!v) throw new Error("--allow-domain got an empty host");
476
+ for (const d of v.split(",")) { domainGlobToRegExp(d.trim()); allowDomains.push(d.trim()); }
477
+ } else if (a.startsWith("-")) throw new Error(`read-storage: unknown option ${a}`);
478
+ else pos.push(a);
479
+ }
480
+ if (pos.length < 2 || pos.length > 3) throw new Error("read-storage needs <local|session> <key> [json-path]");
481
+ const [area, key, rawPath] = pos;
482
+ if (area !== "local" && area !== "session") throw new Error("read-storage: first argument must be 'local' or 'session'");
483
+ if (!key) throw new Error("read-storage: a storage key is required");
484
+ if (!allowDomains.length) throw new Error("read-storage needs --allow-domain: name the site the value must come from");
485
+ return { area, key, path: rawPath ? parseStoragePath(rawPath) : [], allowDomains };
486
+ }
487
+
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}`)];
490
+ }
491
+
492
+ export function parseReadStorageWire(args: string[]): { area: "local" | "session"; key: string; path: string[]; allowDomains: string[] } {
493
+ const [cmd, area, key, ...rest] = args;
494
+ if (cmd !== "read-storage" || (area !== "local" && area !== "session") || !key || key.startsWith("-")) throw new Error("read-storage needs <local|session> <key>");
495
+ const allowDomains: string[] = [];
496
+ let path: string[] = [];
497
+ for (const a of rest) {
498
+ if (a.startsWith("--allow-domain=")) { const d = a.slice("--allow-domain=".length); domainGlobToRegExp(d); allowDomains.push(d); }
499
+ else if (!a.startsWith("-") && !path.length) path = parseStoragePath(a);
500
+ else throw new Error(`read-storage: unexpected argument ${a.startsWith("-") ? a : "(positional)"}`);
501
+ }
502
+ if (!allowDomains.length) throw new Error("read-storage needs --allow-domain");
503
+ return { area, key, path, allowDomains };
504
+ }
505
+
506
+ /** run-code reading storage[key], optionally walking a JSON path; returns {host,value} JSON. */
507
+ export function buildReadStorageCode(area: "local" | "session", key: string, path: string[], allowDomains: string[]): string {
508
+ const patterns = allowDomains.map(d => domainGlobToRegExp(d).source);
509
+ const store = area === "local" ? "localStorage" : "sessionStorage";
510
+ return `async page => {
511
+ // One evaluate: the host checked is the origin read, with no navigation in between.
512
+ const out = await page.evaluate(({ key, path }) => {
513
+ const host = String(location.hostname).toLowerCase();
514
+ const raw = window.${store}.getItem(key);
515
+ if (raw === null) return { host, missing: true };
516
+ if (!path.length) return { host, value: raw };
517
+ let v;
518
+ try { v = JSON.parse(raw); } catch { return { host, notjson: true }; }
519
+ for (const seg of path) {
520
+ if (v == null || typeof v !== "object") return { host, nopath: true };
521
+ v = v[seg];
522
+ }
523
+ if (v === undefined) return { host, nopath: true };
524
+ return { host, value: typeof v === "string" ? v : JSON.stringify(v) };
525
+ }, { key: ${JSON.stringify(key)}, path: ${JSON.stringify(path)} });
526
+ const host = out.host;
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");
530
+ if (out.missing) throw new Error("read-storage: no ${store} key " + JSON.stringify(${JSON.stringify(key)}) + " on " + host);
531
+ if (out.notjson) throw new Error("read-storage: the value is not JSON, so the path can't apply");
532
+ if (out.nopath) throw new Error("read-storage: the json-path is not present in the value");
533
+ return JSON.stringify({ host, value: out.value });
534
+ }`;
535
+ }
536
+
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
+ const { isError, text } = await runCodeOnSession({ ...opts, code: buildReadStorageCode(opts.area, opts.key, opts.path, opts.allowDomains), json: true });
539
+ if (isError) throw new Error(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"));
540
+ let raw: unknown;
541
+ try { raw = JSON.parse(text).result; } catch { raw = text.match(/### Result\n([^\n]*)/)?.[1]; }
542
+ let o: any = raw;
543
+ for (let i = 0; i < 2 && typeof o === "string"; i++) { try { o = JSON.parse(o); } catch { o = undefined; } }
544
+ if (!o || typeof o.host !== "string" || typeof o.value !== "string") throw new Error("read-storage: the page returned no value");
545
+ return o;
546
+ }
547
+
446
548
  // ---------- read-cookie: a cookie value (incl. HttpOnly) to a pipe ----------
447
549
  //
448
550
  // Same contract as read-secret: the value travels only in the response's `secretOut`, is never
package/listeners.js CHANGED
@@ -136,11 +136,13 @@ function authorizeProfileRequest(listener, body) {
136
136
  throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
137
137
  if (args[0] === "read-secret" && listener.readSecrets !== true)
138
138
  throw new Error(`read-secret is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-secrets`);
139
+ if (args[0] === "read-storage" && listener.readSecrets !== true)
140
+ throw new Error(`read-storage is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-secrets`);
139
141
  if (args[0] === "read-cookie" && listener.readCookies !== true)
140
142
  throw new Error(`read-cookie is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-cookies`);
141
- if (args[0] !== "upload" && args[0] !== "read-secret" && args[0] !== "read-cookie" && !SCOPED_COMMANDS.has(args[0]))
143
+ if (args[0] !== "upload" && args[0] !== "read-secret" && args[0] !== "read-cookie" && args[0] !== "read-storage" && !SCOPED_COMMANDS.has(args[0]))
142
144
  throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
143
- 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="))))
145
+ 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="))))
144
146
  throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
145
147
  if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank")
146
148
  throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
package/listeners.ts CHANGED
@@ -111,10 +111,12 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
111
111
  throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
112
112
  if (args[0] === "read-secret" && listener.readSecrets !== true)
113
113
  throw new Error(`read-secret is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-secrets`);
114
+ if (args[0] === "read-storage" && listener.readSecrets !== true)
115
+ throw new Error(`read-storage is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-secrets`);
114
116
  if (args[0] === "read-cookie" && listener.readCookies !== true)
115
117
  throw new Error(`read-cookie is off on listener "${listener.name}". The host can turn it on: rech listener set ${listener.name} --read-cookies`);
116
- if (args[0] !== "upload" && args[0] !== "read-secret" && args[0] !== "read-cookie" && !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");
117
- 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=")))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
118
+ if (args[0] !== "upload" && args[0] !== "read-secret" && args[0] !== "read-cookie" && args[0] !== "read-storage" && !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");
119
+ 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=")))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
118
120
  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");
119
121
  // Upload is handled by the byte-only receiver; the caller cannot supply host paths.
120
122
  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.43.2",
3
+ "version": "1.44.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -6,7 +6,7 @@ import { file } from "bun";
6
6
  import yargs from "yargs";
7
7
  import { readExtensionTokenFromProfile } from "./extension-token.js";
8
8
  import { redact } from "./redact.js";
9
- import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.js";
9
+ import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_STORAGE_USAGE, parseReadStorageArgs, readStorageWireArgs, READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.js";
10
10
  import { createHash, randomBytes } from "crypto";
11
11
  import { mkdirSync, fstatSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
12
12
  import { hostname, homedir, networkInterfaces } from "os";
@@ -2189,6 +2189,26 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2189
2189
  console.error(`[rech] read-cookie: wrote ${res.secretOut.length} characters to stdout`);
2190
2190
  process.exit(0);
2191
2191
  }
2192
+ if (args[cmdIdx] === "read-storage") {
2193
+ if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_STORAGE_USAGE); process.exit(0); }
2194
+ let swire: string[];
2195
+ try {
2196
+ const out = fstatSync(1);
2197
+ if (!out.isFIFO() && !out.isSocket()) throw new Error("read-storage writes the value to stdout, which must be a pipe into the program that stores it (not a terminal or a file)");
2198
+ swire = readStorageWireArgs(parseReadStorageArgs(args.slice(cmdIdx)));
2199
+ } catch (err) {
2200
+ console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${READ_STORAGE_USAGE}`);
2201
+ process.exit(2);
2202
+ }
2203
+ const res = await callServe(url, [...args.filter(a => a.startsWith("-s=")), ...swire], overrideEnv, identity);
2204
+ if (res.status !== 0 || typeof res.secretOut !== "string") {
2205
+ process.stderr.write(res.stderr || (res.status === 0 ? "[rech] read-storage: this daemon is older than the client (no value returned)\n" : ""));
2206
+ process.exit(res.status || 1);
2207
+ }
2208
+ await new Promise<void>((done, fail) => process.stdout.write(res.secretOut!, e => e ? fail(e) : done()));
2209
+ console.error(`[rech] read-storage: wrote ${res.secretOut.length} characters to stdout`);
2210
+ process.exit(0);
2211
+ }
2192
2212
  if (args[cmdIdx] === "read-secret") {
2193
2213
  if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_SECRET_USAGE); process.exit(0); }
2194
2214
  let wire: string[];
@@ -4537,6 +4557,8 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
4537
4557
  copy a field's value (e.g. an API secret) into a pipe, never shown or logged
4538
4558
  rech read-cookie <name> <domain> --allow-domain <domain> | <command>
4539
4559
  copy a cookie (incl. HttpOnly) into a pipe, never shown or logged
4560
+ rech read-storage <local|session> <key> [json-path] --allow-domain <host> | <command>
4561
+ copy a localStorage/sessionStorage value into a pipe, never shown or logged
4540
4562
  rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
4541
4563
  rech --version rechrome's version
4542
4564
 
@@ -4652,7 +4674,7 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4652
4674
  .option("clear-public-url", { type: "boolean", conflicts: "public-url" })
4653
4675
  .option("uploads", { type: "boolean", describe: "Uploads of images the link holder sends (rech upload) are on by default; --no-uploads turns them off" })
4654
4676
  .option("host-upload-dir", { type: "string", array: true, requiresArg: true, describe: "Off by default. Also let links upload files already on this host, from inside this directory (rech upload --host <path>); repeatable" })
4655
- .option("read-secrets", { type: "boolean", describe: "Off by default. Let this listener's links copy any field's value (saved passwords too) into a pipe (rech read-secret): only for links you trust with those accounts. --no-read-secrets turns it off" })
4677
+ .option("read-secrets", { type: "boolean", describe: "Off by default. Let this listener's links copy any field's value (saved passwords too) or localStorage/sessionStorage value (app tokens) into a pipe (rech read-secret, read-storage): only for links you trust with those accounts. --no-read-secrets turns it off" })
4656
4678
  .option("read-cookies", { type: "boolean", describe: "Off by default. Let this listener's links copy any cookie, HttpOnly session cookies too (rech read-cookie): enough to take over the profiles' signed-in sessions, so only for links you trust with those accounts. --no-read-cookies turns it off" })
4657
4679
  .option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
4658
4680
  .check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined || a.readCookies !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, or --host-upload-dir/--no-host-uploads"),
package/rechrome.ts CHANGED
@@ -6,7 +6,7 @@ import { file } from "bun";
6
6
  import yargs from "yargs";
7
7
  import { readExtensionTokenFromProfile } from "./extension-token.ts";
8
8
  import { redact } from "./redact.ts";
9
- import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.ts";
9
+ import { READ_COOKIE_USAGE, parseReadCookieArgs, readCookieWireArgs, READ_STORAGE_USAGE, parseReadStorageArgs, readStorageWireArgs, READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.ts";
10
10
  import { createHash, randomBytes } from "crypto";
11
11
  import { mkdirSync, fstatSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
12
12
  import { hostname, homedir, networkInterfaces } from "os";
@@ -2189,6 +2189,26 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2189
2189
  console.error(`[rech] read-cookie: wrote ${res.secretOut.length} characters to stdout`);
2190
2190
  process.exit(0);
2191
2191
  }
2192
+ if (args[cmdIdx] === "read-storage") {
2193
+ if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_STORAGE_USAGE); process.exit(0); }
2194
+ let swire: string[];
2195
+ try {
2196
+ const out = fstatSync(1);
2197
+ if (!out.isFIFO() && !out.isSocket()) throw new Error("read-storage writes the value to stdout, which must be a pipe into the program that stores it (not a terminal or a file)");
2198
+ swire = readStorageWireArgs(parseReadStorageArgs(args.slice(cmdIdx)));
2199
+ } catch (err) {
2200
+ console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${READ_STORAGE_USAGE}`);
2201
+ process.exit(2);
2202
+ }
2203
+ const res = await callServe(url, [...args.filter(a => a.startsWith("-s=")), ...swire], overrideEnv, identity);
2204
+ if (res.status !== 0 || typeof res.secretOut !== "string") {
2205
+ process.stderr.write(res.stderr || (res.status === 0 ? "[rech] read-storage: this daemon is older than the client (no value returned)\n" : ""));
2206
+ process.exit(res.status || 1);
2207
+ }
2208
+ await new Promise<void>((done, fail) => process.stdout.write(res.secretOut!, e => e ? fail(e) : done()));
2209
+ console.error(`[rech] read-storage: wrote ${res.secretOut.length} characters to stdout`);
2210
+ process.exit(0);
2211
+ }
2192
2212
  if (args[cmdIdx] === "read-secret") {
2193
2213
  if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_SECRET_USAGE); process.exit(0); }
2194
2214
  let wire: string[];
@@ -4537,6 +4557,8 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
4537
4557
  copy a field's value (e.g. an API secret) into a pipe, never shown or logged
4538
4558
  rech read-cookie <name> <domain> --allow-domain <domain> | <command>
4539
4559
  copy a cookie (incl. HttpOnly) into a pipe, never shown or logged
4560
+ rech read-storage <local|session> <key> [json-path] --allow-domain <host> | <command>
4561
+ copy a localStorage/sessionStorage value into a pipe, never shown or logged
4540
4562
  rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
4541
4563
  rech --version rechrome's version
4542
4564
 
@@ -4652,7 +4674,7 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4652
4674
  .option("clear-public-url", { type: "boolean", conflicts: "public-url" })
4653
4675
  .option("uploads", { type: "boolean", describe: "Uploads of images the link holder sends (rech upload) are on by default; --no-uploads turns them off" })
4654
4676
  .option("host-upload-dir", { type: "string", array: true, requiresArg: true, describe: "Off by default. Also let links upload files already on this host, from inside this directory (rech upload --host <path>); repeatable" })
4655
- .option("read-secrets", { type: "boolean", describe: "Off by default. Let this listener's links copy any field's value (saved passwords too) into a pipe (rech read-secret): only for links you trust with those accounts. --no-read-secrets turns it off" })
4677
+ .option("read-secrets", { type: "boolean", describe: "Off by default. Let this listener's links copy any field's value (saved passwords too) or localStorage/sessionStorage value (app tokens) into a pipe (rech read-secret, read-storage): only for links you trust with those accounts. --no-read-secrets turns it off" })
4656
4678
  .option("read-cookies", { type: "boolean", describe: "Off by default. Let this listener's links copy any cookie, HttpOnly session cookies too (rech read-cookie): enough to take over the profiles' signed-in sessions, so only for links you trust with those accounts. --no-read-cookies turns it off" })
4657
4679
  .option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
4658
4680
  .check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined || a.readCookies !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, or --host-upload-dir/--no-host-uploads"),
package/serve.js CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  checkTailscaleServe,
25
25
  chromeDataAccessHint,
26
26
  } from "./rechrome.js";
27
- import { SecretMasker, parseReadCookieWire, readCookieOnSession, parseReadSecretWire, readSecretOnSession, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.js";
27
+ import { SecretMasker, parseReadCookieWire, readCookieOnSession, parseReadStorageWire, readStorageOnSession, parseReadSecretWire, readSecretOnSession, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.js";
28
28
 
29
29
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
30
30
  const CERT_RENEW_THRESHOLD_DAYS = 7;
@@ -961,6 +961,22 @@ export async function serve() {
961
961
  }
962
962
  }
963
963
 
964
+ // read-storage: a localStorage/sessionStorage value, page-level; secretOut only.
965
+ if (filteredArgs[0] === "read-storage") {
966
+ try {
967
+ const { area, key, path, allowDomains } = parseReadStorageWire(filteredArgs);
968
+ const { host, value } = await readStorageOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, area, key, path, allowDomains });
969
+ if (!value) throw new Error(`read-storage: ${area} ${key} on ${host} is empty`);
970
+ secretMasker.add(value, Infinity);
971
+ log(`exit: 0 | read-storage ${area} ${key}${path.length ? " " + path.join(".") : ""} on ${host} (${value.length} chars, value not logged)`);
972
+ return Response.json({ status: 0, stdout: "", stderr: "", files: [], secretOut: value });
973
+ } catch (error) {
974
+ const message = secretMasker.mask(error instanceof Error ? error.message : String(error));
975
+ log(`exit: 1 | read-storage: ${message.split("\n")[0]}`);
976
+ return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
977
+ }
978
+ }
979
+
964
980
  // read-cookie: like read-secret, the value goes back only in secretOut. See fill-secret.js.
965
981
  if (filteredArgs[0] === "read-cookie") {
966
982
  try {
package/serve.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  checkTailscaleServe,
25
25
  chromeDataAccessHint,
26
26
  } from "./rechrome.ts";
27
- import { SecretMasker, parseReadCookieWire, readCookieOnSession, parseReadSecretWire, readSecretOnSession, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.ts";
27
+ import { SecretMasker, parseReadCookieWire, readCookieOnSession, parseReadStorageWire, readStorageOnSession, parseReadSecretWire, readSecretOnSession, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.ts";
28
28
 
29
29
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
30
30
  const CERT_RENEW_THRESHOLD_DAYS = 7;
@@ -961,6 +961,22 @@ export async function serve() {
961
961
  }
962
962
  }
963
963
 
964
+ // read-storage: a localStorage/sessionStorage value, page-level; secretOut only.
965
+ if (filteredArgs[0] === "read-storage") {
966
+ try {
967
+ const { area, key, path, allowDomains } = parseReadStorageWire(filteredArgs);
968
+ const { host, value } = await readStorageOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, area, key, path, allowDomains });
969
+ if (!value) throw new Error(`read-storage: ${area} ${key} on ${host} is empty`);
970
+ secretMasker.add(value, Infinity);
971
+ log(`exit: 0 | read-storage ${area} ${key}${path.length ? " " + path.join(".") : ""} on ${host} (${value.length} chars, value not logged)`);
972
+ return Response.json({ status: 0, stdout: "", stderr: "", files: [], secretOut: value });
973
+ } catch (error) {
974
+ const message = secretMasker.mask(error instanceof Error ? error.message : String(error));
975
+ log(`exit: 1 | read-storage: ${message.split("\n")[0]}`);
976
+ return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
977
+ }
978
+ }
979
+
964
980
  // read-cookie: like read-secret, the value goes back only in secretOut. See fill-secret.ts.
965
981
  if (filteredArgs[0] === "read-cookie") {
966
982
  try {