@jondotsoy/envs 0.1.9-alpha.2 → 0.1.9-alpha.4

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 CHANGED
@@ -43,7 +43,7 @@ A walkthrough with a screenshot of each action is in [docs/ui.md](docs/ui.md) (i
43
43
  | Command | What it does |
44
44
  | --- | --- |
45
45
  | `envs edit` | Runs `init` if `.envs/values.yml` does not exist yet, then runs `pull`, opens `values.yml` with `code -w` and, when the editor closes, runs `push`. If the editor fails, it does not `push`. |
46
- | `envs edit --ui` | Same `init` and `pull`, but instead of VS Code it starts a local server (`127.0.0.1`, random port) and opens a React web editor with the values of `values.yml` (worktree profiles, defaults and per-worktree values). The **Save** button in the top right corner writes `values.yml` and runs `push`. Its **History** panel lists the history points (newest first); hovering or clicking a point shows what restoring it would change in the editor (red: removed, green: changed or added), and its **Restore** button, after a confirmation, runs `envs restore` for that point. Open the address it prints (it carries a one-time access token); requests without the token, from another host name or from another site are refused (see [docs/ui.md](docs/ui.md)). Stop the server with Ctrl+C. |
46
+ | `envs edit --ui` | Same `init` and `pull`, but instead of VS Code it starts a local server (`127.0.0.1`, random port) and opens a React web editor with the values of `values.yml` (worktree profiles, defaults and per-worktree values). The **Save** button in the top right corner writes `values.yml` and runs `push`. Its **History** panel lists the history points (newest first); hovering or clicking a point shows what restoring it would change in the editor (red: removed, green: changed or added), and its **Restore** button, after a confirmation, runs `envs restore` for that point. Open the address it prints (it carries a single-use access token: the first visit trades it for a session cookie, and using it prints a fresh single-use address; if the browser cannot be opened, the address is printed to open by hand; the token never appears in `ps`); requests without the token, from another host name or from another site are refused (see [docs/ui.md](docs/ui.md)). Stop the server with Ctrl+C. |
47
47
  | `envs init` | Creates `.envs/`, `.envs/.gitignore` (containing `*`) and an empty `.envs/values.yml`. Does not overwrite existing files. |
48
48
  | `envs pull` | Reads the `.env` of the main repo and of each worktree and writes it to `values.yml`. Variables that are no longer in a `.env` are removed from `values.yml` for that worktree. |
49
49
  | `envs push` | Writes `values.yml` to the `.env` of each worktree. Updates existing variables in place, appends new ones at the end, keeps comments, refuses (writing nothing) a variable name that is not valid or a value with a line break, and removes the orphan variables: those `envs` manages (they are, or were, in `values.yml` or its history) but that the worktree no longer resolves, for example after `envs restore`. Variables that `envs` never saw are left alone. |
package/SECURITY.md CHANGED
@@ -28,7 +28,7 @@ I will reply as soon as possible and we will coordinate the fix and the disclosu
28
28
  | `envs init` | — | Creates `<root>/.envs/`, `<root>/.envs/.gitignore` and `<root>/.envs/values.yml`, only if they do not exist. Never overwrites. |
29
29
  | `envs pull` | `<worktree>/.env` of each worktree | Rewrites `<root>/.envs/values.yml`. The file is serialized again, so YAML comments are lost. |
30
30
  | `envs push` | `<root>/.envs/values.yml` | Creates or updates `<worktree>/.env` of each worktree that has values. Updates existing variables in place, appends new ones at the end and keeps the rest of the file (it sets the mode of every `.env` it writes to `600`, so another user, such as a container mounting it, can no longer read it), except the orphan variables: those `envs` manages (they are or were in `values.yml` or its history) that the worktree no longer resolves are removed. If there are `defaults`, this includes all worktrees. |
31
- | `envs edit` | Same as `pull` and `push` | Same as `pull` and `push`. It also runs `code -w <root>/.envs/values.yml`. With `--ui` it serves the web editor instead (see below). |
31
+ | `envs edit` | Same as `pull` and `push` | Same as `pull` and `push`. It also runs `code -w <root>/.envs/values.yml`. With `--ui` it serves the web editor instead and runs the system opener (`xdg-open`, `open` or `explorer`) on a temporary redirect page under the OS temp directory (see below). |
32
32
  | `envs use`, `envs restore` | `<root>/.envs/values.yml`, `<root>/.envs/history.ndjson` | Rewrite `values.yml` and then act as `push`. |
33
33
  | `envs config` | `<root>/.envs/config.yml` | Creates or updates `<root>/.envs/config.yml` (mode `600`). |
34
34
 
@@ -51,7 +51,14 @@ Additionally:
51
51
 
52
52
  ## Web editor (`envs edit --ui`)
53
53
 
54
- The server listens on `127.0.0.1` only and exposes the values of every `.env`, secrets included, so each request must pass these checks: a random per-run access token (in the printed URL, then an `HttpOnly`, `SameSite=Strict` cookie), a `Host` of this server (no DNS rebinding), an `Origin` of this server, `application/json` bodies and a body of at most 5 MiB; it never answers CORS preflights. Open only the address printed by the command and do not share it: it carries the token. See [docs/ui.md](docs/ui.md).
54
+ The server listens on `127.0.0.1` only and exposes the values of every `.env`, secrets included, so each request must pass these checks: a random per-run access token (in the printed URL, **single-use**: the first visit trades it for a different session secret, set as an `HttpOnly`, `SameSite=Strict` cookie), a `Host` of this server (no DNS rebinding), an `Origin` of this server, `application/json` bodies and a body of at most 5 MiB; it never answers CORS preflights. Open only the address printed by the command and do not share it: it carries the token. See [docs/ui.md](docs/ui.md).
55
+
56
+ How the access token is handled:
57
+
58
+ - **Single-use.** Once the token in the URL is used it stops working (`401`), so a copy left in the browser history, the terminal or a screenshot is useless afterwards. Using it prints a fresh single-use URL to stdout (`New single-use URL: …`) to open the editor from another browser; every URL gives the same session. The session secret is never in a URL.
59
+ - **Not in `ps`.** The URL is not passed as an argument to `xdg-open`/`open`/`explorer`, where any user could read it in the process list. The opener receives a private temporary page (a directory made with `mkdtemp`, file mode `600`) that redirects to the URL. The page is removed as soon as the token is used, after 60 seconds at most, when the process exits, or when the opener fails.
60
+ - **If the browser cannot be opened** the command prints `Could not open the browser. Open this address manually: …`.
61
+ - **Remaining exposure.** The token is printed to stdout and is in the address bar until it is used, so anyone who can read your screen or your terminal output in that moment can use it before you do. If the address is opened by someone else first, your own visit gets a `401`: stop the server (Ctrl+C) and start it again.
55
62
 
56
63
  ## The history
57
64
 
package/envs.js CHANGED
@@ -499,8 +499,16 @@ function presentedToken(req) {
499
499
  }
500
500
  async function serveUi(ctx) {
501
501
  const assets = await loadAssets();
502
- const token = randomBytes(24).toString("base64url");
502
+ let opened;
503
+ let token = randomBytes(24).toString("base64url");
504
+ const session = randomBytes(24).toString("base64url");
503
505
  const guard = (handler) => async (req) => {
506
+ const res = await check(req, handler);
507
+ for (const [name, value] of Object.entries(HARDENING_HEADERS))
508
+ res.headers.set(name, value);
509
+ return res;
510
+ };
511
+ const check = async (req, handler) => {
504
512
  const port = server.port;
505
513
  const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
506
514
  if (!hosts.includes(req.headers.get("host") ?? "")) {
@@ -511,18 +519,24 @@ async function serveUi(ctx) {
511
519
  return new Response("Forbidden origin", { status: 403 });
512
520
  }
513
521
  const url = new URL(req.url);
514
- if (url.pathname === "/" && sameToken(url.searchParams.get("token"), token)) {
522
+ if (req.method === "GET" && url.pathname === "/" && sameToken(url.searchParams.get("token"), token)) {
523
+ token = randomBytes(24).toString("base64url");
524
+ ctx.log(`New single-use URL: ${urlWith(token)}`);
525
+ opened?.then((cleanup) => cleanup?.(), () => {});
515
526
  return new Response(null, {
516
527
  status: 302,
517
528
  headers: {
518
529
  location: "/",
519
- "set-cookie": `${TOKEN_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`
530
+ "set-cookie": `${TOKEN_COOKIE}=${session}; HttpOnly; SameSite=Lax; Path=/`
520
531
  }
521
532
  });
522
533
  }
523
- if (!sameToken(presentedToken(req), token)) {
534
+ if (!sameToken(presentedToken(req), session)) {
524
535
  return new Response("Unauthorized: open the address printed by `envs edit --ui`.", { status: 401 });
525
536
  }
537
+ if (!["GET", "HEAD", "PUT", "POST"].includes(req.method)) {
538
+ return new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD, PUT, POST" } });
539
+ }
526
540
  if (req.method !== "GET" && req.method !== "HEAD" && !req.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
527
541
  return new Response("Unsupported media type", { status: 415 });
528
542
  }
@@ -574,17 +588,28 @@ async function serveUi(ctx) {
574
588
  return asset ? new Response(asset) : new Response("Not found", { status: 404 });
575
589
  })
576
590
  });
577
- const url = `http://127.0.0.1:${server.port}/?token=${token}`;
591
+ const urlWith = (t) => `http://127.0.0.1:${server.port}/?token=${t}`;
592
+ const url = urlWith(token);
578
593
  ctx.log(`Web editor running at ${url} (Ctrl+C to stop)`);
579
- await ctx.openUrl?.(url);
594
+ try {
595
+ opened = ctx.openUrl?.(url);
596
+ await opened;
597
+ } catch {
598
+ ctx.log(`Could not open the browser. Open this address manually: ${url}`);
599
+ }
580
600
  await (ctx.waitForExit ?? (() => new Promise(() => {})))();
581
601
  await server.stop(true);
582
602
  return 0;
583
603
  }
584
- var ANSI, isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v), isScalar = (v) => ["string", "number", "boolean"].includes(typeof v), TOKEN_COOKIE = "envs_token", MAX_BODY_BYTES;
604
+ var ANSI, isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v), isScalar = (v) => ["string", "number", "boolean"].includes(typeof v), TOKEN_COOKIE = "envs_token", HARDENING_HEADERS, MAX_BODY_BYTES;
585
605
  var init_server = __esm(() => {
586
606
  init_envs();
587
607
  ANSI = /\x1b\[[0-9;]*m/g;
608
+ HARDENING_HEADERS = {
609
+ "x-content-type-options": "nosniff",
610
+ "content-security-policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
611
+ "referrer-policy": "no-referrer"
612
+ };
588
613
  MAX_BODY_BYTES = 5 * 1024 * 1024;
589
614
  });
590
615
 
@@ -593,6 +618,8 @@ import { AsyncLocalStorage } from "async_hooks";
593
618
  import { chmod, mkdir, open, rename, unlink, writeFile } from "fs/promises";
594
619
  import { basename, dirname, join as join2 } from "path";
595
620
  import { stat } from "fs/promises";
621
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
622
+ import { tmpdir } from "os";
596
623
  import { createHash, createHmac, randomBytes as randomBytes2 } from "crypto";
597
624
  import { appendFile, truncate } from "fs/promises";
598
625
  var {$ } = globalThis.Bun;
@@ -1567,9 +1594,21 @@ Commands:
1567
1594
  },
