Public: anyone can read this; the crew can change it.

`forge login` refuses a plain http:// workbench that is not loopback, so a token never crosses the network in clear #7397

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/7377-forge-login-refuses-a-plain-http-workben → main 1 commit fork

  • Merged. torque/7377-forge-login-refuses-a-plain-http-workben was merged into main as 59d3afb.

Review

Sign in to review this change.
4 files changed +245 −14
README.md +5
@@ -280,6 +280,11 @@ forge status # the same report, without touching the network
280280 forge # the local UI over your clones
281281 ```
282282
283 A bare host means https; `localhost`, `127.0.0.1` and `[::1]` mean http. Plain
284 `http://` to any other machine is refused — the token would cross the network in
285 clear — unless you log in with `--insecure` for a host you trust; that is kept
286 with that workbench only, and `FORGE_URL` to another such address is refused too.
287
283288 Repos land under `~/src/<host>/<owner>/<name>` — the path is the identity, so
284289 two forges with a `docs` repo never collide, and the filesystem is the index
285290 (there is no local database of "your repos"; the walk finds them). `--root`
src/cli/config.ts +63 −12
@@ -12,6 +12,11 @@
1212 *
1313 * `FORGE_URL` / `FORGE_TOKEN` override both, so a script or a test never has
1414 * to write a config at all.
15 *
16 * Plain http to another machine is refused (#7377): every request carries
17 * the token, and over http it crosses the network in clear. Loopback is
18 * fine — it never leaves the machine. `forge login --insecure` is the one
19 * way past it, and it is remembered with that workbench only.
1520 */
1621
1722 import { chmod, mkdir, rm, writeFile } from "node:fs/promises";
@@ -28,8 +33,33 @@ interface StoredConfig {
2833 url: string;
2934 token?: string;
3035 keychain?: boolean;
36 /** `forge login --insecure`: plain http to this one workbench is allowed. */
37 insecure?: boolean;
3138 }
3239
40 /** The one line every refusal of a plain-http workbench prints. */
41 export const PLAIN_HTTP_REFUSAL =
42 "plain http sends your token in clear; use https, or --insecure for a host you trust";
43
44 /**
45 * True when `url` is `http://` to a host other than this machine. Hosts are
46 * compared as `URL` parses them, so `LOCALHOST` and `127.0.0.1:3000` are
47 * loopback and `localhost.evil.example` is not. An address that does not
48 * parse is not plain http; the request itself will say what is wrong.
49 */
50 export function plainHttpRemote(url: string): boolean {
51 let parsed: URL;
52 try {
53 parsed = new URL(url);
54 } catch {
55 return false;
56 }
57 if (parsed.protocol !== "http:") return false;
58 return !LOOPBACK.has(parsed.hostname);
59 }
60
61 const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
62
3363 const SERVICE = "forge-cli";
3464
3565 export function configDir(): string {
@@ -108,7 +138,7 @@ async function keychainDelete(url: string): Promise<void> {
108138 await security(["delete-generic-password", "-a", url, "-s", SERVICE]);
109139 }
110140
111 export type ConfigError = "no_config" | "no_token";
141 export type ConfigError = "no_config" | "no_token" | "plain_http";
112142
113143 export interface Loaded {
114144 readonly config: Config;
@@ -116,21 +146,27 @@ export interface Loaded {
116146 readonly fromKeychain: boolean;
117147 }
118148
119 /** Read the config, with the environment winning over the file. */
149 /**
150 * Read the config, with the environment winning over the file. A plain-http
151 * remote address is refused here, before any request is made, unless the
152 * stored workbench is that same address and was signed in with `--insecure`.
153 */
120154 export async function loadConfig(): Promise<Loaded | ConfigError> {
121155 const envUrl = process.env["FORGE_URL"];
122156 const envToken = process.env["FORGE_TOKEN"];
123 if (envUrl !== undefined && envUrl !== "" && envToken !== undefined)
124 return {
125 config: { url: normalizeUrl(envUrl), token: envToken },
126 fromKeychain: false,
127 };
128
157 const hasEnvUrl = envUrl !== undefined && envUrl !== "";
129158 const stored = await readStored();
159 if (hasEnvUrl && envToken !== undefined) {
160 const url = normalizeUrl(envUrl);
161 if (plainHttpRemote(url) && !allowedInsecure(stored, url))
162 return "plain_http";
163 return { config: { url, token: envToken }, fromKeychain: false };
164 }
165
130166 if (stored === null) return "no_config";
131 const url = normalizeUrl(
132 envUrl !== undefined && envUrl !== "" ? envUrl : stored.url,
133 );
167 const url = normalizeUrl(hasEnvUrl ? envUrl : stored.url);
168 if (plainHttpRemote(url) && !allowedInsecure(stored, url))
169 return "plain_http";
134170
135171 if (envToken !== undefined && envToken !== "")
136172 return { config: { url, token: envToken }, fromKeychain: false };
@@ -144,6 +180,14 @@ export async function loadConfig(): Promise<Loaded | ConfigError> {
144180 return { config: { url, token: stored.token }, fromKeychain: false };
145181 }
146182
183 function allowedInsecure(stored: StoredConfig | null, url: string): boolean {
184 return (
185 stored !== null &&
186 stored.insecure === true &&
187 normalizeUrl(stored.url) === url
188 );
189 }
190
147191 async function readStored(): Promise<StoredConfig | null> {
148192 const file = Bun.file(configPath());
149193 if (!(await file.exists())) return null;
@@ -155,15 +199,21 @@ async function readStored(): Promise<StoredConfig | null> {
155199 const out: StoredConfig = { url: record["url"] };
156200 if (typeof record["token"] === "string") out.token = record["token"];
157201 if (record["keychain"] === true) out.keychain = true;
202 if (record["insecure"] === true) out.insecure = true;
158203 return out;
159204 } catch {
160205 return null;
161206 }
162207 }
163208
164 /** Write the config, putting the token in the keychain where there is one. */
209 /**
210 * Write the config, putting the token in the keychain where there is one.
211 * `insecure` is stored only for a plain-http remote, the one place it means
212 * anything, so the file never claims an exception it does not need.
213 */
165214 export async function saveConfig(
166215 config: Config,
216 opts: { insecure?: boolean } = {},
167217 ): Promise<{ keychain: boolean }> {
168218 const url = normalizeUrl(config.url);
169219 const path = configPath();
@@ -173,6 +223,7 @@ export async function saveConfig(
173223 const stored: StoredConfig = keychain
174224 ? { url, keychain: true }
175225 : { url, token: config.token };
226 if (opts.insecure === true && plainHttpRemote(url)) stored.insecure = true;
176227 // Create the file before the secret goes in it: a chmod after the write is
177228 // a window, however short, where the token is world-readable.
178229 await writeFile(path, "", { mode: 0o600 });
src/cli/main.ts +19 −2
@@ -20,6 +20,8 @@ import {
2020 configPath,
2121 loadConfig,
2222 normalizeUrl,
23 PLAIN_HTTP_REFUSAL,
24 plainHttpRemote,
2325 rootDir,
2426 saveConfig,
2527 type Config,
@@ -109,7 +111,7 @@ function usage(out: Out): void {
109111 out.line(`forge ${VERSION} — your workbench, on this machine`);
110112 out.line();
111113 out.line(b(" forge") + " the local terminal UI");
112 out.line(b(" forge login <url>") + " [--token T]");
114 out.line(b(" forge login <url>") + " [--token T] [--insecure]");
113115 out.line(b(" forge logout"));
114116 out.line(b(" forge whoami"));
115117 out.line(
@@ -142,6 +144,13 @@ async function login(out: Out, args: Args): Promise<number> {
142144 return EXIT.usage;
143145 }
144146 const url = normalizeUrl(raw);
147 // Before the token is asked for, let alone sent: nothing crosses the
148 // network in clear unless the person said this host is one they trust.
149 const insecure = args.flag("insecure");
150 if (plainHttpRemote(url) && !insecure) {
151 out.err(`forge login: ${PLAIN_HTTP_REFUSAL}`);
152 return EXIT.usage;
153 }
145154 const token = args.value("token") ?? (await askSecret(`Token for ${url}: `));
146155 if (token === "") {
147156 out.err("forge login: no token given");
@@ -155,7 +164,7 @@ async function login(out: Out, args: Args): Promise<number> {
155164 out.err(`forge login: ${me.error.message}`);
156165 return me.error.status === 401 ? EXIT.notConfigured : EXIT.failed;
157166 }
158 const stored = await saveConfig({ url, token });
167 const stored = await saveConfig({ url, token }, { insecure });
159168 out.line(
160169 `${out.paint(G.check, out.c.success)} signed in to ${url} as ${me.value.username}`,
161170 );
@@ -402,6 +411,10 @@ async function openTui(out: Out, args: Args): Promise<number> {
402411 return EXIT.usage;
403412 }
404413 const loaded = await loadConfig();
414 if (loaded === "plain_http") {
415 out.err(`forge: ${PLAIN_HTTP_REFUSAL}`);
416 return EXIT.usage;
417 }
405418 const config = typeof loaded === "string" ? null : loaded.config;
406419 const { runLocalTui } = await import("../tui/local/app.ts");
407420 await runLocalTui({
@@ -423,6 +436,10 @@ async function requireConfig(out: Out): Promise<Config | number> {
423436 out.err(`forge: ${configPath()} has no usable token — run \`forge login\``);
424437 return EXIT.notConfigured;
425438 }
439 if (loaded === "plain_http") {
440 out.err(`forge: ${PLAIN_HTTP_REFUSAL}`);
441 return EXIT.usage;
442 }
426443 return loaded.config;
427444 }
428445
test/cli-plain-http.test.ts added +158
@@ -0,0 +1,158 @@
1 /**
2 * `forge login` and every later command refuse plain http to another
3 * machine (#7377): the token rides on every request, and over http it
4 * crosses the network in clear. Loopback http and https pass; `--insecure`
5 * passes and is remembered with that workbench only.
6 *
7 * Driven through `main()`, the function the shell calls, with `fetch`
8 * stubbed so no request leaves the test — and counted, so "refused before
9 * any request" is a number, not a hope.
10 */
11
12 import { afterEach, beforeEach, expect, test } from "bun:test";
13 import { mkdtemp, rm } from "node:fs/promises";
14 import { tmpdir } from "node:os";
15 import { join } from "node:path";
16 import {
17 configPath,
18 loadConfig,
19 normalizeUrl,
20 PLAIN_HTTP_REFUSAL,
21 plainHttpRemote,
22 } from "../src/cli/config.ts";
23 import { main } from "../src/cli/main.ts";
24
25 const ENV = ["FORGE_CONFIG_DIR", "FORGE_KEYCHAIN", "FORGE_URL", "FORGE_TOKEN"];
26 const saved = new Map<string, string | undefined>();
27 let dir = "";
28 let requests: string[] = [];
29 let stderr = "";
30 const realFetch = globalThis.fetch;
31 const realErr = process.stderr.write.bind(process.stderr);
32 const realOut = process.stdout.write.bind(process.stdout);
33
34 beforeEach(async () => {
35 for (const key of ENV) saved.set(key, process.env[key]);
36 dir = await mkdtemp(join(tmpdir(), "forge-plain-http-"));
37 process.env["FORGE_CONFIG_DIR"] = dir;
38 process.env["FORGE_KEYCHAIN"] = "0";
39 delete process.env["FORGE_URL"];
40 delete process.env["FORGE_TOKEN"];
41 requests = [];
42 stderr = "";
43 globalThis.fetch = (async (input: string | URL | Request) => {
44 requests.push(String(input));
45 return Response.json({ username: "ada", displayName: "Ada" });
46 }) as typeof fetch;
47 process.stderr.write = ((text: string) => {
48 stderr += text;
49 return true;
50 }) as typeof process.stderr.write;
51 process.stdout.write = (() => true) as typeof process.stdout.write;
52 });
53
54 afterEach(async () => {
55 globalThis.fetch = realFetch;
56 process.stderr.write = realErr;
57 process.stdout.write = realOut;
58 for (const [key, value] of saved) {
59 if (value === undefined) delete process.env[key];
60 else process.env[key] = value;
61 }
62 await rm(dir, { recursive: true, force: true });
63 });
64
65 async function stored(): Promise<Record<string, unknown> | null> {
66 const file = Bun.file(configPath());
67 return (await file.exists()) ? ((await file.json()) as never) : null;
68 }
69
70 test("only http to a host other than this machine is plain-http remote", () => {
71 expect(plainHttpRemote("http://forge.example.com")).toBe(true);
72 expect(plainHttpRemote("http://10.0.0.5:3000")).toBe(true);
73 expect(plainHttpRemote("http://localhost.evil.example")).toBe(true);
74 expect(plainHttpRemote("http://localhost:3000")).toBe(false);
75 expect(plainHttpRemote("http://LOCALHOST")).toBe(false);
76 expect(plainHttpRemote("http://127.0.0.1:8080")).toBe(false);
77 expect(plainHttpRemote("http://[::1]:8080")).toBe(false);
78 expect(plainHttpRemote("https://forge.example.com")).toBe(false);
79 });
80
81 test("bare hosts still become https, loopback still http", () => {
82 expect(normalizeUrl("forge.example.com/")).toBe("https://forge.example.com");
83 expect(normalizeUrl("localhost:3000")).toBe("http://localhost:3000");
84 expect(normalizeUrl("127.0.0.1")).toBe("http://127.0.0.1");
85 expect(normalizeUrl("[::1]:3000")).toBe("http://[::1]:3000");
86 });
87
88 test("login to a remote http address is refused, exits 2, writes nothing, sends nothing", async () => {
89 const code = await main(["login", "http://forge.example.com", "--token", "t"]);
90 expect(code).toBe(2);
91 expect(stderr).toBe(`forge login: ${PLAIN_HTTP_REFUSAL}\n`);
92 expect(requests).toEqual([]);
93 expect(await stored()).toBeNull();
94 });
95
96 test("login over loopback http and over https passes", async () => {
97 expect(await main(["login", "http://127.0.0.1:3000", "--token", "t"])).toBe(0);
98 expect(await stored()).toEqual({ url: "http://127.0.0.1:3000", token: "t" });
99 expect(await main(["login", "forge.example.com", "--token", "t"])).toBe(0);
100 expect(await stored()).toEqual({ url: "https://forge.example.com", token: "t" });
101 expect(requests).toEqual([
102 "http://127.0.0.1:3000/api/v1/user",
103 "https://forge.example.com/api/v1/user",
104 ]);
105 });
106
107 test("--insecure passes, is kept with the workbench, and later commands work", async () => {
108 const code = await main([
109 "login",
110 "http://forge.lan:3000",
111 "--insecure",
112 "--token",
113 "t",
114 ]);
115 expect(code).toBe(0);
116 expect(await stored()).toEqual({
117 url: "http://forge.lan:3000",
118 token: "t",
119 insecure: true,
120 });
121 expect(await main(["whoami"])).toBe(0);
122 expect(requests).toEqual([
123 "http://forge.lan:3000/api/v1/user",
124 "http://forge.lan:3000/api/v1/user",
125 ]);
126 });
127
128 test("--insecure is not stored where it means nothing", async () => {
129 await main(["login", "forge.example.com", "--insecure", "--token", "t"]);
130 expect(await stored()).toEqual({ url: "https://forge.example.com", token: "t" });
131 });
132
133 test("FORGE_URL to a remote http address is refused before any request", async () => {
134 process.env["FORGE_URL"] = "http://forge.example.com";
135 process.env["FORGE_TOKEN"] = "t";
136 expect(await loadConfig()).toBe("plain_http");
137 expect(await main(["whoami"])).toBe(2);
138 expect(stderr).toBe(`forge: ${PLAIN_HTTP_REFUSAL}\n`);
139 expect(requests).toEqual([]);
140 });
141
142 test("a stored --insecure covers its own workbench, not another FORGE_URL", async () => {
143 await main(["login", "http://forge.lan", "--insecure", "--token", "t"]);
144 process.env["FORGE_URL"] = "http://forge.lan/";
145 expect(await loadConfig()).not.toBeString();
146 process.env["FORGE_URL"] = "http://other.lan";
147 expect(await loadConfig()).toBe("plain_http");
148 });
149
150 test("an existing remote http login gets the refusal's words on its next command", async () => {
151 await Bun.write(
152 configPath(),
153 JSON.stringify({ url: "http://forge.example.com", token: "t" }),
154 );
155 expect(await main(["sync"])).toBe(2);
156 expect(stderr).toBe(`forge: ${PLAIN_HTTP_REFUSAL}\n`);
157 expect(requests).toEqual([]);
158 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs