rechrome 1.41.0 → 1.43.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 +38 -0
- package/fill-secret.js +217 -0
- package/fill-secret.ts +185 -0
- package/listeners.js +10 -2
- package/listeners.ts +9 -3
- package/package.json +1 -1
- package/rechrome.js +66 -7
- package/rechrome.ts +66 -7
- package/serve.js +34 -1
- package/serve.ts +34 -1
package/README.md
CHANGED
|
@@ -97,6 +97,44 @@ browser writes it, not before (downloads wait for the scrub). The check reads th
|
|
|
97
97
|
the snapshot, so a page script that clears or replaces a neutrally-labelled password field in
|
|
98
98
|
between can slip past; closing that needs redaction inside playwright's snapshot code. Not yet supported on a Windows daemon.
|
|
99
99
|
|
|
100
|
+
#### Copying a secret out of a page: `read-secret`
|
|
101
|
+
|
|
102
|
+
The reverse of `fill-secret`, for moving a secret that a page shows (an app's signing
|
|
103
|
+
secret, an API key) into a vault without it appearing anywhere:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
rech read-secret e12 --allow-domain api.example.com | vault-put my-app/signing-secret
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- `--allow-domain` is required. The field's own page host must match it.
|
|
110
|
+
- The value goes back in its own response field. It is never in a log line, a file or
|
|
111
|
+
another command's output, and is masked in every later output.
|
|
112
|
+
- stdout must be a pipe: a terminal or a file redirect is refused before the host is asked.
|
|
113
|
+
- A label ref means its input, as with `fill`. A non-field element gives its text.
|
|
114
|
+
- On a shared (scoped) listener it is off until the host runs
|
|
115
|
+
`rech listener set <listener> --read-secrets`. Each read is logged without the value.
|
|
116
|
+
Turn it on only for links you trust with every account in their profiles: the caller picks
|
|
117
|
+
`--allow-domain`, so the link can read any field it can reach, saved passwords included.
|
|
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
|
+
|
|
100
138
|
### 3. Where things are kept
|
|
101
139
|
|
|
102
140
|
| Where | What |
|
package/fill-secret.js
CHANGED
|
@@ -332,6 +332,211 @@ async function runCodeOnSession(opts) {
|
|
|
332
332
|
function fillSecretOnSession(opts) {
|
|
333
333
|
return runCodeOnSession({ ...opts, code: buildFillCode(opts.ref, opts.value, opts.allowDomains, opts.submit) });
|
|
334
334
|
}
|
|
335
|
+
var READ_SECRET_USAGE = `Usage: rech read-secret <ref|selector> --allow-domain <glob> [--allow-domain <glob>]... | <vault command>
|
|
336
|
+
Writes the field's current value, raw and without a newline, to stdout, which must be a pipe
|
|
337
|
+
into the program that stores it (never a terminal or a file). The value is never logged and
|
|
338
|
+
is masked in later outputs.
|
|
339
|
+
--allow-domain GLOB required: refuse unless the field's page host matches, e.g. app.slack.com`;
|
|
340
|
+
function parseReadSecretArgs(args) {
|
|
341
|
+
const [cmd, ...rest] = args;
|
|
342
|
+
if (cmd !== "read-secret")
|
|
343
|
+
throw new Error("Expected read-secret");
|
|
344
|
+
let ref;
|
|
345
|
+
const allowDomains = [];
|
|
346
|
+
for (let i = 0;i < rest.length; i++) {
|
|
347
|
+
const a = rest[i];
|
|
348
|
+
if (a === "--allow-domain" || a.startsWith("--allow-domain=")) {
|
|
349
|
+
const v = a === "--allow-domain" ? rest[++i] : a.slice("--allow-domain=".length);
|
|
350
|
+
if (!v)
|
|
351
|
+
throw new Error("--allow-domain got an empty host");
|
|
352
|
+
for (const d of v.split(",")) {
|
|
353
|
+
domainGlobToRegExp(d.trim());
|
|
354
|
+
allowDomains.push(d.trim());
|
|
355
|
+
}
|
|
356
|
+
} else if (a.startsWith("-"))
|
|
357
|
+
throw new Error(`read-secret: unknown option ${a}`);
|
|
358
|
+
else if (ref === undefined)
|
|
359
|
+
ref = a;
|
|
360
|
+
else
|
|
361
|
+
throw new Error("read-secret takes one target");
|
|
362
|
+
}
|
|
363
|
+
if (!ref)
|
|
364
|
+
throw new Error("read-secret needs a target ref (from rech snapshot) or a selector");
|
|
365
|
+
if (!allowDomains.length)
|
|
366
|
+
throw new Error("read-secret needs --allow-domain: name the site the secret must come from");
|
|
367
|
+
return { ref, allowDomains };
|
|
368
|
+
}
|
|
369
|
+
function readSecretWireArgs(req) {
|
|
370
|
+
return ["read-secret", req.ref, ...req.allowDomains.map((d) => `--allow-domain=${d}`)];
|
|
371
|
+
}
|
|
372
|
+
function parseReadSecretWire(args) {
|
|
373
|
+
const [cmd, ref, ...rest] = args;
|
|
374
|
+
if (cmd !== "read-secret" || !ref || ref.startsWith("-"))
|
|
375
|
+
throw new Error("read-secret needs a target ref");
|
|
376
|
+
const allowDomains = [];
|
|
377
|
+
for (const a of rest) {
|
|
378
|
+
if (!a.startsWith("--allow-domain="))
|
|
379
|
+
throw new Error(`read-secret: unexpected argument ${a.startsWith("-") ? a : "(positional)"}`);
|
|
380
|
+
const d = a.slice("--allow-domain=".length);
|
|
381
|
+
domainGlobToRegExp(d);
|
|
382
|
+
allowDomains.push(d);
|
|
383
|
+
}
|
|
384
|
+
if (!allowDomains.length)
|
|
385
|
+
throw new Error("read-secret needs --allow-domain");
|
|
386
|
+
return { ref, allowDomains };
|
|
387
|
+
}
|
|
388
|
+
function buildReadCode(ref, allowDomains) {
|
|
389
|
+
const patterns = allowDomains.map((d) => domainGlobToRegExp(d).source);
|
|
390
|
+
const locator = /^(f\d+)?e\d+$/.test(ref) ? `page.locator(${JSON.stringify(`aria-ref=${ref}`)})` : `page.locator(${JSON.stringify(ref)})`;
|
|
391
|
+
return `async page => {
|
|
392
|
+
const el = await ${locator}.elementHandle({ timeout: 10000 });
|
|
393
|
+
try {
|
|
394
|
+
const host = String(await el.evaluate(e => e.ownerDocument.location.hostname)).toLowerCase();
|
|
395
|
+
const allowed = ${JSON.stringify(patterns)};
|
|
396
|
+
if (!allowed.some(p => new RegExp(p, "i").test(host)))
|
|
397
|
+
throw new Error("read-secret refused: the field's page host " + JSON.stringify(host) + " is not allowed by --allow-domain");
|
|
398
|
+
// Like fill: a label ref means its control. A field reads as its value; anything else (a
|
|
399
|
+
// secret shown as text after "Show") as its text.
|
|
400
|
+
// A page getter can throw an error that quotes the value: report a fixed message instead.
|
|
401
|
+
let value;
|
|
402
|
+
try {
|
|
403
|
+
value = await el.evaluate(e => {
|
|
404
|
+
const t = e.tagName === "LABEL" && e.control ? e.control : e;
|
|
405
|
+
return t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" ? String(t.value) : String(t.textContent ?? "").trim();
|
|
406
|
+
});
|
|
407
|
+
} catch { throw new Error("read-secret: could not read the field"); }
|
|
408
|
+
return JSON.stringify({ host, value });
|
|
409
|
+
} finally {
|
|
410
|
+
await el.dispose().catch(() => {});
|
|
411
|
+
}
|
|
412
|
+
}`;
|
|
413
|
+
}
|
|
414
|
+
async function readSecretOnSession(opts) {
|
|
415
|
+
const { isError, text } = await runCodeOnSession({ ...opts, code: buildReadCode(opts.ref, opts.allowDomains), json: true });
|
|
416
|
+
if (isError)
|
|
417
|
+
throw new Error(text.match(/read-secret refused: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split(`
|
|
418
|
+
`)[0] : "read-secret: could not read the field (is the ref from a fresh rech snapshot?)"));
|
|
419
|
+
let raw;
|
|
420
|
+
try {
|
|
421
|
+
raw = JSON.parse(text).result;
|
|
422
|
+
} catch {
|
|
423
|
+
raw = text.match(/### Result\n([^\n]*)/)?.[1];
|
|
424
|
+
}
|
|
425
|
+
let out = raw;
|
|
426
|
+
for (let i = 0;i < 2 && typeof out === "string"; i++) {
|
|
427
|
+
try {
|
|
428
|
+
out = JSON.parse(out);
|
|
429
|
+
} catch {
|
|
430
|
+
out = undefined;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (!out || typeof out.host !== "string" || typeof out.value !== "string")
|
|
434
|
+
throw new Error("read-secret: the page returned no value");
|
|
435
|
+
return out;
|
|
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
|
+
const url = "https://" + dom + "/";
|
|
509
|
+
const cookies = await page.context().cookies([url]);
|
|
510
|
+
const match = cookies.filter(c => c.name === ${JSON.stringify(name)} && dom === String(c.domain).replace(/^\\./, "").toLowerCase());
|
|
511
|
+
if (!match.length) throw new Error("read-cookie: no cookie " + JSON.stringify(${JSON.stringify(name)}) + " for " + dom + " (is this profile signed in?)");
|
|
512
|
+
let value;
|
|
513
|
+
try { value = String(match[0].value); } catch { throw new Error("read-cookie: could not read the cookie"); }
|
|
514
|
+
return JSON.stringify({ domain: dom, value });
|
|
515
|
+
}`;
|
|
516
|
+
}
|
|
517
|
+
async function readCookieOnSession(opts) {
|
|
518
|
+
const { isError, text } = await runCodeOnSession({ ...opts, code: buildReadCookieCode(opts.name, opts.domain, opts.allowDomains), json: true });
|
|
519
|
+
if (isError)
|
|
520
|
+
throw new Error(text.match(/read-cookie(?: refused)?: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split(`
|
|
521
|
+
`)[0] : "read-cookie: could not read the cookie"));
|
|
522
|
+
let raw;
|
|
523
|
+
try {
|
|
524
|
+
raw = JSON.parse(text).result;
|
|
525
|
+
} catch {
|
|
526
|
+
raw = text.match(/### Result\n([^\n]*)/)?.[1];
|
|
527
|
+
}
|
|
528
|
+
let out = raw;
|
|
529
|
+
for (let i = 0;i < 2 && typeof out === "string"; i++) {
|
|
530
|
+
try {
|
|
531
|
+
out = JSON.parse(out);
|
|
532
|
+
} catch {
|
|
533
|
+
out = undefined;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (!out || typeof out.domain !== "string" || typeof out.value !== "string")
|
|
537
|
+
throw new Error("read-cookie: the browser returned no value");
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
335
540
|
var PASSWORD_FIELDS = 'input[type=password], input[autocomplete~="current-password" i], input[autocomplete~="new-password" i], input[name*="pass" i], input[id*="pass" i]';
|
|
336
541
|
var PASSWORD_VALUES_CODE = `async page => {
|
|
337
542
|
const values = [];
|
|
@@ -381,11 +586,15 @@ function maskPasswordLines(text) {
|
|
|
381
586
|
export {
|
|
382
587
|
FILL_SECRET_USAGE,
|
|
383
588
|
MIN_MASK_LENGTH,
|
|
589
|
+
READ_COOKIE_USAGE,
|
|
590
|
+
READ_SECRET_USAGE,
|
|
384
591
|
SECRET_MASK,
|
|
385
592
|
SECRET_MASK_TTL_MS,
|
|
386
593
|
SecretMasker,
|
|
387
594
|
base32Decode,
|
|
388
595
|
buildFillCode,
|
|
596
|
+
buildReadCode,
|
|
597
|
+
buildReadCookieCode,
|
|
389
598
|
daemonSessionSockets,
|
|
390
599
|
domainGlobToRegExp,
|
|
391
600
|
fillSecretOnSession,
|
|
@@ -397,9 +606,17 @@ export {
|
|
|
397
606
|
parseDotenv,
|
|
398
607
|
parseFillSecretArgs,
|
|
399
608
|
parseFillSecretWire,
|
|
609
|
+
parseReadCookieArgs,
|
|
610
|
+
parseReadCookieWire,
|
|
611
|
+
parseReadSecretArgs,
|
|
612
|
+
parseReadSecretWire,
|
|
400
613
|
parseScanResult,
|
|
401
614
|
passwordValuesOnSession,
|
|
615
|
+
readCookieOnSession,
|
|
616
|
+
readCookieWireArgs,
|
|
617
|
+
readSecretOnSession,
|
|
402
618
|
readSecretSource,
|
|
619
|
+
readSecretWireArgs,
|
|
403
620
|
runCodeOnSession,
|
|
404
621
|
sessionSocketCandidates,
|
|
405
622
|
socketRequest,
|
package/fill-secret.ts
CHANGED
|
@@ -348,6 +348,191 @@ export function fillSecretOnSession(opts: { socketRoot: string; session: string;
|
|
|
348
348
|
return runCodeOnSession({ ...opts, code: buildFillCode(opts.ref, opts.value, opts.allowDomains, opts.submit) });
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
+
// ---------- read-secret: a field's value to a pipe ----------
|
|
352
|
+
//
|
|
353
|
+
// The reverse of fill-secret, for moving a secret a page shows (an API signing secret in a
|
|
354
|
+
// password-type field) into a vault without it appearing anywhere. The page host must match a
|
|
355
|
+
// required --allow-domain; the value travels only in the response's `secretOut` field, is never
|
|
356
|
+
// logged, is masked in every later output, and the client writes it raw to a pipe only, never
|
|
357
|
+
// to a terminal. On a profile-scoped listener it is refused unless the host turned it on.
|
|
358
|
+
|
|
359
|
+
export const READ_SECRET_USAGE = `Usage: rech read-secret <ref|selector> --allow-domain <glob> [--allow-domain <glob>]... | <vault command>
|
|
360
|
+
Writes the field's current value, raw and without a newline, to stdout, which must be a pipe
|
|
361
|
+
into the program that stores it (never a terminal or a file). The value is never logged and
|
|
362
|
+
is masked in later outputs.
|
|
363
|
+
--allow-domain GLOB required: refuse unless the field's page host matches, e.g. app.slack.com`;
|
|
364
|
+
|
|
365
|
+
/** Client args → the wire args (same shape); --allow-domain is required. */
|
|
366
|
+
export function parseReadSecretArgs(args: string[]): { ref: string; allowDomains: string[] } {
|
|
367
|
+
const [cmd, ...rest] = args;
|
|
368
|
+
if (cmd !== "read-secret") throw new Error("Expected read-secret");
|
|
369
|
+
let ref: string | undefined;
|
|
370
|
+
const allowDomains: string[] = [];
|
|
371
|
+
for (let i = 0; i < rest.length; i++) {
|
|
372
|
+
const a = rest[i]!;
|
|
373
|
+
if (a === "--allow-domain" || a.startsWith("--allow-domain=")) {
|
|
374
|
+
const v = a === "--allow-domain" ? rest[++i] : a.slice("--allow-domain=".length);
|
|
375
|
+
if (!v) throw new Error("--allow-domain got an empty host");
|
|
376
|
+
for (const d of v.split(",")) { domainGlobToRegExp(d.trim()); allowDomains.push(d.trim()); }
|
|
377
|
+
} else if (a.startsWith("-")) throw new Error(`read-secret: unknown option ${a}`);
|
|
378
|
+
else if (ref === undefined) ref = a;
|
|
379
|
+
else throw new Error("read-secret takes one target");
|
|
380
|
+
}
|
|
381
|
+
if (!ref) throw new Error("read-secret needs a target ref (from rech snapshot) or a selector");
|
|
382
|
+
if (!allowDomains.length) throw new Error("read-secret needs --allow-domain: name the site the secret must come from");
|
|
383
|
+
return { ref, allowDomains };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function readSecretWireArgs(req: { ref: string; allowDomains: string[] }): string[] {
|
|
387
|
+
return ["read-secret", req.ref, ...req.allowDomains.map(d => `--allow-domain=${d}`)];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Parse what the daemon received; exactly the shape readSecretWireArgs sends. */
|
|
391
|
+
export function parseReadSecretWire(args: string[]): { ref: string; allowDomains: string[] } {
|
|
392
|
+
const [cmd, ref, ...rest] = args;
|
|
393
|
+
if (cmd !== "read-secret" || !ref || ref.startsWith("-")) throw new Error("read-secret needs a target ref");
|
|
394
|
+
const allowDomains: string[] = [];
|
|
395
|
+
for (const a of rest) {
|
|
396
|
+
if (!a.startsWith("--allow-domain=")) throw new Error(`read-secret: unexpected argument ${a.startsWith("-") ? a : "(positional)"}`);
|
|
397
|
+
const d = a.slice("--allow-domain=".length);
|
|
398
|
+
domainGlobToRegExp(d);
|
|
399
|
+
allowDomains.push(d);
|
|
400
|
+
}
|
|
401
|
+
if (!allowDomains.length) throw new Error("read-secret needs --allow-domain");
|
|
402
|
+
return { ref, allowDomains };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** run-code that checks the field's own document host, then returns {host, value} as JSON. */
|
|
406
|
+
export function buildReadCode(ref: string, allowDomains: string[]): string {
|
|
407
|
+
const patterns = allowDomains.map(d => domainGlobToRegExp(d).source);
|
|
408
|
+
const locator = /^(f\d+)?e\d+$/.test(ref) ? `page.locator(${JSON.stringify(`aria-ref=${ref}`)})` : `page.locator(${JSON.stringify(ref)})`;
|
|
409
|
+
return `async page => {
|
|
410
|
+
const el = await ${locator}.elementHandle({ timeout: 10000 });
|
|
411
|
+
try {
|
|
412
|
+
const host = String(await el.evaluate(e => e.ownerDocument.location.hostname)).toLowerCase();
|
|
413
|
+
const allowed = ${JSON.stringify(patterns)};
|
|
414
|
+
if (!allowed.some(p => new RegExp(p, "i").test(host)))
|
|
415
|
+
throw new Error("read-secret refused: the field's page host " + JSON.stringify(host) + " is not allowed by --allow-domain");
|
|
416
|
+
// Like fill: a label ref means its control. A field reads as its value; anything else (a
|
|
417
|
+
// secret shown as text after "Show") as its text.
|
|
418
|
+
// A page getter can throw an error that quotes the value: report a fixed message instead.
|
|
419
|
+
let value;
|
|
420
|
+
try {
|
|
421
|
+
value = await el.evaluate(e => {
|
|
422
|
+
const t = e.tagName === "LABEL" && e.control ? e.control : e;
|
|
423
|
+
return t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" ? String(t.value) : String(t.textContent ?? "").trim();
|
|
424
|
+
});
|
|
425
|
+
} catch { throw new Error("read-secret: could not read the field"); }
|
|
426
|
+
return JSON.stringify({ host, value });
|
|
427
|
+
} finally {
|
|
428
|
+
await el.dispose().catch(() => {});
|
|
429
|
+
}
|
|
430
|
+
}`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Read the field on the session's live browser. The returned value must never be logged. */
|
|
434
|
+
export async function readSecretOnSession(opts: { socketRoot: string; session: string; cwd: string; ref: string; allowDomains: string[] }): Promise<{ host: string; value: string }> {
|
|
435
|
+
const { isError, text } = await runCodeOnSession({ ...opts, code: buildReadCode(opts.ref, opts.allowDomains), json: true });
|
|
436
|
+
// Pass on only the messages this code writes itself (they hold the host, never the value).
|
|
437
|
+
if (isError) throw new Error(text.match(/read-secret refused: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split("\n")[0]! : "read-secret: could not read the field (is the ref from a fresh rech snapshot?)"));
|
|
438
|
+
let raw: unknown;
|
|
439
|
+
try { raw = JSON.parse(text).result; } catch { raw = text.match(/### Result\n([^\n]*)/)?.[1]; }
|
|
440
|
+
let out: any = raw;
|
|
441
|
+
for (let i = 0; i < 2 && typeof out === "string"; i++) { try { out = JSON.parse(out); } catch { out = undefined; } }
|
|
442
|
+
if (!out || typeof out.host !== "string" || typeof out.value !== "string") throw new Error("read-secret: the page returned no value");
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
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
|
+
const url = "https://" + dom + "/";
|
|
516
|
+
const cookies = await page.context().cookies([url]);
|
|
517
|
+
const match = cookies.filter(c => c.name === ${JSON.stringify(name)} && dom === String(c.domain).replace(/^\\./, "").toLowerCase());
|
|
518
|
+
if (!match.length) throw new Error("read-cookie: no cookie " + JSON.stringify(${JSON.stringify(name)}) + " for " + dom + " (is this profile signed in?)");
|
|
519
|
+
let value;
|
|
520
|
+
try { value = String(match[0].value); } catch { throw new Error("read-cookie: could not read the cookie"); }
|
|
521
|
+
return JSON.stringify({ domain: dom, value });
|
|
522
|
+
}`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export async function readCookieOnSession(opts: { socketRoot: string; session: string; cwd: string; name: string; domain: string; allowDomains: string[] }): Promise<{ domain: string; value: string }> {
|
|
526
|
+
const { isError, text } = await runCodeOnSession({ ...opts, code: buildReadCookieCode(opts.name, opts.domain, opts.allowDomains), json: true });
|
|
527
|
+
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"));
|
|
528
|
+
let raw: unknown;
|
|
529
|
+
try { raw = JSON.parse(text).result; } catch { raw = text.match(/### Result\n([^\n]*)/)?.[1]; }
|
|
530
|
+
let out: any = raw;
|
|
531
|
+
for (let i = 0; i < 2 && typeof out === "string"; i++) { try { out = JSON.parse(out); } catch { out = undefined; } }
|
|
532
|
+
if (!out || typeof out.domain !== "string" || typeof out.value !== "string") throw new Error("read-cookie: the browser returned no value");
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
535
|
+
|
|
351
536
|
// ---------- password fields in snapshots ----------
|
|
352
537
|
//
|
|
353
538
|
// Snapshots print every input's value, and that includes password fields Chrome autofilled
|
package/listeners.js
CHANGED
|
@@ -76,6 +76,10 @@ function validateListeners(value) {
|
|
|
76
76
|
}
|
|
77
77
|
if (l.uploads !== undefined && l.uploads !== false)
|
|
78
78
|
validateUploadPolicy(l.uploads);
|
|
79
|
+
if (l.readSecrets !== undefined && typeof l.readSecrets !== "boolean")
|
|
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");
|
|
79
83
|
const address = listenerAddress(l);
|
|
80
84
|
if (names.has(l.name) || addresses.has(address) || keys.has(l.key))
|
|
81
85
|
throw new Error("Listener names, addresses, and credentials must be unique");
|
|
@@ -130,9 +134,13 @@ function authorizeProfileRequest(listener, body) {
|
|
|
130
134
|
const args = body.args.filter((a) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
|
|
131
135
|
if (args[0] === "upload" && listener.uploads === false)
|
|
132
136
|
throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
|
|
133
|
-
if (args[0]
|
|
137
|
+
if (args[0] === "read-secret" && listener.readSecrets !== true)
|
|
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-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]))
|
|
134
142
|
throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
|
|
135
|
-
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=")))))
|
|
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="))))
|
|
136
144
|
throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
|
|
137
145
|
if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank")
|
|
138
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 };
|
|
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[] };
|
|
@@ -61,6 +61,8 @@ export function validateListeners(value: unknown): ListenerConfig {
|
|
|
61
61
|
// Uploads are on unless set to false: the client sends file bytes it already has, and the
|
|
62
62
|
// host never reads a path for them.
|
|
63
63
|
if (l.uploads !== undefined && l.uploads !== false) validateUploadPolicy(l.uploads);
|
|
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");
|
|
64
66
|
const address = listenerAddress(l);
|
|
65
67
|
if (names.has(l.name) || addresses.has(address) || keys.has(l.key)) throw new Error("Listener names, addresses, and credentials must be unique");
|
|
66
68
|
names.add(l.name); addresses.add(address); keys.add(l.key);
|
|
@@ -107,8 +109,12 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
|
|
|
107
109
|
const args = body.args.filter((a: string) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
|
|
108
110
|
if (args[0] === "upload" && listener.uploads === false)
|
|
109
111
|
throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
|
|
110
|
-
if (args[0]
|
|
111
|
-
|
|
112
|
+
if (args[0] === "read-secret" && listener.readSecrets !== true)
|
|
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-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");
|
|
112
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");
|
|
113
119
|
// Upload is handled by the byte-only receiver; the caller cannot supply host paths.
|
|
114
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,9 +6,9 @@ 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 { 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
|
-
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
|
|
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";
|
|
13
13
|
import { isIPv4 } from "net";
|
|
14
14
|
import { join, basename, dirname, resolve } from "path";
|
|
@@ -1932,7 +1932,7 @@ async function callServe(
|
|
|
1932
1932
|
throwOnFailure = false,
|
|
1933
1933
|
upload?: UploadPayload,
|
|
1934
1934
|
secret?: string,
|
|
1935
|
-
): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
|
|
1935
|
+
): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean; secretOut?: string }> {
|
|
1936
1936
|
const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
1937
1937
|
// Reuse the caller's identity when provided — computing it shells out to `git` several times,
|
|
1938
1938
|
// and run() has already done so for its log line. Recomputing here would double those git
|
|
@@ -2169,6 +2169,49 @@ 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
|
+
}
|
|
2192
|
+
if (args[cmdIdx] === "read-secret") {
|
|
2193
|
+
if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_SECRET_USAGE); process.exit(0); }
|
|
2194
|
+
let wire: string[];
|
|
2195
|
+
try {
|
|
2196
|
+
// Only a pipe: a terminal would show the secret, and a file would keep it. Checked before
|
|
2197
|
+
// asking the host for it.
|
|
2198
|
+
const out = fstatSync(1);
|
|
2199
|
+
if (!out.isFIFO() && !out.isSocket()) throw new Error("read-secret writes the secret to stdout, which must be a pipe into the program that stores it (not a terminal or a file)");
|
|
2200
|
+
wire = readSecretWireArgs(parseReadSecretArgs(args.slice(cmdIdx)));
|
|
2201
|
+
} catch (err) {
|
|
2202
|
+
console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${READ_SECRET_USAGE}`);
|
|
2203
|
+
process.exit(2);
|
|
2204
|
+
}
|
|
2205
|
+
const res = await callServe(url, [...args.filter(a => a.startsWith("-s=")), ...wire], overrideEnv, identity);
|
|
2206
|
+
if (res.status !== 0 || typeof res.secretOut !== "string") {
|
|
2207
|
+
process.stderr.write(res.stderr || (res.status === 0 ? "[rech] read-secret: this daemon is older than the client (no value returned)\n" : ""));
|
|
2208
|
+
process.exit(res.status || 1);
|
|
2209
|
+
}
|
|
2210
|
+
// Wait until the pipe has taken all of it before exiting.
|
|
2211
|
+
await new Promise<void>((done, fail) => process.stdout.write(res.secretOut!, e => e ? fail(e) : done()));
|
|
2212
|
+
console.error(`[rech] read-secret: wrote ${res.secretOut.length} characters to stdout`);
|
|
2213
|
+
process.exit(0);
|
|
2214
|
+
}
|
|
2172
2215
|
if (args[cmdIdx] === "fill-secret") {
|
|
2173
2216
|
if (args.some(a => a === "--help" || a === "-h")) { console.log(FILL_SECRET_USAGE); process.exit(0); }
|
|
2174
2217
|
try {
|
|
@@ -3345,7 +3388,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3345
3388
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3346
3389
|
}
|
|
3347
3390
|
|
|
3348
|
-
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: 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> {
|
|
3349
3392
|
const config = await requireListeners();
|
|
3350
3393
|
let listener = config.listeners.find(l => l.name === name);
|
|
3351
3394
|
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
@@ -3363,7 +3406,17 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
|
|
|
3363
3406
|
else policy.hostDirs = [...new Set([...(policy.hostDirs ?? []), ...opts.hostUploadDirs!.map(d => realpathSync(resolve(d)))])];
|
|
3364
3407
|
listener.uploads = policy;
|
|
3365
3408
|
}
|
|
3409
|
+
if (opts.readSecrets !== undefined) {
|
|
3410
|
+
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-secret is always available there.`);
|
|
3411
|
+
if (opts.readSecrets) listener.readSecrets = true; else delete listener.readSecrets;
|
|
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
|
+
}
|
|
3366
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`);
|
|
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`);
|
|
3367
3420
|
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3368
3421
|
if (opts.hostUploadDirs?.length || opts.noHostUploads) {
|
|
3369
3422
|
const dirs = (listener.uploads || {}).hostDirs;
|
|
@@ -4447,7 +4500,7 @@ export type RechHandlers = {
|
|
|
4447
4500
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
4448
4501
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
4449
4502
|
rotateKey(name: string): Promise<void>;
|
|
4450
|
-
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean }): Promise<void>;
|
|
4503
|
+
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean }): Promise<void>;
|
|
4451
4504
|
setup(opts: SetupOptions): Promise<void>;
|
|
4452
4505
|
tray(action?: string): Promise<void>;
|
|
4453
4506
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -4480,6 +4533,10 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
|
|
|
4480
4533
|
--isolate in a throwaway session, e.g. for a login flow
|
|
4481
4534
|
rech fill-secret <ref> --from-env VAR | --totp-from-env VAR [--env-file f] [--allow-domain '*.example.com']
|
|
4482
4535
|
type a password / TOTP code without it reaching argv, logs or output
|
|
4536
|
+
rech read-secret <ref> --allow-domain <host> | <vault command>
|
|
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
|
|
4483
4540
|
rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
|
|
4484
4541
|
rech --version rechrome's version
|
|
4485
4542
|
|
|
@@ -4595,9 +4652,11 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4595
4652
|
.option("clear-public-url", { type: "boolean", conflicts: "public-url" })
|
|
4596
4653
|
.option("uploads", { type: "boolean", describe: "Uploads of images the link holder sends (rech upload) are on by default; --no-uploads turns them off" })
|
|
4597
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" })
|
|
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" })
|
|
4598
4657
|
.option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
|
|
4599
|
-
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, or --host-upload-dir/--no-host-uploads"),
|
|
4600
|
-
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 } : {}) }))
|
|
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 } : {}) }))
|
|
4601
4660
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4602
4661
|
a => handlers.listenerPort(a.name))
|
|
4603
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,9 +6,9 @@ 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 { 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
|
-
import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
|
|
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";
|
|
13
13
|
import { isIPv4 } from "net";
|
|
14
14
|
import { join, basename, dirname, resolve } from "path";
|
|
@@ -1932,7 +1932,7 @@ async function callServe(
|
|
|
1932
1932
|
throwOnFailure = false,
|
|
1933
1933
|
upload?: UploadPayload,
|
|
1934
1934
|
secret?: string,
|
|
1935
|
-
): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
|
|
1935
|
+
): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean; secretOut?: string }> {
|
|
1936
1936
|
const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
1937
1937
|
// Reuse the caller's identity when provided — computing it shells out to `git` several times,
|
|
1938
1938
|
// and run() has already done so for its log line. Recomputing here would double those git
|
|
@@ -2169,6 +2169,49 @@ 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
|
+
}
|
|
2192
|
+
if (args[cmdIdx] === "read-secret") {
|
|
2193
|
+
if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_SECRET_USAGE); process.exit(0); }
|
|
2194
|
+
let wire: string[];
|
|
2195
|
+
try {
|
|
2196
|
+
// Only a pipe: a terminal would show the secret, and a file would keep it. Checked before
|
|
2197
|
+
// asking the host for it.
|
|
2198
|
+
const out = fstatSync(1);
|
|
2199
|
+
if (!out.isFIFO() && !out.isSocket()) throw new Error("read-secret writes the secret to stdout, which must be a pipe into the program that stores it (not a terminal or a file)");
|
|
2200
|
+
wire = readSecretWireArgs(parseReadSecretArgs(args.slice(cmdIdx)));
|
|
2201
|
+
} catch (err) {
|
|
2202
|
+
console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${READ_SECRET_USAGE}`);
|
|
2203
|
+
process.exit(2);
|
|
2204
|
+
}
|
|
2205
|
+
const res = await callServe(url, [...args.filter(a => a.startsWith("-s=")), ...wire], overrideEnv, identity);
|
|
2206
|
+
if (res.status !== 0 || typeof res.secretOut !== "string") {
|
|
2207
|
+
process.stderr.write(res.stderr || (res.status === 0 ? "[rech] read-secret: this daemon is older than the client (no value returned)\n" : ""));
|
|
2208
|
+
process.exit(res.status || 1);
|
|
2209
|
+
}
|
|
2210
|
+
// Wait until the pipe has taken all of it before exiting.
|
|
2211
|
+
await new Promise<void>((done, fail) => process.stdout.write(res.secretOut!, e => e ? fail(e) : done()));
|
|
2212
|
+
console.error(`[rech] read-secret: wrote ${res.secretOut.length} characters to stdout`);
|
|
2213
|
+
process.exit(0);
|
|
2214
|
+
}
|
|
2172
2215
|
if (args[cmdIdx] === "fill-secret") {
|
|
2173
2216
|
if (args.some(a => a === "--help" || a === "-h")) { console.log(FILL_SECRET_USAGE); process.exit(0); }
|
|
2174
2217
|
try {
|
|
@@ -3345,7 +3388,7 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3345
3388
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3346
3389
|
}
|
|
3347
3390
|
|
|
3348
|
-
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: 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> {
|
|
3349
3392
|
const config = await requireListeners();
|
|
3350
3393
|
let listener = config.listeners.find(l => l.name === name);
|
|
3351
3394
|
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
@@ -3363,7 +3406,17 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
|
|
|
3363
3406
|
else policy.hostDirs = [...new Set([...(policy.hostDirs ?? []), ...opts.hostUploadDirs!.map(d => realpathSync(resolve(d)))])];
|
|
3364
3407
|
listener.uploads = policy;
|
|
3365
3408
|
}
|
|
3409
|
+
if (opts.readSecrets !== undefined) {
|
|
3410
|
+
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-secret is always available there.`);
|
|
3411
|
+
if (opts.readSecrets) listener.readSecrets = true; else delete listener.readSecrets;
|
|
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
|
+
}
|
|
3366
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`);
|
|
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`);
|
|
3367
3420
|
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3368
3421
|
if (opts.hostUploadDirs?.length || opts.noHostUploads) {
|
|
3369
3422
|
const dirs = (listener.uploads || {}).hostDirs;
|
|
@@ -4447,7 +4500,7 @@ export type RechHandlers = {
|
|
|
4447
4500
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
4448
4501
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
4449
4502
|
rotateKey(name: string): Promise<void>;
|
|
4450
|
-
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean }): Promise<void>;
|
|
4503
|
+
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean; readCookies?: boolean }): Promise<void>;
|
|
4451
4504
|
setup(opts: SetupOptions): Promise<void>;
|
|
4452
4505
|
tray(action?: string): Promise<void>;
|
|
4453
4506
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -4480,6 +4533,10 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
|
|
|
4480
4533
|
--isolate in a throwaway session, e.g. for a login flow
|
|
4481
4534
|
rech fill-secret <ref> --from-env VAR | --totp-from-env VAR [--env-file f] [--allow-domain '*.example.com']
|
|
4482
4535
|
type a password / TOTP code without it reaching argv, logs or output
|
|
4536
|
+
rech read-secret <ref> --allow-domain <host> | <vault command>
|
|
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
|
|
4483
4540
|
rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
|
|
4484
4541
|
rech --version rechrome's version
|
|
4485
4542
|
|
|
@@ -4595,9 +4652,11 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4595
4652
|
.option("clear-public-url", { type: "boolean", conflicts: "public-url" })
|
|
4596
4653
|
.option("uploads", { type: "boolean", describe: "Uploads of images the link holder sends (rech upload) are on by default; --no-uploads turns them off" })
|
|
4597
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" })
|
|
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" })
|
|
4598
4657
|
.option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
|
|
4599
|
-
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, or --host-upload-dir/--no-host-uploads"),
|
|
4600
|
-
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 } : {}) }))
|
|
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 } : {}) }))
|
|
4601
4660
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4602
4661
|
a => handlers.listenerPort(a.name))
|
|
4603
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, 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;
|
|
@@ -944,6 +944,39 @@ export async function serve() {
|
|
|
944
944
|
}
|
|
945
945
|
}
|
|
946
946
|
|
|
947
|
+
// read-secret: the value goes back only in `secretOut`, never into stdout, a log line or a
|
|
948
|
+
// file, and is masked in every later output. See fill-secret.js.
|
|
949
|
+
if (filteredArgs[0] === "read-secret") {
|
|
950
|
+
try {
|
|
951
|
+
const { ref, allowDomains } = parseReadSecretWire(filteredArgs);
|
|
952
|
+
const { host, value } = await readSecretOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, ref, allowDomains });
|
|
953
|
+
if (!value) throw new Error(`read-secret: ${ref} on ${host} is empty`);
|
|
954
|
+
secretMasker.add(value, Infinity);
|
|
955
|
+
log(`exit: 0 | read-secret ${ref} on ${host} (${value.length} chars, value not logged)`);
|
|
956
|
+
return Response.json({ status: 0, stdout: "", stderr: "", files: [], secretOut: value });
|
|
957
|
+
} catch (error) {
|
|
958
|
+
const message = secretMasker.mask(error instanceof Error ? error.message : String(error));
|
|
959
|
+
log(`exit: 1 | read-secret: ${message.split("\n")[0]}`);
|
|
960
|
+
return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
|
|
961
|
+
}
|
|
962
|
+
}
|
|
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
|
+
|
|
947
980
|
// For open commands, default to about:blank to avoid leaving connect.html visible
|
|
948
981
|
const isOpenCmd = filteredArgs[0] === "open";
|
|
949
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, 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;
|
|
@@ -944,6 +944,39 @@ export async function serve() {
|
|
|
944
944
|
}
|
|
945
945
|
}
|
|
946
946
|
|
|
947
|
+
// read-secret: the value goes back only in `secretOut`, never into stdout, a log line or a
|
|
948
|
+
// file, and is masked in every later output. See fill-secret.ts.
|
|
949
|
+
if (filteredArgs[0] === "read-secret") {
|
|
950
|
+
try {
|
|
951
|
+
const { ref, allowDomains } = parseReadSecretWire(filteredArgs);
|
|
952
|
+
const { host, value } = await readSecretOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, ref, allowDomains });
|
|
953
|
+
if (!value) throw new Error(`read-secret: ${ref} on ${host} is empty`);
|
|
954
|
+
secretMasker.add(value, Infinity);
|
|
955
|
+
log(`exit: 0 | read-secret ${ref} on ${host} (${value.length} chars, value not logged)`);
|
|
956
|
+
return Response.json({ status: 0, stdout: "", stderr: "", files: [], secretOut: value });
|
|
957
|
+
} catch (error) {
|
|
958
|
+
const message = secretMasker.mask(error instanceof Error ? error.message : String(error));
|
|
959
|
+
log(`exit: 1 | read-secret: ${message.split("\n")[0]}`);
|
|
960
|
+
return Response.json({ status: 1, stdout: "", stderr: `[rech] ${message}\n`, files: [] });
|
|
961
|
+
}
|
|
962
|
+
}
|
|
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
|
+
|
|
947
980
|
// For open commands, default to about:blank to avoid leaving connect.html visible
|
|
948
981
|
const isOpenCmd = filteredArgs[0] === "open";
|
|
949
982
|
const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
|