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

`forge sync` gives git the token only for an https (or loopback) remote on the workbench's own host #7408

Merged opened this

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

Merged

scout/forge:scout/7400-forge-sync-gives-git-the-token-only-for → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/7400-forge-sync-gives-git-the-token-only-for was merged into main as 2d11ad2.

Review

Sign in to review this change.
4 files changed +167 −5
README.md +5
@@ -300,6 +300,11 @@ ahead, no rebase or merge is half-finished, and `merge-base --is-ancestor` has
300300 proved that moving the branch cannot lose a commit. A dirty repo is reported
301301 and left exactly as you left it.
302302
303 `sync` hands git your token only for a remote on the workbench's own origin
304 (scheme, host and port) over https, or plain http to loopback. A clone whose
305 `origin` was edited to anywhere else is fetched without the token, and its row
306 says `no token sent`.
307
303308 `forge` with no arguments opens the local UI on the same runtime as the SSH
304309 one: the library of clones, then a lazygit-shaped working-tree screen —
305310 `space` stages, `enter` opens the hunks, `s` stages one, `c` commits, `P`
src/cli/report.ts +1
@@ -109,6 +109,7 @@ function row(out: Out, r: RepoReport): Cell[] {
109109 if (r.fastForwarded) state.push("fast-forwarded");
110110 if (r.cloned) state.push("cloned");
111111 if (r.upstream === null) state.push("no upstream");
112 if (r.note !== undefined) state.push("no token sent");
112113 if (r.error !== null) state.push("failed");
113114
114115 const tone =
src/cli/sync.ts +48 −4
@@ -40,6 +40,8 @@ export interface RepoReport {
4040 readonly lastFetch: Date | null;
4141 readonly fastForwarded: boolean;
4242 readonly error: string | null;
43 /** Why git ran without the token, in one line; absent when it had it. */
44 readonly note?: string;
4345 }
4446
4547 export interface SyncReport {
@@ -140,10 +142,14 @@ async function visit(
140142 ): Promise<RepoReport> {
141143 const dir = join(root, owner, name);
142144 const label = `${owner}/${name}`;
143 const env = tokenGitEnv(opts.token);
144145 const progress = (text: string): void => opts.onProgress?.(label, text);
145146 let cloned = false;
146147 let error: string | null = null;
148 let note: string | undefined;
149 const withheld = (why: string): void => {
150 note = why;
151 progress(why);
152 };
147153
148154 const exists = await Bun.file(join(dir, ".git/HEAD")).exists();
149155 if (!exists) {
@@ -151,22 +157,30 @@ async function visit(
151157 return absent(owner, name, dir, pin !== null);
152158 await mkdir(join(root, owner), { recursive: true });
153159 const url = await remoteFor(pin);
160 const why = url.ssh ? null : tokenRefusal(url.url, opts.api.url);
161 if (why !== null) withheld(why);
154162 const result = await git.clone(url.url, dir, {
155163 parent: join(root, owner),
156164 onProgress: progress,
157 ...(url.ssh ? {} : { env }),
165 ...(url.ssh || why !== null ? {} : { env: tokenGitEnv(opts.token) }),
158166 });
159167 if (!result.ok)
160 return { ...absent(owner, name, dir, true), error: result.error.stderr };
168 return {
169 ...absent(owner, name, dir, true),
170 error: result.error.stderr,
171 ...(note === undefined ? {} : { note }),
172 };
161173 cloned = true;
162174 if (pin.origin !== null)
163175 await git.setRemote(dir, "upstream", pin.origin.htmlUrl);
164176 } else if (opts.fetch) {
165177 const url = await git.remoteUrl(dir);
166178 const ssh = url !== null && !url.startsWith("http");
179 const why = ssh ? null : tokenRefusal(url, opts.api.url);
180 if (why !== null) withheld(why);
167181 const result = await git.fetch(dir, {
168182 onProgress: progress,
169 ...(ssh ? {} : { env }),
183 ...(ssh || why !== null ? {} : { env: tokenGitEnv(opts.token) }),
170184 });
171185 if (!result.ok) error = result.error.stderr;
172186 }
@@ -203,6 +217,7 @@ async function visit(
203217 pinned: pin !== null,
204218 fastForwarded,
205219 error,
220 ...(note === undefined ? {} : { note }),
206221 ...after,
207222 };
208223 }
@@ -287,6 +302,35 @@ function absent(
287302 };
288303 }
289304
305 /**
306 * Why the token must not go to this git URL, or null when it may: the URL has
307 * the workbench's own origin (scheme, host, port), and is https or plain http
308 * to loopback. An edited `origin` must not be able to collect the token.
309 */
310 export function tokenRefusal(
311 remote: string | null,
312 workbench: string,
313 ): string | null {
314 const refuse = (what: string): string =>
315 `token withheld: origin ${what} is not this workbench over https`;
316 if (remote === null) return refuse("(unreadable)");
317 let r: URL;
318 let w: URL;
319 try {
320 r = new URL(remote);
321 w = new URL(workbench);
322 } catch {
323 return refuse("(unparseable)");
324 }
325 if (r.origin !== w.origin) return refuse(r.origin);
326 if (r.protocol === "https:") return null;
327 const loopback =
328 r.hostname === "localhost" ||
329 r.hostname === "[::1]" ||
330 /^127\./.test(r.hostname);
331 return r.protocol === "http:" && loopback ? null : refuse(r.origin);
332 }
333
290334 /**
291335 * SSH when the user has a key and the forge offered an `ssh://` URL: their
292336 * agent and `~/.ssh/config` then do the authentication, and no token ever
test/cli-sync.test.ts +113 −1
@@ -15,8 +15,9 @@ import { tmpdir } from "node:os";
1515 import { join } from "node:path";
1616 import { createApp, type App } from "../src/app.ts";
1717 import { serve, type Servers } from "../src/http/server.ts";
18 import type { ForgeApi, Pin } from "../src/cli/api.ts";
1819 import { hostDir } from "../src/cli/config.ts";
19 import { changesBetween, type RepoReport, type SyncReport } from "../src/cli/sync.ts";
20 import { changesBetween, syncOnce, tokenRefusal, type RepoReport, type SyncReport } from "../src/cli/sync.ts";
2021 import { gitClientEnv } from "./helpers/git-client.ts";
2122 import { seedAdmin } from "./helpers/admin.ts";
2223
@@ -377,3 +378,114 @@ describe("the local client, against a real forge", () => {
377378 expect(result.err).toContain("forge login");
378379 });
379380 });
381
382 describe("tokenRefusal", () => {
383 const wb = "https://forge.example";
384 test("only the workbench's own origin over https, or loopback http, gets the token", () => {
385 expect(tokenRefusal("https://forge.example/a/b.git", wb)).toBeNull();
386 expect(tokenRefusal("http://127.0.0.1:3000/a/b.git", "http://127.0.0.1:3000")).toBeNull();
387 expect(tokenRefusal("http://other.example/a/b.git", wb)).toContain("token withheld");
388 expect(tokenRefusal("https://other.example/a/b.git", wb)).toContain("token withheld");
389 expect(tokenRefusal("http://forge.example/a/b.git", wb)).toContain("token withheld");
390 expect(tokenRefusal("https://forge.example:8443/a/b.git", wb)).toContain("token withheld");
391 expect(tokenRefusal("http://10.0.0.5/a.git", "http://10.0.0.5")).toContain("token withheld");
392 expect(tokenRefusal(null, wb)).toContain("token withheld");
393 });
394 });
395
396 describe("the token reaches git only for the workbench's own origin", () => {
397 const secret = "tok-secret-7400";
398 let tmp = "";
399 const seen: { port: number; auth: string | null }[] = [];
400 const listeners: ReturnType<typeof Bun.serve>[] = [];
401
402 /** A local listener that records Authorization and refuses every request. */
403 function listen(): ReturnType<typeof Bun.serve> {
404 const server = Bun.serve({
405 port: 0,
406 hostname: "127.0.0.1",
407 fetch: (req) => {
408 seen.push({ port: server.port ?? 0, auth: req.headers.get("authorization") });
409 return new Response("no", { status: 404 });
410 },
411 });
412 listeners.push(server);
413 return server;
414 }
415 const stubApi = (url: string, pins: Pin[]): ForgeApi =>
416 ({
417 url,
418 pinsUnavailable: false,
419 pins: async () => ({ ok: true, value: pins }),
420 }) as unknown as ForgeApi;
421 const pin = (cloneUrl: string): Pin => ({
422 owner: "alice",
423 name: "one",
424 cloneUrl,
425 sshUrl: null,
426 defaultBranch: "main",
427 origin: null,
428 });
429
430 beforeAll(async () => {
431 tmp = await mkdtemp(join(tmpdir(), "forge-token-"));
432 });
433 afterAll(async () => {
434 for (const l of listeners) l.stop(true);
435 await rm(tmp, { recursive: true, force: true });
436 });
437
438 test("a clone from a foreign cloneUrl is sent without the token", async () => {
439 const own = listen();
440 const other = listen();
441 seen.length = 0;
442 const root = join(tmp, "clone");
443 const report = await syncOnce({
444 api: stubApi(`http://127.0.0.1:${own.port}`, [pin(`http://127.0.0.1:${other.port}/alice/one.git`)]),
445 token: secret,
446 root,
447 fetch: true,
448 });
449 const one = report.repos[0]!;
450 expect(one.note).toContain("token withheld");
451 expect(one.error).not.toBeNull();
452 const hits = seen.filter((h) => h.port === other.port);
453 expect(hits.length).toBeGreaterThan(0);
454 expect(hits.some((h) => (h.auth ?? "").startsWith("Bearer"))).toBe(false);
455 });
456
457 test("a fetch of an edited origin is sent without the token", async () => {
458 const own = listen();
459 const other = listen();
460 const api = stubApi(`http://127.0.0.1:${own.port}`, []);
461 const root = join(tmp, "fetch");
462 const dir = join(root, hostDir(api.url), "alice", "one");
463 await sh(["git", "init", "-q", dir], tmp);
464 await sh(["git", "remote", "add", "origin", `http://127.0.0.1:${other.port}/alice/one.git`], dir);
465 seen.length = 0;
466 const report = await syncOnce({ api, token: secret, root, fetch: true });
467 const one = report.repos[0]!;
468 expect(one.note).toContain("token withheld");
469 const hits = seen.filter((h) => h.port === other.port);
470 expect(hits.length).toBeGreaterThan(0);
471 expect(hits.some((h) => (h.auth ?? "").startsWith("Bearer"))).toBe(false);
472 });
473
474 test("the workbench's own origin still gets the token, on clone and on fetch", async () => {
475 const own = listen();
476 const api = stubApi(`http://127.0.0.1:${own.port}`, [pin(`http://127.0.0.1:${own.port}/alice/one.git`)]);
477 const root = join(tmp, "own");
478 seen.length = 0;
479 const first = await syncOnce({ api, token: secret, root, fetch: true });
480 expect(first.repos[0]!.note).toBeUndefined();
481 expect(seen.some((h) => h.auth === `Bearer ${secret}`)).toBe(true);
482 // Not cloned (the listener refuses), so make it a clone to exercise fetch.
483 const dir = join(root, hostDir(api.url), "alice", "one");
484 await sh(["git", "init", "-q", dir], tmp);
485 await sh(["git", "remote", "add", "origin", `http://127.0.0.1:${own.port}/alice/one.git`], dir);
486 seen.length = 0;
487 const second = await syncOnce({ api, token: secret, root, fetch: true });
488 expect(second.repos[0]!.note).toBeUndefined();
489 expect(seen.some((h) => h.auth === `Bearer ${secret}`)).toBe(true);
490 });
491 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs