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

A private repository brought from another forge clones with its credential never in argv, config or logs #8988

Merged opened this

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

Merged

scout/forge:scout/8963-a-private-repository-brought-from-anothe → main 1 commit fork

  • Merged. scout/8963-a-private-repository-brought-from-anothe was merged into main as af117b3.

Review

Sign in to review this change.
1 file changed +166 −0
test/bring-private-credential.test.ts added +166
@@ -0,0 +1,166 @@
1 /**
2 * A private repository brought from a connected forge: its credential rides
3 * in git's environment (`GIT_CONFIG_*` → a URL-scoped `http.extraHeader`) and
4 * nowhere else. Not on any command line, not in the clone's config, not in
5 * the job a person reads, not in anything the server printed.
6 *
7 * argv is caught by a `git` shim first on PATH that records every argument
8 * of every invocation and then runs the real git — so the assertion is about
9 * what was actually spawned, not about what the code means to spawn.
10 */
11
12 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
13 import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
14 import { tmpdir } from "node:os";
15 import { join } from "node:path";
16 import { createApp, type App } from "../src/app.ts";
17 import { createOrigin } from "../src/core/origins.ts";
18 import { getRepo } from "../src/core/repos.ts";
19 import { getUserByName } from "../src/core/users.ts";
20 import { repoDir } from "../src/git/repos.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { settled } from "../src/origins/jobs.ts";
23 import { syncsSettled } from "../src/origins/sync.ts";
24 import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
25 import { proveAll } from "./helpers/verified.ts";
26 import { seedAdmin } from "./helpers/admin.ts";
27
28 setDefaultTimeout(60_000);
29
30 const LOGIN = "octo";
31 const TOKEN = "octo-private-token-5d1f9a";
32 const PASSWORD = "alice-secret-7089";
33 const basic = (user: string, secret: string): string =>
34 `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`;
35 /** Every form the credential could leak in: plaintext, and the header blob. */
36 const LEAKS = [TOKEN, Buffer.from(`${LOGIN}:${TOKEN}`).toString("base64")];
37
38 describe("bringing a private repository", () => {
39 const dirs: string[] = [];
40 let app: App;
41 let gitApp: App;
42 let servers: Servers;
43 let gitServers: Servers;
44 let fake: FakeForge;
45 let argvLog = "";
46 const path = process.env.PATH;
47 const printed: string[] = [];
48 const consoles = ["log", "info", "warn", "error", "debug"] as const;
49 const originals = consoles.map((name) => console[name]);
50
51 beforeAll(async () => {
52 for (const prefix of ["forge-bringp-", "forge-bringp-git-", "forge-bringp-shim-"])
53 dirs.push(await mkdtemp(join(tmpdir(), prefix)));
54 const shim = dirs[2]!;
55 argvLog = join(shim, "argv.log");
56 const real = Bun.which("git");
57 if (real === null) throw new Error("no git on PATH");
58 // One argument per line, a blank line between invocations.
59 await writeFile(
60 join(shim, "git"),
61 `#!/bin/sh\nfor a in "$@"; do printf '%s\\n' "$a" >> '${argvLog}'; done\nprintf '\\n' >> '${argvLog}'\nexec '${real}' "$@"\n`,
62 );
63 await chmod(join(shim, "git"), 0o755);
64 process.env.PATH = `${shim}:${path ?? "/usr/bin:/bin"}`;
65
66 gitApp = createApp({ dataDir: dirs[1]!, port: 0 });
67 gitServers = await serve(gitApp);
68 await seedAdmin(gitApp, { username: LOGIN, email: "[email protected]", password: TOKEN });
69 proveAll(gitApp);
70 const auth = { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) };
71 const repo = await fetch(`${gitServers.url}/api/v1/repos`, {
72 method: "POST",
73 headers: auth,
74 body: JSON.stringify({ name: "vault", visibility: "private" }),
75 });
76 expect(repo.status).toBeLessThan(300);
77 const made = await fetch(`${gitServers.url}/api/v1/repos/octo/vault/contents`, {
78 method: "POST",
79 headers: auth,
80 body: JSON.stringify({
81 branch: "main",
82 message: "a private thing",
83 files: [{ path: "README.md", content: "private\n" }],
84 }),
85 });
86 expect(made.status).toBeLessThan(300);
87 // Private for real: without the credential the upstream will not serve it.
88 const anon = await fetch(`${gitServers.url}/octo/vault.git/info/refs?service=git-upload-pack`);
89 expect(anon.status).not.toBe(200);
90
91 const state = newFakeState("forgejo", LOGIN, TOKEN);
92 state.repos.push({
93 id: 77,
94 fullName: "octo/vault",
95 private: true,
96 defaultBranch: "main",
97 cloneUrl: `${gitServers.url}/octo/vault.git`,
98 });
99 fake = serveFakeForge(state);
100 state.repos[0]!.htmlUrl = `${fake.url}/octo/vault`;
101 app = createApp({ dataDir: dirs[0]!, port: 0 });
102 servers = await serve(app);
103 await seedAdmin(app, { username: "alice", email: "[email protected]", password: PASSWORD });
104 proveAll(app);
105 }, 120_000);
106
107 afterAll(async () => {
108 consoles.forEach((name, i) => (console[name] = originals[i]!));
109 process.env.PATH = path;
110 await syncsSettled();
111 fake.stop();
112 servers.stop();
113 gitServers.stop();
114 app.close();
115 gitApp.close();
116 for (const d of dirs) await rm(d, { recursive: true, force: true, maxRetries: 5 });
117 }, 120_000);
118
119 test("it comes across, and the token is in no argv, no config and no log", async () => {
120 const made = createOrigin(app.db, app.config.dataDir, {
121 userId: getUserByName(app.db, "alice")!.id,
122 kind: "forgejo",
123 apiUrl: fake.apiUrl,
124 webUrl: fake.url,
125 login: LOGIN,
126 token: TOKEN,
127 });
128 if (!made.ok) throw new Error(made.error);
129
130 consoles.forEach((name) => {
131 console[name] = (...args: unknown[]) => void printed.push(args.map(String).join(" "));
132 });
133 const started = await fetch(`${servers.url}/bring`, {
134 method: "POST",
135 redirect: "manual",
136 headers: { authorization: basic("alice", PASSWORD), "content-type": "application/x-www-form-urlencoded" },
137 body: new URLSearchParams({ link: `${fake.url}/octo/vault` }).toString(),
138 });
139 expect(started.status).toBe(303);
140 const where = started.headers.get("location")!;
141 const job = await settled(where.split("/").pop()!);
142 const page = await fetch(`${servers.url}${where}`, { headers: { authorization: basic("alice", PASSWORD) } });
143 const pageText = await page.text();
144 consoles.forEach((name, i) => (console[name] = originals[i]!));
145
146 // It worked, so the credential did reach the upstream — by the env.
147 expect(job?.stage).toBe("done");
148 const repo = getRepo(app.db, "alice", "vault")!;
149 expect(repo).toBeDefined();
150 const dir = repoDir(app.reposRoot, "alice", "vault");
151 const head = Bun.spawnSync(["git", "rev-parse", "--verify", "refs/heads/main"], { cwd: dir });
152 expect(head.exitCode).toBe(0);
153
154 const argv = await readFile(argvLog, "utf8");
155 // The shim saw the fetch from the upstream, so its silence below means something.
156 expect(argv).toContain(`${gitServers.url}/octo/vault.git`);
157 const config = await readFile(join(dir, "config"), "utf8");
158 expect(config).toContain(`${gitServers.url}/octo/vault.git`);
159 const log = [JSON.stringify(job), pageText, ...printed].join("\n");
160 for (const leak of LEAKS) {
161 expect(argv).not.toContain(leak);
162 expect(config).not.toContain(leak);
163 expect(log).not.toContain(leak);
164 }
165 });
166 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs