Public: anyone can read this; the crew can change it.
scripts/drills/run.ts refuses to send the admin token to a plain-http forge that is not loopback, and strips control characters from what it prints #2203
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2202-scripts-drills-run-ts-refuses-to-send-th → main 1 commit fork
3 files
changed +94
−13
README.md +1 −1
Diff not shown — a line 4,279 characters wide (limit 2,000). +1 −1
View filescripts/drills/run.ts +47 −11
| @@ -27,8 +27,15 @@ export interface DrillRun { | ||
| 27 | 27 | const SEEN_MAX = 8_000; |
| 28 | 28 | const DEFAULT_CEILING_MS = 3_600_000; |
| 29 | 29 | |
| 30 | /** C0 and C1 control characters out (tab stays): the forge's words never drive the terminal. */ | |
| 31 | export const clean = (s: string): string => s.replace(/[\x00-\x08\x0a-\x1f\x7f-\x9f]/g, ""); | |
| 32 | ||
| 30 | 33 | /** Every string in a JSON value, or the text's own lines when it is not JSON. */ |
| 31 | 34 | function linesOf(text: string, contentType: string): string[] { |
| 35 | return rawLinesOf(text, contentType).map(clean).filter((l) => l.trim() !== ""); | |
| 36 | } | |
| 37 | ||
| 38 | function rawLinesOf(text: string, contentType: string): string[] { | |
| 32 | 39 | if (contentType.includes("json")) { |
| 33 | 40 | try { |
| 34 | 41 | const out: string[] = []; |
| @@ -159,21 +166,50 @@ export async function runDrill(run: DrillRun): Promise<number> { | ||
| 159 | 166 | return 0; |
| 160 | 167 | } |
| 161 | 168 | |
| 169 | export interface DrillArgs { | |
| 170 | domain: string; | |
| 171 | forge: string; | |
| 172 | token: string; | |
| 173 | } | |
| 174 | ||
| 175 | const USAGE = "usage: FORGE_TOKEN=… bun scripts/drills/run.ts <domain> --forge <url> [--insecure]"; | |
| 176 | const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); | |
| 177 | ||
| 178 | /** The options, or the one plain line to print before exiting 2. */ | |
| 179 | export function checkArgs( | |
| 180 | argv: string[], | |
| 181 | env: Record<string, string | undefined>, | |
| 182 | isTTY: boolean, | |
| 183 | ): { ok: true; args: DrillArgs } | { ok: false; error: string } { | |
| 184 | const at = argv.indexOf("--forge"); | |
| 185 | const forge = at >= 0 ? argv[at + 1] : undefined; | |
| 186 | const insecure = argv.includes("--insecure"); | |
| 187 | const domain = argv.find((a, i) => !a.startsWith("--") && i !== at + 1); | |
| 188 | if (!domain) return { ok: false, error: `a domain is required; ${USAGE}` }; | |
| 189 | if (!forge) return { ok: false, error: `--forge <url> is required; ${USAGE}` }; | |
| 190 | let url: URL; | |
| 191 | try { url = new URL(forge); } catch { return { ok: false, error: `--forge must be an http(s) URL; ${USAGE}` }; } | |
| 192 | if (url.protocol !== "http:" && url.protocol !== "https:") { | |
| 193 | return { ok: false, error: `--forge must be an http(s) URL; ${USAGE}` }; | |
| 194 | } | |
| 195 | const token = env.FORGE_TOKEN ?? ""; | |
| 196 | if (token === "") return { ok: false, error: `FORGE_TOKEN is empty; ${USAGE}` }; | |
| 197 | if (!isTTY) return { ok: false, error: "stdin is not a terminal; run the drill from an interactive shell" }; | |
| 198 | if (url.protocol === "http:" && !LOOPBACK.has(url.hostname) && !insecure) { | |
| 199 | return { ok: false, error: `refusing to send the admin token over plain http to ${url.hostname}; use https, or pass --insecure` }; | |
| 200 | } | |
| 201 | return { ok: true, args: { domain, forge: forge.replace(/\/+$/, ""), token } }; | |
| 202 | } | |
| 203 | ||
| 162 | 204 | if (import.meta.main) { |
| 163 | const args = process.argv.slice(2); | |
| 164 | const at = args.indexOf("--forge"); | |
| 165 | const forge = at >= 0 ? args[at + 1] : undefined; | |
| 166 | const domain = args.find((a, i) => !a.startsWith("--") && i !== at + 1); | |
| 167 | const token = process.env.FORGE_TOKEN ?? ""; | |
| 168 | if (!domain || !forge || !/^https?:\/\//.test(forge) || token === "") { | |
| 169 | console.error("usage: FORGE_TOKEN=… bun scripts/drills/run.ts <domain> --forge <url>"); | |
| 205 | const checked = checkArgs(process.argv.slice(2), process.env, process.stdin.isTTY === true); | |
| 206 | if (!checked.ok) { | |
| 207 | console.error(checked.error); | |
| 170 | 208 | process.exit(2); |
| 171 | 209 | } |
| 172 | 210 | const code = await runDrill({ |
| 173 | domain, | |
| 174 | forge: forge.replace(/\/+$/, ""), | |
| 175 | token, | |
| 176 | say: (l) => console.log(l), | |
| 211 | ...checked.args, | |
| 212 | say: (l) => console.log(clean(l)), | |
| 177 | 213 | enter: async () => { |
| 178 | 214 | for await (const _ of console) break; |
| 179 | 215 | }, |
test/drill-run.test.ts +46 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { afterEach, expect, test } from "bun:test"; |
| 2 | import { runDrill } from "../scripts/drills/run.ts"; | |
| 2 | import { checkArgs, runDrill } from "../scripts/drills/run.ts"; | |
| 3 | 3 | |
| 4 | 4 | let server: ReturnType<typeof Bun.serve> | undefined; |
| 5 | 5 | afterEach(() => server?.stop(true)); |
| @@ -116,3 +116,48 @@ test("a sign-in redirect from / is up, and a changing counter is one line", asyn | ||
| 116 | 116 | expect(state.posts).toHaveLength(1); |
| 117 | 117 | expect(h.lines.filter((l) => l.startsWith("health: up "))).toHaveLength(1); |
| 118 | 118 | }); |
| 119 | ||
| 120 | test("checkArgs: each missing piece and the cleartext refusal is an error; the good case is options", () => { | |
| 121 | const env = { FORGE_TOKEN: "t" }; | |
| 122 | const bad = (argv: string[], e: Record<string, string | undefined>, tty: boolean) => { | |
| 123 | const r = checkArgs(argv, e, tty); | |
| 124 | expect(r.ok).toBe(false); | |
| 125 | return r.ok ? "" : r.error; | |
| 126 | }; | |
| 127 | expect(bad(["--forge", "https://f.example"], env, true)).toContain("domain"); | |
| 128 | expect(bad(["runner"], env, true)).toContain("--forge"); | |
| 129 | expect(bad(["runner", "--forge", "https://f.example"], { FORGE_TOKEN: "" }, true)).toContain("FORGE_TOKEN"); | |
| 130 | expect(bad(["runner", "--forge", "https://f.example"], env, false)).toContain("not a terminal"); | |
| 131 | expect(bad(["runner", "--forge", "http://forge.example"], env, true)).toContain("plain http"); | |
| 132 | const ok = (argv: string[]) => checkArgs(argv, env, true); | |
| 133 | expect(ok(["runner", "--forge", "http://forge.example", "--insecure"]).ok).toBe(true); | |
| 134 | for (const host of ["localhost:3000", "127.0.0.1:1", "[::1]:1"]) expect(ok(["runner", "--forge", `http://${host}/`]).ok).toBe(true); | |
| 135 | expect(ok(["runner", "--forge", "https://f.example/"])).toEqual({ | |
| 136 | ok: true, | |
| 137 | args: { domain: "runner", forge: "https://f.example", token: "t" }, | |
| 138 | }); | |
| 139 | }); | |
| 140 | ||
| 141 | test("control characters the forge sends are removed before printing or posting; tab stays", async () => { | |
| 142 | const state: Stub = { down: false, posts: [], refuse: false }; | |
| 143 | server = Bun.serve({ | |
| 144 | port: 0, | |
| 145 | hostname: "127.0.0.1", | |
| 146 | async fetch(req) { | |
| 147 | const path = new URL(req.url).pathname; | |
| 148 | if (path === "/api/v1/health") | |
| 149 | return state.down | |
| 150 | ? Response.json({ ok: false, note: "bad\x1b[31mRED\x07\tx\u009b" }, { status: 503 }) | |
| 151 | : Response.json({ ok: true }); | |
| 152 | if (path === "/") return new Response("<p>fine</p>", { headers: { "content-type": "text/html" } }); | |
| 153 | state.posts.push({ auth: null, body: await req.json() }); | |
| 154 | return Response.json({ green: true, why: "ok" }, { status: 201 }); | |
| 155 | }, | |
| 156 | }); | |
| 157 | const h = harness(state); | |
| 158 | expect(await h.run("runner", `http://127.0.0.1:${server.port}`)).toBe(0); | |
| 159 | const said = h.lines.find((l) => l.startsWith("health: "))!; | |
| 160 | expect(said).toBe("health: bad[31mRED\tx"); | |
| 161 | expect(said).not.toContain("\x1b"); | |
| 162 | expect(JSON.stringify(state.posts[0]!.body)).not.toContain("\\u001b"); | |
| 163 | }); |
new above
offline