rechrome 1.42.0 → 1.43.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/fill-secret.js +111 -0
- package/fill-secret.ts +92 -0
- package/listeners.js +6 -2
- package/listeners.ts +6 -3
- package/package.json +1 -1
- package/rechrome.js +33 -5
- package/rechrome.ts +33 -5
- package/serve.js +17 -1
- package/serve.ts +17 -1
package/README.md
CHANGED
|
@@ -116,6 +116,25 @@ rech read-secret e12 --allow-domain api.example.com | vault-put my-app/signing-s
|
|
|
116
116
|
Turn it on only for links you trust with every account in their profiles: the caller picks
|
|
117
117
|
`--allow-domain`, so the link can read any field it can reach, saved passwords included.
|
|
118
118
|
|
|
119
|
+
#### Copying a cookie out of the browser: `read-cookie`
|
|
120
|
+
|
|
121
|
+
Like `read-secret`, but for a cookie, including an `HttpOnly` one that `document.cookie`
|
|
122
|
+
and `eval` cannot see. For moving a session cookie (e.g. Slack's `d` / xoxd) from the
|
|
123
|
+
browser into an auth tool:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
rech read-cookie d acme.slack.com --allow-domain acme.slack.com | slack auth cookie
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
- Reads via Playwright's `context.cookies()`, so `HttpOnly` cookies are included.
|
|
130
|
+
- `--allow-domain` is required, and the cookie's own domain must match it.
|
|
131
|
+
- Same pipe-only, never-logged, masked-afterwards rules as `read-secret`.
|
|
132
|
+
- On a shared (scoped) listener it is off until the host runs
|
|
133
|
+
`rech listener set <listener> --read-cookies` (a gate separate from `--read-secrets`).
|
|
134
|
+
A session cookie is a login: turn it on only for links you trust with every account their
|
|
135
|
+
profiles are signed in to (the caller picks `--allow-domain`).
|
|
136
|
+
|
|
137
|
+
|
|
119
138
|
### 3. Where things are kept
|
|
120
139
|
|
|
121
140
|
| Where | What |
|
package/fill-secret.js
CHANGED
|
@@ -434,6 +434,111 @@ 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_COOKIE_USAGE = `Usage: rech read-cookie <name> <domain> --allow-domain <domain> | <command>
|
|
438
|
+
Writes the cookie's 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
|
+
<name> the cookie name, e.g. d
|
|
441
|
+
<domain> the cookie's domain, e.g. acme.slack.com
|
|
442
|
+
--allow-domain GLOB required: the cookie's domain must match, e.g. acme.slack.com`;
|
|
443
|
+
function validCookieName(n) {
|
|
444
|
+
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,256}$/.test(n);
|
|
445
|
+
}
|
|
446
|
+
function parseReadCookieArgs(args) {
|
|
447
|
+
const [cmd, ...rest] = args;
|
|
448
|
+
if (cmd !== "read-cookie")
|
|
449
|
+
throw new Error("Expected read-cookie");
|
|
450
|
+
const pos = [];
|
|
451
|
+
const allowDomains = [];
|
|
452
|
+
for (let i = 0;i < rest.length; i++) {
|
|
453
|
+
const a = rest[i];
|
|
454
|
+
if (a === "--allow-domain" || a.startsWith("--allow-domain=")) {
|
|
455
|
+
const v = a === "--allow-domain" ? rest[++i] : a.slice("--allow-domain=".length);
|
|
456
|
+
if (!v)
|
|
457
|
+
throw new Error("--allow-domain got an empty host");
|
|
458
|
+
for (const d of v.split(",")) {
|
|
459
|
+
domainGlobToRegExp(d.trim());
|
|
460
|
+
allowDomains.push(d.trim());
|
|
461
|
+
}
|
|
462
|
+
} else if (a.startsWith("-"))
|
|
463
|
+
throw new Error(`read-cookie: unknown option ${a}`);
|
|
464
|
+
else
|
|
465
|
+
pos.push(a);
|
|
466
|
+
}
|
|
467
|
+
if (pos.length !== 2)
|
|
468
|
+
throw new Error("read-cookie needs <name> <domain>, e.g. rech read-cookie d acme.slack.com");
|
|
469
|
+
const [name, domain] = pos;
|
|
470
|
+
if (!validCookieName(name))
|
|
471
|
+
throw new Error("read-cookie: that cookie name is not a valid token");
|
|
472
|
+
if (!/^[a-z0-9.-]+$/i.test(domain))
|
|
473
|
+
throw new Error("read-cookie: <domain> must be a plain host like acme.slack.com");
|
|
474
|
+
if (!allowDomains.length)
|
|
475
|
+
throw new Error("read-cookie needs --allow-domain: name the domain the cookie must belong to");
|
|
476
|
+
return { name, domain, allowDomains };
|
|
477
|
+
}
|
|
478
|
+
function readCookieWireArgs(req) {
|
|
479
|
+
return ["read-cookie", req.name, req.domain, ...req.allowDomains.map((d) => `--allow-domain=${d}`)];
|
|
480
|
+
}
|
|
481
|
+
function parseReadCookieWire(args) {
|
|
482
|
+
const [cmd, name, domain, ...rest] = args;
|
|
483
|
+
if (cmd !== "read-cookie" || !name || !domain || name.startsWith("-") || domain.startsWith("-"))
|
|
484
|
+
throw new Error("read-cookie needs <name> <domain>");
|
|
485
|
+
if (!validCookieName(name))
|
|
486
|
+
throw new Error("read-cookie: invalid cookie name");
|
|
487
|
+
if (!/^[a-z0-9.-]+$/i.test(domain))
|
|
488
|
+
throw new Error("read-cookie: invalid domain");
|
|
489
|
+
const allowDomains = [];
|
|
490
|
+
for (const a of rest) {
|
|
491
|
+
if (!a.startsWith("--allow-domain="))
|
|
492
|
+
throw new Error(`read-cookie: unexpected argument ${a.startsWith("-") ? a : "(positional)"}`);
|
|
493
|
+
const d = a.slice("--allow-domain=".length);
|
|
494
|
+
domainGlobToRegExp(d);
|
|
495
|
+
allowDomains.push(d);
|
|
496
|
+
}
|
|
497
|
+
if (!allowDomains.length)
|
|
498
|
+
throw new Error("read-cookie needs --allow-domain");
|
|
499
|
+
return { name, domain, allowDomains };
|
|
500
|
+
}
|
|
501
|
+
function buildReadCookieCode(name, domain, allowDomains) {
|
|
502
|
+
const patterns = allowDomains.map((d) => domainGlobToRegExp(d).source);
|
|
503
|
+
return `async page => {
|
|
504
|
+
const allowed = ${JSON.stringify(patterns)};
|
|
505
|
+
const dom = ${JSON.stringify(domain.toLowerCase())}.replace(/^\\./, "");
|
|
506
|
+
if (!allowed.some(p => new RegExp(p, "i").test(dom)))
|
|
507
|
+
throw new Error("read-cookie refused: domain " + JSON.stringify(dom) + " is not allowed by --allow-domain");
|
|
508
|
+
// All cookies, any path (a URL filter would drop /app-scoped ones). Domain-match per RFC6265:
|
|
509
|
+
// the cookie's own domain is dom, or a parent of dom (a ".slack.com" cookie serves a subdomain).
|
|
510
|
+
const cookies = await page.context().cookies();
|
|
511
|
+
const domainMatches = cd => { cd = String(cd).replace(/^\\./, "").toLowerCase(); return dom === cd || dom.endsWith("." + cd); };
|
|
512
|
+
const match = cookies.filter(c => c.name === ${JSON.stringify(name)} && domainMatches(c.domain));
|
|
513
|
+
if (!match.length) throw new Error("read-cookie: no cookie " + JSON.stringify(${JSON.stringify(name)}) + " for " + dom + " (is this profile signed in?)");
|
|
514
|
+
const values = [...new Set(match.map(c => { try { return String(c.value); } catch { return null; } }))];
|
|
515
|
+
if (values.length !== 1 || values[0] == null) throw new Error("read-cookie: " + match.length + " cookies named " + JSON.stringify(${JSON.stringify(name)}) + " for " + dom + " disagree; narrow the domain");
|
|
516
|
+
return JSON.stringify({ domain: dom, value: values[0] });
|
|
517
|
+
}`;
|
|
518
|
+
}
|
|
519
|
+
async function readCookieOnSession(opts) {
|
|
520
|
+
const { isError, text } = await runCodeOnSession({ ...opts, code: buildReadCookieCode(opts.name, opts.domain, opts.allowDomains), json: true });
|
|
521
|
+
if (isError)
|
|
522
|
+
throw new Error(text.match(/read-cookie(?: refused)?: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split(`
|
|
523
|
+
`)[0] : "read-cookie: could not read the cookie"));
|
|
524
|
+
let raw;
|
|
525
|
+
try {
|
|
526
|
+
raw = JSON.parse(text).result;
|
|
527
|
+
} catch {
|
|
528
|
+
raw = text.match(/### Result\n([^\n]*)/)?.[1];
|
|
529
|
+
}
|
|
530
|
+
let out = raw;
|
|
531
|
+
for (let i = 0;i < 2 && typeof out === "string"; i++) {
|
|
532
|
+
try {
|
|
533
|
+
out = JSON.parse(out);
|
|
534
|
+
} catch {
|
|
535
|
+
out = undefined;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (!out || typeof out.domain !== "string" || typeof out.value !== "string")
|
|
539
|
+
throw new Error("read-cookie: the browser returned no value");
|
|
540
|
+
return out;
|
|
541
|
+
}
|
|
437
542
|
var PASSWORD_FIELDS = 'input[type=password], input[autocomplete~="current-password" i], input[autocomplete~="new-password" i], input[name*="pass" i], input[id*="pass" i]';
|
|
438
543
|
var PASSWORD_VALUES_CODE = `async page => {
|
|
439
544
|
const values = [];
|
|
@@ -483,6 +588,7 @@ function maskPasswordLines(text) {
|
|
|
483
588
|
export {
|
|
484
589
|
FILL_SECRET_USAGE,
|
|
485
590
|
MIN_MASK_LENGTH,
|
|
591
|
+
READ_COOKIE_USAGE,
|
|
486
592
|
READ_SECRET_USAGE,
|
|
487
593
|
SECRET_MASK,
|
|
488
594
|
SECRET_MASK_TTL_MS,
|
|
@@ -490,6 +596,7 @@ export {
|
|
|
490
596
|
base32Decode,
|
|
491
597
|
buildFillCode,
|
|
492
598
|
buildReadCode,
|
|
599
|
+
buildReadCookieCode,
|
|
493
600
|
daemonSessionSockets,
|
|
494
601
|
domainGlobToRegExp,
|
|
495
602
|
fillSecretOnSession,
|
|
@@ -501,10 +608,14 @@ export {
|
|
|
501
608
|
parseDotenv,
|
|
502
609
|
parseFillSecretArgs,
|
|
503
610
|
parseFillSecretWire,
|
|
611
|
+
parseReadCookieArgs,
|
|
612
|
+
parseReadCookieWire,
|
|
504
613
|
parseReadSecretArgs,
|
|
505
614
|
parseReadSecretWire,
|
|
506
615
|
parseScanResult,
|
|
507
616
|
passwordValuesOnSession,
|
|
617
|
+
readCookieOnSession,
|
|
618
|
+
readCookieWireArgs,
|
|
508
619
|
readSecretOnSession,
|
|
509
620
|
readSecretSource,
|
|
510
621
|
readSecretWireArgs,
|
package/fill-secret.ts
CHANGED
|
@@ -443,6 +443,98 @@ export async function readSecretOnSession(opts: { socketRoot: string; session: s
|
|
|
443
443
|
return out;
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
+
// ---------- read-cookie: a cookie value (incl. HttpOnly) to a pipe ----------
|
|
447
|
+
//
|
|
448
|
+
// Same contract as read-secret: the value travels only in the response's `secretOut`, is never
|
|
449
|
+
// logged, is masked in later outputs, and the client writes it raw to a pipe only. For moving a
|
|
450
|
+
// session cookie (Slack's HttpOnly `d`/xoxd) from the user's own browser into an auth tool.
|
|
451
|
+
// Reads via Playwright's context.cookies(), which returns HttpOnly cookies (document.cookie can't).
|
|
452
|
+
|
|
453
|
+
export const READ_COOKIE_USAGE = `Usage: rech read-cookie <name> <domain> --allow-domain <domain> | <command>
|
|
454
|
+
Writes the cookie's 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
|
+
<name> the cookie name, e.g. d
|
|
457
|
+
<domain> the cookie's domain, e.g. acme.slack.com
|
|
458
|
+
--allow-domain GLOB required: the cookie's domain must match, e.g. acme.slack.com`;
|
|
459
|
+
|
|
460
|
+
function validCookieName(n: string): boolean {
|
|
461
|
+
// RFC6265 token; no control/separator chars. Keeps the name out of regex/URL trouble.
|
|
462
|
+
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,256}$/.test(n);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function parseReadCookieArgs(args: string[]): { name: string; domain: string; allowDomains: string[] } {
|
|
466
|
+
const [cmd, ...rest] = args;
|
|
467
|
+
if (cmd !== "read-cookie") throw new Error("Expected read-cookie");
|
|
468
|
+
const pos: string[] = [];
|
|
469
|
+
const allowDomains: string[] = [];
|
|
470
|
+
for (let i = 0; i < rest.length; i++) {
|
|
471
|
+
const a = rest[i]!;
|
|
472
|
+
if (a === "--allow-domain" || a.startsWith("--allow-domain=")) {
|
|
473
|
+
const v = a === "--allow-domain" ? rest[++i] : a.slice("--allow-domain=".length);
|
|
474
|
+
if (!v) throw new Error("--allow-domain got an empty host");
|
|
475
|
+
for (const d of v.split(",")) { domainGlobToRegExp(d.trim()); allowDomains.push(d.trim()); }
|
|
476
|
+
} else if (a.startsWith("-")) throw new Error(`read-cookie: unknown option ${a}`);
|
|
477
|
+
else pos.push(a);
|
|
478
|
+
}
|
|
479
|
+
if (pos.length !== 2) throw new Error("read-cookie needs <name> <domain>, e.g. rech read-cookie d acme.slack.com");
|
|
480
|
+
const [name, domain] = pos;
|
|
481
|
+
if (!validCookieName(name!)) throw new Error("read-cookie: that cookie name is not a valid token");
|
|
482
|
+
if (!/^[a-z0-9.-]+$/i.test(domain!)) throw new Error("read-cookie: <domain> must be a plain host like acme.slack.com");
|
|
483
|
+
if (!allowDomains.length) throw new Error("read-cookie needs --allow-domain: name the domain the cookie must belong to");
|
|
484
|
+
return { name: name!, domain: domain!, allowDomains };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function readCookieWireArgs(req: { name: string; domain: string; allowDomains: string[] }): string[] {
|
|
488
|
+
return ["read-cookie", req.name, req.domain, ...req.allowDomains.map(d => `--allow-domain=${d}`)];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function parseReadCookieWire(args: string[]): { name: string; domain: string; allowDomains: string[] } {
|
|
492
|
+
const [cmd, name, domain, ...rest] = args;
|
|
493
|
+
if (cmd !== "read-cookie" || !name || !domain || name.startsWith("-") || domain.startsWith("-")) throw new Error("read-cookie needs <name> <domain>");
|
|
494
|
+
if (!validCookieName(name)) throw new Error("read-cookie: invalid cookie name");
|
|
495
|
+
if (!/^[a-z0-9.-]+$/i.test(domain)) throw new Error("read-cookie: invalid domain");
|
|
496
|
+
const allowDomains: string[] = [];
|
|
497
|
+
for (const a of rest) {
|
|
498
|
+
if (!a.startsWith("--allow-domain=")) throw new Error(`read-cookie: unexpected argument ${a.startsWith("-") ? a : "(positional)"}`);
|
|
499
|
+
const d = a.slice("--allow-domain=".length);
|
|
500
|
+
domainGlobToRegExp(d);
|
|
501
|
+
allowDomains.push(d);
|
|
502
|
+
}
|
|
503
|
+
if (!allowDomains.length) throw new Error("read-cookie needs --allow-domain");
|
|
504
|
+
return { name, domain, allowDomains };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** run-code that reads one cookie via context.cookies(); returns {domain, value} JSON, or throws. */
|
|
508
|
+
export function buildReadCookieCode(name: string, domain: string, allowDomains: string[]): string {
|
|
509
|
+
const patterns = allowDomains.map(d => domainGlobToRegExp(d).source);
|
|
510
|
+
return `async page => {
|
|
511
|
+
const allowed = ${JSON.stringify(patterns)};
|
|
512
|
+
const dom = ${JSON.stringify(domain.toLowerCase())}.replace(/^\\./, "");
|
|
513
|
+
if (!allowed.some(p => new RegExp(p, "i").test(dom)))
|
|
514
|
+
throw new Error("read-cookie refused: domain " + JSON.stringify(dom) + " is not allowed by --allow-domain");
|
|
515
|
+
// All cookies, any path (a URL filter would drop /app-scoped ones). Domain-match per RFC6265:
|
|
516
|
+
// the cookie's own domain is dom, or a parent of dom (a ".slack.com" cookie serves a subdomain).
|
|
517
|
+
const cookies = await page.context().cookies();
|
|
518
|
+
const domainMatches = cd => { cd = String(cd).replace(/^\\./, "").toLowerCase(); return dom === cd || dom.endsWith("." + cd); };
|
|
519
|
+
const match = cookies.filter(c => c.name === ${JSON.stringify(name)} && domainMatches(c.domain));
|
|
520
|
+
if (!match.length) throw new Error("read-cookie: no cookie " + JSON.stringify(${JSON.stringify(name)}) + " for " + dom + " (is this profile signed in?)");
|
|
521
|
+
const values = [...new Set(match.map(c => { try { return String(c.value); } catch { return null; } }))];
|
|
522
|
+
if (values.length !== 1 || values[0] == null) throw new Error("read-cookie: " + match.length + " cookies named " + JSON.stringify(${JSON.stringify(name)}) + " for " + dom + " disagree; narrow the domain");
|
|
523
|
+
return JSON.stringify({ domain: dom, value: values[0] });
|
|
524
|
+
}`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export async function readCookieOnSession(opts: { socketRoot: string; session: string; cwd: string; name: string; domain: string; allowDomains: string[] }): Promise<{ domain: string; value: string }> {
|
|
528
|
+
const { isError, text } = await runCodeOnSession({ ...opts, code: buildReadCookieCode(opts.name, opts.domain, opts.allowDomains), json: true });
|
|
529
|
+
if (isError) throw new Error(text.match(/read-cookie(?: refused)?: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split("\n")[0]! : "read-cookie: could not read the cookie"));
|
|
530
|
+
let raw: unknown;
|
|
531
|
+
try { raw = JSON.parse(text).result; } catch { raw = text.match(/### Result\n([^\n]*)/)?.[1]; }
|
|
532
|
+
let out: any = raw;
|
|
533
|
+
for (let i = 0; i < 2 && typeof out === "string"; i++) { try { out = JSON.parse(out); } catch { out = undefined; } }
|
|
534
|
+
if (!out || typeof out.domain !== "string" || typeof out.value !== "string") throw new Error("read-cookie: the browser returned no value");
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
|
|
446
538
|
// ---------- password fields in snapshots ----------
|
|
447
539
|
//
|
|
448
540
|
// Snapshots print every input's value, and that includes password fields Chrome autofilled
|
package/listeners.js
CHANGED
|
@@ -78,6 +78,8 @@ function validateListeners(value) {
|
|
|
78
78
|
validateUploadPolicy(l.uploads);
|
|
79
79
|
if (l.readSecrets !== undefined && typeof l.readSecrets !== "boolean")
|
|
80
80
|
throw new Error("readSecrets must be true or false");
|
|
81
|
+
if (l.readCookies !== undefined && typeof l.readCookies !== "boolean")
|
|
82
|
+
throw new Error("readCookies must be true or false");
|
|
81
83
|
const address = listenerAddress(l);
|
|
82
84
|
if (names.has(l.name) || addresses.has(address) || keys.has(l.key))
|
|
83
85
|
throw new Error("Listener names, addresses, and credentials must be unique");
|
|
@@ -134,9 +136,11 @@ function authorizeProfileRequest(listener, body) {
|
|
|
134
136
|
throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
|
|
135
137
|
if (args[0] === "read-secret" && listener.readSecrets !== true)
|
|
136
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`);
|
|
137
|
-
if (args[0]
|
|
139
|
+
if (args[0] === "read-cookie" && listener.readCookies !== true)
|
|
140
|
+
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]))
|
|
138
142
|
throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
|
|
139
|
-
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="))))
|
|
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="))))
|
|
140
144
|
throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
|
|
141
145
|
if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank")
|
|
142
146
|
throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
|
package/listeners.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from "path";
|
|
|
6
6
|
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
7
7
|
|
|
8
8
|
// publicUrl: where a reverse proxy (Tailscale Serve, Caddy, nginx, ...) exposes this listener.
|
|
9
|
-
export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy | false; readSecrets?: boolean };
|
|
9
|
+
export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy | false; readSecrets?: boolean; readCookies?: boolean };
|
|
10
10
|
// retiredPorts: ports of removed listeners, newest last. New listeners skip them so a link
|
|
11
11
|
// saved for a removed listener gets "connection refused", not some other listener's answer.
|
|
12
12
|
export type ListenerConfig = { version: 1; listeners: Listener[]; retiredPorts?: number[] };
|
|
@@ -62,6 +62,7 @@ export function validateListeners(value: unknown): ListenerConfig {
|
|
|
62
62
|
// host never reads a path for them.
|
|
63
63
|
if (l.uploads !== undefined && l.uploads !== false) validateUploadPolicy(l.uploads);
|
|
64
64
|
if (l.readSecrets !== undefined && typeof l.readSecrets !== "boolean") throw new Error("readSecrets must be true or false");
|
|
65
|
+
if (l.readCookies !== undefined && typeof l.readCookies !== "boolean") throw new Error("readCookies must be true or false");
|
|
65
66
|
const address = listenerAddress(l);
|
|
66
67
|
if (names.has(l.name) || addresses.has(address) || keys.has(l.key)) throw new Error("Listener names, addresses, and credentials must be unique");
|
|
67
68
|
names.add(l.name); addresses.add(address); keys.add(l.key);
|
|
@@ -110,8 +111,10 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
|
|
|
110
111
|
throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
|
|
111
112
|
if (args[0] === "read-secret" && listener.readSecrets !== true)
|
|
112
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`);
|
|
113
|
-
if (args[0]
|
|
114
|
-
|
|
114
|
+
if (args[0] === "read-cookie" && listener.readCookies !== true)
|
|
115
|
+
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");
|
|
115
118
|
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");
|
|
116
119
|
// Upload is handled by the byte-only receiver; the caller cannot supply host paths.
|
|
117
120
|
if (args[0] === "upload" && (args.length !== 1 || !body.upload)) throw new Error("Upload requires stdin bytes; host paths are unavailable");
|
package/package.json
CHANGED
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_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_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";
|
|
@@ -2169,6 +2169,26 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2169
2169
|
let secret: string | undefined;
|
|
2170
2170
|
let upload: UploadPayload | undefined;
|
|
2171
2171
|
const cmdIdx = args.findIndex(a => !a.startsWith("-s="));
|
|
2172
|
+
if (args[cmdIdx] === "read-cookie") {
|
|
2173
|
+
if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_COOKIE_USAGE); process.exit(0); }
|
|
2174
|
+
let cwire: string[];
|
|
2175
|
+
try {
|
|
2176
|
+
const out = fstatSync(1);
|
|
2177
|
+
if (!out.isFIFO() && !out.isSocket()) throw new Error("read-cookie writes the cookie to stdout, which must be a pipe into the program that stores it (not a terminal or a file)");
|
|
2178
|
+
cwire = readCookieWireArgs(parseReadCookieArgs(args.slice(cmdIdx)));
|
|
2179
|
+
} catch (err) {
|
|
2180
|
+
console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${READ_COOKIE_USAGE}`);
|
|
2181
|
+
process.exit(2);
|
|
2182
|
+
}
|
|
2183
|
+
const res = await callServe(url, [...args.filter(a => a.startsWith("-s=")), ...cwire], overrideEnv, identity);
|
|
2184
|
+
if (res.status !== 0 || typeof res.secretOut !== "string") {
|
|
2185
|
+
process.stderr.write(res.stderr || (res.status === 0 ? "[rech] read-cookie: this daemon is older than the client (no value returned)\n" : ""));
|
|
2186
|
+
process.exit(res.status || 1);
|
|
2187
|
+
}
|
|
2188
|
+
await new Promise<void>((done, fail) => process.stdout.write(res.secretOut!, e => e ? fail(e) : done()));
|
|
2189
|
+
console.error(`[rech] read-cookie: wrote ${res.secretOut.length} characters to stdout`);
|
|
2190
|
+
process.exit(0);
|
|
2191
|
+
}
|
|
2172
2192
|
if (args[cmdIdx] === "read-secret") {
|
|
2173
2193
|
if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_SECRET_USAGE); process.exit(0); }
|
|
2174
2194
|
let wire: string[];
|
|
@@ -3368,7 +3388,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3368
3388
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3369
3389
|
}
|
|
3370
3390
|
|
|
3371
|
-
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean }): Promise<void> {
|
|
3391
|
+
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean }): Promise<void> {
|
|
3372
3392
|
const config = await requireListeners();
|
|
3373
3393
|
let listener = config.listeners.find(l => l.name === name);
|
|
3374
3394
|
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
@@ -3390,7 +3410,12 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
|
|
|
3390
3410
|
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-secret is always available there.`);
|
|
3391
3411
|
if (opts.readSecrets) listener.readSecrets = true; else delete listener.readSecrets;
|
|
3392
3412
|
}
|
|
3413
|
+
if (opts.readCookies !== undefined) {
|
|
3414
|
+
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-cookie is always available there.`);
|
|
3415
|
+
if (opts.readCookies) listener.readCookies = true; else delete listener.readCookies;
|
|
3416
|
+
}
|
|
3393
3417
|
await writeListeners(config);
|
|
3418
|
+
if (opts.readCookies !== undefined) console.log(opts.readCookies ? `${name}: read-cookie on. Anyone with this link can copy any cookie (HttpOnly session cookies included) of any site its profiles are signed in to, which is enough to take over those sessions; --allow-domain only checks the domain the caller names. Each read is logged without the value` : `${name}: read-cookie off`);
|
|
3394
3419
|
if (opts.readSecrets !== undefined) console.log(opts.readSecrets ? `${name}: read-secret on. Anyone with this link can copy the value of any field (saved passwords included) on any site its profiles have open; --allow-domain only checks the site the caller names. Each read is logged without the value` : `${name}: read-secret off`);
|
|
3395
3420
|
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3396
3421
|
if (opts.hostUploadDirs?.length || opts.noHostUploads) {
|
|
@@ -4475,7 +4500,7 @@ export type RechHandlers = {
|
|
|
4475
4500
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
4476
4501
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
4477
4502
|
rotateKey(name: string): Promise<void>;
|
|
4478
|
-
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean }): Promise<void>;
|
|
4503
|
+
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean }): Promise<void>;
|
|
4479
4504
|
setup(opts: SetupOptions): Promise<void>;
|
|
4480
4505
|
tray(action?: string): Promise<void>;
|
|
4481
4506
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -4510,6 +4535,8 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
|
|
|
4510
4535
|
type a password / TOTP code without it reaching argv, logs or output
|
|
4511
4536
|
rech read-secret <ref> --allow-domain <host> | <vault command>
|
|
4512
4537
|
copy a field's value (e.g. an API secret) into a pipe, never shown or logged
|
|
4538
|
+
rech read-cookie <name> <domain> --allow-domain <domain> | <command>
|
|
4539
|
+
copy a cookie (incl. HttpOnly) into a pipe, never shown or logged
|
|
4513
4540
|
rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
|
|
4514
4541
|
rech --version rechrome's version
|
|
4515
4542
|
|
|
@@ -4626,9 +4653,10 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4626
4653
|
.option("uploads", { type: "boolean", describe: "Uploads of images the link holder sends (rech upload) are on by default; --no-uploads turns them off" })
|
|
4627
4654
|
.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" })
|
|
4628
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" })
|
|
4656
|
+
.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" })
|
|
4629
4657
|
.option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
|
|
4630
|
-
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, or --host-upload-dir/--no-host-uploads"),
|
|
4631
|
-
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}) }))
|
|
4658
|
+
.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"),
|
|
4659
|
+
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}), ...(a.readCookies !== undefined ? { readCookies: a.readCookies } : {}) }))
|
|
4632
4660
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4633
4661
|
a => handlers.listenerPort(a.name))
|
|
4634
4662
|
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
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_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_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";
|
|
@@ -2169,6 +2169,26 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2169
2169
|
let secret: string | undefined;
|
|
2170
2170
|
let upload: UploadPayload | undefined;
|
|
2171
2171
|
const cmdIdx = args.findIndex(a => !a.startsWith("-s="));
|
|
2172
|
+
if (args[cmdIdx] === "read-cookie") {
|
|
2173
|
+
if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_COOKIE_USAGE); process.exit(0); }
|
|
2174
|
+
let cwire: string[];
|
|
2175
|
+
try {
|
|
2176
|
+
const out = fstatSync(1);
|
|
2177
|
+
if (!out.isFIFO() && !out.isSocket()) throw new Error("read-cookie writes the cookie to stdout, which must be a pipe into the program that stores it (not a terminal or a file)");
|
|
2178
|
+
cwire = readCookieWireArgs(parseReadCookieArgs(args.slice(cmdIdx)));
|
|
2179
|
+
} catch (err) {
|
|
2180
|
+
console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${READ_COOKIE_USAGE}`);
|
|
2181
|
+
process.exit(2);
|
|
2182
|
+
}
|
|
2183
|
+
const res = await callServe(url, [...args.filter(a => a.startsWith("-s=")), ...cwire], overrideEnv, identity);
|
|
2184
|
+
if (res.status !== 0 || typeof res.secretOut !== "string") {
|
|
2185
|
+
process.stderr.write(res.stderr || (res.status === 0 ? "[rech] read-cookie: this daemon is older than the client (no value returned)\n" : ""));
|
|
2186
|
+
process.exit(res.status || 1);
|
|
2187
|
+
}
|
|
2188
|
+
await new Promise<void>((done, fail) => process.stdout.write(res.secretOut!, e => e ? fail(e) : done()));
|
|
2189
|
+
console.error(`[rech] read-cookie: wrote ${res.secretOut.length} characters to stdout`);
|
|
2190
|
+
process.exit(0);
|
|
2191
|
+
}
|
|
2172
2192
|
if (args[cmdIdx] === "read-secret") {
|
|
2173
2193
|
if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_SECRET_USAGE); process.exit(0); }
|
|
2174
2194
|
let wire: string[];
|
|
@@ -3368,7 +3388,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3368
3388
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3369
3389
|
}
|
|
3370
3390
|
|
|
3371
|
-
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean }): Promise<void> {
|
|
3391
|
+
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean }): Promise<void> {
|
|
3372
3392
|
const config = await requireListeners();
|
|
3373
3393
|
let listener = config.listeners.find(l => l.name === name);
|
|
3374
3394
|
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
@@ -3390,7 +3410,12 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
|
|
|
3390
3410
|
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-secret is always available there.`);
|
|
3391
3411
|
if (opts.readSecrets) listener.readSecrets = true; else delete listener.readSecrets;
|
|
3392
3412
|
}
|
|
3413
|
+
if (opts.readCookies !== undefined) {
|
|
3414
|
+
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-cookie is always available there.`);
|
|
3415
|
+
if (opts.readCookies) listener.readCookies = true; else delete listener.readCookies;
|
|
3416
|
+
}
|
|
3393
3417
|
await writeListeners(config);
|
|
3418
|
+
if (opts.readCookies !== undefined) console.log(opts.readCookies ? `${name}: read-cookie on. Anyone with this link can copy any cookie (HttpOnly session cookies included) of any site its profiles are signed in to, which is enough to take over those sessions; --allow-domain only checks the domain the caller names. Each read is logged without the value` : `${name}: read-cookie off`);
|
|
3394
3419
|
if (opts.readSecrets !== undefined) console.log(opts.readSecrets ? `${name}: read-secret on. Anyone with this link can copy the value of any field (saved passwords included) on any site its profiles have open; --allow-domain only checks the site the caller names. Each read is logged without the value` : `${name}: read-secret off`);
|
|
3395
3420
|
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3396
3421
|
if (opts.hostUploadDirs?.length || opts.noHostUploads) {
|
|
@@ -4475,7 +4500,7 @@ export type RechHandlers = {
|
|
|
4475
4500
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
4476
4501
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
4477
4502
|
rotateKey(name: string): Promise<void>;
|
|
4478
|
-
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean }): Promise<void>;
|
|
4503
|
+
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean }): Promise<void>;
|
|
4479
4504
|
setup(opts: SetupOptions): Promise<void>;
|
|
4480
4505
|
tray(action?: string): Promise<void>;
|
|
4481
4506
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -4510,6 +4535,8 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
|
|
|
4510
4535
|
type a password / TOTP code without it reaching argv, logs or output
|
|
4511
4536
|
rech read-secret <ref> --allow-domain <host> | <vault command>
|
|
4512
4537
|
copy a field's value (e.g. an API secret) into a pipe, never shown or logged
|
|
4538
|
+
rech read-cookie <name> <domain> --allow-domain <domain> | <command>
|
|
4539
|
+
copy a cookie (incl. HttpOnly) into a pipe, never shown or logged
|
|
4513
4540
|
rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
|
|
4514
4541
|
rech --version rechrome's version
|
|
4515
4542
|
|
|
@@ -4626,9 +4653,10 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4626
4653
|
.option("uploads", { type: "boolean", describe: "Uploads of images the link holder sends (rech upload) are on by default; --no-uploads turns them off" })
|
|
4627
4654
|
.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" })
|
|
4628
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" })
|
|
4656
|
+
.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" })
|
|
4629
4657
|
.option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
|
|
4630
|
-
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, or --host-upload-dir/--no-host-uploads"),
|
|
4631
|
-
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}) }))
|
|
4658
|
+
.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"),
|
|
4659
|
+
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}), ...(a.readCookies !== undefined ? { readCookies: a.readCookies } : {}) }))
|
|
4632
4660
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4633
4661
|
a => handlers.listenerPort(a.name))
|
|
4634
4662
|
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
package/serve.js
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
checkTailscaleServe,
|
|
25
25
|
chromeDataAccessHint,
|
|
26
26
|
} from "./rechrome.js";
|
|
27
|
-
import { SecretMasker, parseReadSecretWire, readSecretOnSession, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.js";
|
|
27
|
+
import { SecretMasker, parseReadCookieWire, readCookieOnSession, 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-cookie: like read-secret, the value goes back only in secretOut. See fill-secret.js.
|
|
965
|
+
if (filteredArgs[0] === "read-cookie") {
|
|
966
|
+
try {
|
|
967
|
+
const { name, domain, allowDomains } = parseReadCookieWire(filteredArgs);
|
|
968
|
+
const { domain: dom, value } = await readCookieOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, name, domain, allowDomains });
|
|
969
|
+
if (!value) throw new Error(`read-cookie: ${name} for ${dom} is empty`);
|
|
970
|
+
secretMasker.add(value, Infinity);
|
|
971
|
+
log(`exit: 0 | read-cookie ${name} for ${dom} (${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-cookie: ${message.split("\n")[0]}`);
|
|
976
|
+
return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
964
980
|
// For open commands, default to about:blank to avoid leaving connect.html visible
|
|
965
981
|
const isOpenCmd = filteredArgs[0] === "open";
|
|
966
982
|
const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
|
package/serve.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
checkTailscaleServe,
|
|
25
25
|
chromeDataAccessHint,
|
|
26
26
|
} from "./rechrome.ts";
|
|
27
|
-
import { SecretMasker, parseReadSecretWire, readSecretOnSession, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.ts";
|
|
27
|
+
import { SecretMasker, parseReadCookieWire, readCookieOnSession, 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-cookie: like read-secret, the value goes back only in secretOut. See fill-secret.ts.
|
|
965
|
+
if (filteredArgs[0] === "read-cookie") {
|
|
966
|
+
try {
|
|
967
|
+
const { name, domain, allowDomains } = parseReadCookieWire(filteredArgs);
|
|
968
|
+
const { domain: dom, value } = await readCookieOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, name, domain, allowDomains });
|
|
969
|
+
if (!value) throw new Error(`read-cookie: ${name} for ${dom} is empty`);
|
|
970
|
+
secretMasker.add(value, Infinity);
|
|
971
|
+
log(`exit: 0 | read-cookie ${name} for ${dom} (${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-cookie: ${message.split("\n")[0]}`);
|
|
976
|
+
return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
964
980
|
// For open commands, default to about:blank to avoid leaving connect.html visible
|
|
965
981
|
const isOpenCmd = filteredArgs[0] === "open";
|
|
966
982
|
const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
|