1568
1595
  openUrl: async (url) => {
1569
1596
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
1597
+ const dir = mkdtempSync(join2(tmpdir(), "envs-"));
1598
+ const cleanup = () => rmSync(dir, { recursive: true, force: true });
1570
1599
  try {
1571
- await Bun.spawn([opener, url], { stdio: ["ignore", "ignore", "ignore"] }).exited;
1572
- } catch {}
1600
+ const page = join2(dir, "open.html");
1601
+ writeFileSync(page, `<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${url}"><script>location.replace(${JSON.stringify(url)})</script>`, { mode: 384 });
1602
+ process.once("exit", cleanup);
1603
+ setTimeout(cleanup, 60000).unref();
1604
+ const code = await Bun.spawn([opener, page], { stdio: ["ignore", "ignore", "ignore"] }).exited;
1605
+ if (code !== 0)
1606
+ throw new Error(`${opener} exited with code ${code}`);
1607
+ return cleanup;
1608
+ } catch (error) {
1609
+ cleanup();
1610
+ throw error;
1611
+ }
1573
1612
  },
1574
1613
  waitForExit: () => new Promise((resolve) => {
1575
1614
  process.once("SIGINT", () => resolve());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jondotsoy/envs",
3
- "version": "0.1.9-alpha.2",
3
+ "version": "0.1.9-alpha.4",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Jonathan Delgado",