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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7377-forge-login-refuses-a-plain-http-workben → main 1 commit fork
4 files
changed +245
−14
README.md +5
| @@ -280,6 +280,11 @@ forge status # the same report, without touching the network | ||
| 280 | 280 | forge # the local UI over your clones |
| 281 | 281 | |
| 282 | 282 | |
| 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 | ||
| 283 | 288 | Repos land under `~/src/<host>/<owner>/<name>` — the path is the identity, so |
| 284 | 289 | two forges with a `docs` repo never collide, and the filesystem is the index |
| 285 | 290 | (there is no local database of "your repos"; the walk finds them). `--root` |
src/cli/config.ts +63 −12
| @@ -12,6 +12,11 @@ | ||
| 12 | 12 | * |
| 13 | 13 | * `FORGE_URL` / `FORGE_TOKEN` override both, so a script or a test never has |
| 14 | 14 | * 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. | |
| 15 | 20 | */ |
| 16 | 21 | |
| 17 | 22 | import { chmod, mkdir, rm, writeFile } from "node:fs/promises"; |
| @@ -28,8 +33,33 @@ interface StoredConfig { | ||
| 28 | 33 | url: string; |
| 29 | 34 | token?: string; |
| 30 | 35 | keychain?: boolean; |
| 36 | /** `forge login --insecure`: plain http to this one workbench is allowed. */ | |
| 37 | insecure?: boolean; | |
| 31 | 38 | } |
| 32 | 39 | |
| 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 | ||
| 33 | 63 | const SERVICE = "forge-cli"; |
| 34 | 64 | |
| 35 | 65 | export function configDir(): string { |
| @@ -108,7 +138,7 @@ async function keychainDelete(url: string): Promise<void> { | ||
| 108 | 138 | await security(["delete-generic-password", "-a", url, "-s", SERVICE]); |
| 109 | 139 | } |
| 110 | 140 | |
| 111 | export type ConfigError = "no_config" | "no_token"; | |
| 141 | export type ConfigError = "no_config" | "no_token" | "plain_http"; | |
| 112 | 142 | |
| 113 | 143 | export interface Loaded { |
| 114 | 144 | readonly config: Config; |
| @@ -116,21 +146,27 @@ export interface Loaded { | ||
| 116 | 146 | readonly fromKeychain: boolean; |
| 117 | 147 | } |
| 118 | 148 | |
| 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 | */ | |
| 120 | 154 | export async function loadConfig(): Promise<Loaded | ConfigError> { |
| 121 | 155 | const envUrl = process.env["FORGE_URL"]; |
| 122 | 156 | 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 !== ""; | |
| 129 | 158 | 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 | ||
| 130 | 166 | 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"; | |
| 134 | 170 | |
| 135 | 171 | if (envToken !== undefined && envToken !== "") |
| 136 | 172 | return { config: { url, token: envToken }, fromKeychain: false }; |
| @@ -144,6 +180,14 @@ export async function loadConfig(): Promise<Loaded | ConfigError> { | ||
| 144 | 180 | return { config: { url, token: stored.token }, fromKeychain: false }; |
| 145 | 181 | } |
| 146 | 182 | |
| 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 | ||
| 147 | 191 | async function readStored(): Promise<StoredConfig | null> { |
| 148 | 192 | const file = Bun.file(configPath()); |
| 149 | 193 | if (!(await file.exists())) return null; |
| @@ -155,15 +199,21 @@ async function readStored(): Promise<StoredConfig | null> { | ||
| 155 | 199 | const out: StoredConfig = { url: record["url"] }; |
| 156 | 200 | if (typeof record["token"] === "string") out.token = record["token"]; |
| 157 | 201 | if (record["keychain"] === true) out.keychain = true; |
| 202 | if (record["insecure"] === true) out.insecure = true; | |
| 158 | 203 | return out; |
| 159 | 204 | } catch { |
| 160 | 205 | return null; |
| 161 | 206 | } |
| 162 | 207 | } |
| 163 | 208 | |
| 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 | */ | |
| 165 | 214 | export async function saveConfig( |
| 166 | 215 | config: Config, |
| 216 | opts: { insecure?: boolean } = {}, | |
| 167 | 217 | ): Promise<{ keychain: boolean }> { |
| 168 | 218 | const url = normalizeUrl(config.url); |
| 169 | 219 | const path = configPath(); |
| @@ -173,6 +223,7 @@ export async function saveConfig( | ||
| 173 | 223 | const stored: StoredConfig = keychain |
| 174 | 224 | ? { url, keychain: true } |
| 175 | 225 | : { url, token: config.token }; |
| 226 | if (opts.insecure === true && plainHttpRemote(url)) stored.insecure = true; | |
| 176 | 227 | // Create the file before the secret goes in it: a chmod after the write is |
| 177 | 228 | // a window, however short, where the token is world-readable. |
| 178 | 229 | await writeFile(path, "", { mode: 0o600 }); |
src/cli/main.ts +19 −2
| @@ -20,6 +20,8 @@ import { | ||
| 20 | 20 | configPath, |
| 21 | 21 | loadConfig, |
| 22 | 22 | normalizeUrl, |
| 23 | PLAIN_HTTP_REFUSAL, | |
| 24 | plainHttpRemote, | |
| 23 | 25 | rootDir, |
| 24 | 26 | saveConfig, |
| 25 | 27 | type Config, |
| @@ -109,7 +111,7 @@ function usage(out: Out): void { | ||
| 109 | 111 | out.line(`forge ${VERSION} — your workbench, on this machine`); |
| 110 | 112 | out.line(); |
| 111 | 113 | 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]"); | |
| 113 | 115 | out.line(b(" forge logout")); |
| 114 | 116 | out.line(b(" forge whoami")); |
| 115 | 117 | out.line( |
| @@ -142,6 +144,13 @@ async function login(out: Out, args: Args): Promise<number> { | ||
| 142 | 144 | return EXIT.usage; |
| 143 | 145 | } |
| 144 | 146 | 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 | } | |
| 145 | 154 | const token = args.value("token") ?? (await askSecret(`Token for ${url}: `)); |
| 146 | 155 | if (token === "") { |
| 147 | 156 | out.err("forge login: no token given"); |
| @@ -155,7 +164,7 @@ async function login(out: Out, args: Args): Promise<number> { | ||
| 155 | 164 | out.err(`forge login: ${me.error.message}`); |
| 156 | 165 | return me.error.status === 401 ? EXIT.notConfigured : EXIT.failed; |
| 157 | 166 | } |
| 158 | const stored = await saveConfig({ url, token }); | |
| 167 | const stored = await saveConfig({ url, token }, { insecure }); | |
| 159 | 168 | out.line( |
| 160 | 169 | `${out.paint(G.check, out.c.success)} signed in to ${url} as ${me.value.username}`, |
| 161 | 170 | ); |
| @@ -402,6 +411,10 @@ async function openTui(out: Out, args: Args): Promise<number> { | ||
| 402 | 411 | return EXIT.usage; |
| 403 | 412 | } |
| 404 | 413 | const loaded = await loadConfig(); |
| 414 | if (loaded === "plain_http") { | |
| 415 | out.err(`forge: ${PLAIN_HTTP_REFUSAL}`); | |
| 416 | return EXIT.usage; | |
| 417 | } | |
| 405 | 418 | const config = typeof loaded === "string" ? null : loaded.config; |
| 406 | 419 | const { runLocalTui } = await import("../tui/local/app.ts"); |
| 407 | 420 | await runLocalTui({ |
| @@ -423,6 +436,10 @@ async function requireConfig(out: Out): Promise<Config | number> { | ||
| 423 | 436 | out.err(`forge: ${configPath()} has no usable token — run \`forge login\``); |
| 424 | 437 | return EXIT.notConfigured; |
| 425 | 438 | } |
| 439 | if (loaded === "plain_http") { | |
| 440 | out.err(`forge: ${PLAIN_HTTP_REFUSAL}`); | |
| 441 | return EXIT.usage; | |
| 442 | } | |
| 426 | 443 | return loaded.config; |
| 427 | 444 | } |
| 428 | 445 |
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 | }); |
new above
offline