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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7400-forge-sync-gives-git-the-token-only-for → main 2 commits fork 1 thread · 1 unresolved
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 | ||
| 300 | 300 | proved that moving the branch cannot lose a commit. A dirty repo is reported |
| 301 | 301 | and left exactly as you left it. |
| 302 | 302 | |
| 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 | ||
| 303 | 308 | `forge` with no arguments opens the local UI on the same runtime as the SSH |
| 304 | 309 | one: the library of clones, then a lazygit-shaped working-tree screen — |
| 305 | 310 | `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[] { | ||
| 109 | 109 | if (r.fastForwarded) state.push("fast-forwarded"); |
| 110 | 110 | if (r.cloned) state.push("cloned"); |
| 111 | 111 | if (r.upstream === null) state.push("no upstream"); |
| 112 | if (r.note !== undefined) state.push("no token sent"); | |
| 112 | 113 | if (r.error !== null) state.push("failed"); |
| 113 | 114 | |
| 114 | 115 | const tone = |
src/cli/sync.ts +48 −4
| @@ -40,6 +40,8 @@ export interface RepoReport { | ||
| 40 | 40 | readonly lastFetch: Date | null; |
| 41 | 41 | readonly fastForwarded: boolean; |
| 42 | 42 | readonly error: string | null; |
| 43 | /** Why git ran without the token, in one line; absent when it had it. */ | |
| 44 | readonly note?: string; | |
| 43 | 45 | } |
| 44 | 46 | |
| 45 | 47 | export interface SyncReport { |
| @@ -140,10 +142,14 @@ async function visit( | ||
| 140 | 142 | ): Promise<RepoReport> { |
| 141 | 143 | const dir = join(root, owner, name); |
| 142 | 144 | const label = `${owner}/${name}`; |
| 143 | const env = tokenGitEnv(opts.token); | |
| 144 | 145 | const progress = (text: string): void => opts.onProgress?.(label, text); |
| 145 | 146 | let cloned = false; |
| 146 | 147 | let error: string | null = null; |
| 148 | let note: string | undefined; | |
| 149 | const withheld = (why: string): void => { | |
| 150 | note = why; | |
| 151 | progress(why); | |
| 152 | }; | |
| 147 | 153 | |
| 148 | 154 | const exists = await Bun.file(join(dir, ".git/HEAD")).exists(); |
| 149 | 155 | if (!exists) { |
| @@ -151,22 +157,30 @@ async function visit( | ||
| 151 | 157 | return absent(owner, name, dir, pin !== null); |
| 152 | 158 | await mkdir(join(root, owner), { recursive: true }); |
| 153 | 159 | const url = await remoteFor(pin); |
| 160 | const why = url.ssh ? null : tokenRefusal(url.url, opts.api.url); | |
| 161 | if (why !== null) withheld(why); | |
| 154 | 162 | const result = await git.clone(url.url, dir, { |
| 155 | 163 | parent: join(root, owner), |
| 156 | 164 | onProgress: progress, |
| 157 | ...(url.ssh ? {} : { env }), | |
| 165 | ...(url.ssh || why !== null ? {} : { env: tokenGitEnv(opts.token) }), | |
| 158 | 166 | }); |
| 159 | 167 | 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 | }; | |
| 161 | 173 | cloned = true; |
| 162 | 174 | if (pin.origin !== null) |
| 163 | 175 | await git.setRemote(dir, "upstream", pin.origin.htmlUrl); |
| 164 | 176 | } else if (opts.fetch) { |
| 165 | 177 | const url = await git.remoteUrl(dir); |
| 166 | 178 | const ssh = url !== null && !url.startsWith("http"); |
| 179 | const why = ssh ? null : tokenRefusal(url, opts.api.url); | |
| 180 | if (why !== null) withheld(why); | |
| 167 | 181 | const result = await git.fetch(dir, { |
| 168 | 182 | onProgress: progress, |
| 169 | ...(ssh ? {} : { env }), | |
| 183 | ...(ssh || why !== null ? {} : { env: tokenGitEnv(opts.token) }), | |
| 170 | 184 | }); |
| 171 | 185 | if (!result.ok) error = result.error.stderr; |
| 172 | 186 | } |
| @@ -203,6 +217,7 @@ async function visit( | ||
| 203 | 217 | pinned: pin !== null, |
| 204 | 218 | fastForwarded, |
| 205 | 219 | error, |
| 220 | ...(note === undefined ? {} : { note }), | |
| 206 | 221 | ...after, |
| 207 | 222 | }; |
| 208 | 223 | } |
| @@ -287,6 +302,35 @@ function absent( | ||
| 287 | 302 | }; |
| 288 | 303 | } |
| 289 | 304 | |
| 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 | ||
| 290 | 334 | /** |
| 291 | 335 | * SSH when the user has a key and the forge offered an `ssh://` URL: their |
| 292 | 336 | * 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"; | ||
| 15 | 15 | import { join } from "node:path"; |
| 16 | 16 | import { createApp, type App } from "../src/app.ts"; |
| 17 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| 18 | import type { ForgeApi, Pin } from "../src/cli/api.ts"; | |
| 18 | 19 | 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"; | |
| 20 | 21 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 21 | 22 | import { seedAdmin } from "./helpers/admin.ts"; |
| 22 | 23 | |
| @@ -377,3 +378,114 @@ describe("the local client, against a real forge", () => { | ||
| 377 | 378 | expect(result.err).toContain("forge login"); |
| 378 | 379 | }); |
| 379 | 380 | }); |
| 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 | }); |
new above
offline