Public: anyone can read this; the crew can change it.
Origin discovery and migrate refuse a host that resolves to a private address, at every redirect hop #3049
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2993-origin-discovery-and-migrate-refuse-a-ho → main 1 commit fork 1 thread · 1 unresolved
10 files
changed +161
−32
docs/ORIGINS.md +3 −2
| @@ -111,8 +111,9 @@ publishes neither, so the user names it (`kind: "github"` with a host → | ||
| 111 | 111 | `/api/v3`). `whoami()` then validates the credential and fixes `login`. |
| 112 | 112 | |
| 113 | 113 | Discovery fetches a host a stranger named, so it is https-only, refuses |
| 114 | private address literals and non-http schemes, follows no redirect off-host, | |
| 115 | caps the body at 64 KB and times out in 5 s. An operator can vouch for a LAN | |
| 114 | resolves the name and refuses any private answer (`vetHost`, at every hop, | |
| 115 | dialling the address it judged), refuses non-http schemes, follows no redirect | |
| 116 | off-host, reaches loopback only in dev (`FORGE_DEV=1`), caps the body at 64 KB and times out in 5 s. An operator can vouch for a LAN | |
| 116 | 117 | or plaintext host with `FORGE_ORIGIN_ALLOW=host[,host…]`; there is no |
| 117 | 118 | per-user toggle, because the risk it accepts belongs to the server. |
| 118 | 119 |
src/actions/migrate.ts +16 −9
| @@ -28,6 +28,7 @@ import type { User } from "../core/users.ts"; | ||
| 28 | 28 | import { runGitCapture } from "../git/env.ts"; |
| 29 | 29 | import { isSafeRef } from "../git/guard.ts"; |
| 30 | 30 | import { repoDir } from "../git/repos.ts"; |
| 31 | import { type Resolve, vetHost } from "../kernel/outbound.ts"; | |
| 31 | 32 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 32 | 33 | import { providerForOrigin } from "../origins/credential.ts"; |
| 33 | 34 | import { redact, safeRemoteUrl, secretsIn } from "../origins/credentials.ts"; |
| @@ -70,6 +71,8 @@ export interface MigrateInput { | ||
| 70 | 71 | readonly visibility?: "public" | "private"; |
| 71 | 72 | /** Put an agent on it: the crew makes it run here. */ |
| 72 | 73 | readonly crew?: { readonly agent: string; readonly autonomous: boolean }; |
| 74 | /** Name → addresses, for the private-address check. Tests inject it. */ | |
| 75 | readonly resolve?: Resolve; | |
| 73 | 76 | } |
| 74 | 77 | |
| 75 | 78 | export interface MigrateStarted { |
| @@ -113,7 +116,7 @@ export async function migrateRepo( | ||
| 113 | 116 | const proven = requireVerified(app, user.value); |
| 114 | 117 | if (!proven.ok) return proven; |
| 115 | 118 | |
| 116 | const source = await resolveSource(app, user.value, input.source); | |
| 119 | const source = await resolveSource(app, user.value, input.source, input.resolve); | |
| 117 | 120 | if (!source.ok) return source; |
| 118 | 121 | if (input.crew !== undefined && !getAgentByName(app.db, input.crew.agent)) |
| 119 | 122 | return err("no_such_agent"); |
| @@ -235,8 +238,8 @@ export function migrateUrl(raw: string): string | undefined { | ||
| 235 | 238 | /** |
| 236 | 239 | * Whether a hostname names something inside the network rather than on the |
| 237 | 240 | * open internet: loopback, link-local, the private ranges, `.localhost`, |
| 238 | * and the unspecified address. Names are not resolved here — a public name | |
| 239 | * pointing at a private address is the network's own filter to catch. | |
| 241 | * and the unspecified address. Names are not resolved here: that is | |
| 242 | * `vetHost`'s job, which runs after this literal check. | |
| 240 | 243 | */ |
| 241 | 244 | export function isPrivateHost(hostname: string): boolean { |
| 242 | 245 | const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); |
| @@ -340,6 +343,7 @@ async function resolveSource( | ||
| 340 | 343 | app: App, |
| 341 | 344 | user: User, |
| 342 | 345 | source: MigrateSource, |
| 346 | resolve?: Resolve, | |
| 343 | 347 | ): Promise<Result<Resolved, MigrateError>> { |
| 344 | 348 | if ("url" in source) { |
| 345 | 349 | const url = migrateUrl(source.url); |
| @@ -348,12 +352,15 @@ async function resolveSource( | ||
| 348 | 352 | // range — is not a public repository, and a pasted address must not |
| 349 | 353 | // become a way to knock on it. An admin may; a forge run for one person |
| 350 | 354 | // may say so once (`FORGE_MIGRATE_PRIVATE_HOSTS=1`). |
| 351 | if ( | |
| 352 | isPrivateHost(new URL(url).hostname) && | |
| 353 | !user.isAdmin && | |
| 354 | app.config.migrateFromPrivateHosts !== true | |
| 355 | ) | |
| 356 | return err("private_host"); | |
| 355 | const mayReachPrivate = | |
| 356 | user.isAdmin || app.config.migrateFromPrivateHosts === true; | |
| 357 | if (!mayReachPrivate) { | |
| 358 | const hostname = new URL(url).hostname; | |
| 359 | if (isPrivateHost(hostname)) return err("private_host"); | |
| 360 | // A public-looking name can resolve inside the network. | |
| 361 | const vetted = await vetHost(hostname, resolve ? { resolve } : {}); | |
| 362 | if (!vetted.ok && vetted.error === "refused") return err("private_host"); | |
| 363 | } | |
| 357 | 364 | return ok({ |
| 358 | 365 | url, |
| 359 | 366 | label: url, |
src/actions/origins.ts +6 −1
| @@ -136,6 +136,7 @@ function tooManyAttempts(app: App, userId: string): boolean { | ||
| 136 | 136 | export type ConnectError = |
| 137 | 137 | | "unauthorized" |
| 138 | 138 | | "unknown_host" |
| 139 | | "private_host" | |
| 139 | 140 | | "unreachable" |
| 140 | 141 | | "bad_token" |
| 141 | 142 | | "already_connected" |
| @@ -330,6 +331,7 @@ export function listUserOrigins(app: App, actor: User): Origin[] { | ||
| 330 | 331 | export type OAuthStartError = |
| 331 | 332 | | "unauthorized" |
| 332 | 333 | | "unknown_host" |
| 334 | | "private_host" | |
| 333 | 335 | | "unreachable" |
| 334 | 336 | | "no_app" |
| 335 | 337 | | "rate_limited" |
| @@ -661,7 +663,10 @@ export function registerOriginApp( | ||
| 661 | 663 | clientId: string; |
| 662 | 664 | clientSecret?: string; |
| 663 | 665 | }, |
| 664 | ): Promise<Result<AppRow, "unauthorized" | "unknown_host" | "unreachable">> { | |
| 666 | ): Promise<Result< | |
| 667 | AppRow, | |
| 668 | "unauthorized" | "unknown_host" | "private_host" | "unreachable" | |
| 669 | >> { | |
| 665 | 670 | return (async () => { |
| 666 | 671 | const user = requireUser(actor); |
| 667 | 672 | if (!user.ok) return user; |
src/http/api.ts +2
| @@ -5895,6 +5895,8 @@ export function forgeBaseUrl( | ||
| 5895 | 5895 | |
| 5896 | 5896 | function connectMessage(code: string): string { |
| 5897 | 5897 | if (code === "unknown_host") return "that does not look like a forge host"; |
| 5898 | if (code === "private_host") | |
| 5899 | return "that address is inside a network, not on the internet"; | |
| 5898 | 5900 | if (code === "unreachable") return "that host could not be reached"; |
| 5899 | 5901 | if (code === "bad_token") return "the origin rejected that token"; |
| 5900 | 5902 | if (code === "already_connected") |
src/origins/discover.ts +74 −18
| @@ -16,6 +16,12 @@ | ||
| 16 | 16 | * a redirect may never leave the host we asked. |
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | import { | |
| 20 | outboundFetch, | |
| 21 | type OutboundOptions, | |
| 22 | RefusedError, | |
| 23 | vetHost, | |
| 24 | } from "../kernel/outbound.ts"; | |
| 19 | 25 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 20 | 26 | import { readCapped } from "./providers/http.ts"; |
| 21 | 27 | import type { Discovered, OriginKind } from "./types.ts"; |
| @@ -24,7 +30,7 @@ const TIMEOUT_MS = 5_000; | ||
| 24 | 30 | const MAX_BYTES = 64 * 1024; |
| 25 | 31 | const MAX_HOPS = 3; |
| 26 | 32 | |
| 27 | export type DiscoverError = "unknown_host" | "unreachable"; | |
| 33 | export type DiscoverError = "unknown_host" | "unreachable" | "private_host"; | |
| 28 | 34 | |
| 29 | 35 | export interface DiscoverInput { |
| 30 | 36 | /** What the user typed: `github`, `github.com`, `https://codeberg.org`. */ |
| @@ -47,8 +53,13 @@ function allowedHosts(env: Record<string, string | undefined>): Set<string> { | ||
| 47 | 53 | ); |
| 48 | 54 | } |
| 49 | 55 | |
| 50 | /** Loopback is always reachable: it is this machine, and it is where a | |
| 51 | * development instance and the test suite live. */ | |
| 56 | /** A development instance (`FORGE_DEV=1`, also set for the test suite). */ | |
| 57 | function isDev(env: Record<string, string | undefined>): boolean { | |
| 58 | return env.FORGE_DEV === "1"; | |
| 59 | } | |
| 60 | ||
| 61 | /** Loopback is this machine: reachable in dev, where a development instance | |
| 62 | * and the test suite live, and nowhere else unless an operator names it. */ | |
| 52 | 63 | function isLoopback(hostname: string): boolean { |
| 53 | 64 | const host = hostname.replace(/^\[|\]$/g, "").toLowerCase(); |
| 54 | 65 | return ( |
| @@ -140,7 +151,7 @@ export function allowedOrigin( | ||
| 140 | 151 | allowed.has(url.host.toLowerCase()) || |
| 141 | 152 | allowed.has(url.hostname.toLowerCase()); |
| 142 | 153 | if (named) return url.origin; |
| 143 | if (isLoopback(url.hostname)) return url.origin; | |
| 154 | if (isDev(env) && isLoopback(url.hostname)) return url.origin; | |
| 144 | 155 | if (url.protocol !== "https:") return undefined; |
| 145 | 156 | if (isPrivateLiteral(url.hostname)) return undefined; |
| 146 | 157 | return url.origin; |
| @@ -149,6 +160,7 @@ export function allowedOrigin( | ||
| 149 | 160 | export async function discover( |
| 150 | 161 | input: string | DiscoverInput, |
| 151 | 162 | env: Record<string, string | undefined> = process.env, |
| 163 | options: OutboundOptions = {}, | |
| 152 | 164 | ): Promise<Result<Discovered, DiscoverError>> { |
| 153 | 165 | const wanted: DiscoverInput = |
| 154 | 166 | typeof input === "string" ? { host: input } : input; |
| @@ -173,6 +185,11 @@ export async function discover( | ||
| 173 | 185 | |
| 174 | 186 | const web = allowedOrigin(wanted.host, env); |
| 175 | 187 | if (web === undefined) return err("unknown_host"); |
| 188 | const probe: Probe = { env, options, refused: false }; | |
| 189 | if (!exempt(web, env)) { | |
| 190 | const vetted = await vetHost(new URL(web).hostname, options); | |
| 191 | if (!vetted.ok && vetted.error === "refused") return err("private_host"); | |
| 192 | } | |
| 176 | 193 | |
| 177 | 194 | // GitHub Enterprise publishes neither NodeInfo nor a version endpoint we |
| 178 | 195 | // can read unauthenticated; the user says so, and we believe them. |
| @@ -187,13 +204,13 @@ export async function discover( | ||
| 187 | 204 | } |
| 188 | 205 | |
| 189 | 206 | // One cheap look at the standard, then the probe that actually answers. |
| 190 | const node = await viaNodeInfo(web); | |
| 207 | const node = await viaNodeInfo(web, probe); | |
| 191 | 208 | if (node) return ok(node); |
| 192 | 209 | |
| 193 | const version = await viaVersionEndpoint(web); | |
| 210 | const version = await viaVersionEndpoint(web, probe); | |
| 194 | 211 | if (version) return ok(version); |
| 195 | 212 | |
| 196 | return err("unreachable"); | |
| 213 | return err(probe.refused ? "private_host" : "unreachable"); | |
| 197 | 214 | } |
| 198 | 215 | |
| 199 | 216 | // ---- probes ---- |
| @@ -205,8 +222,9 @@ export async function discover( | ||
| 205 | 222 | */ |
| 206 | 223 | async function viaVersionEndpoint( |
| 207 | 224 | web: string, |
| 225 | probe: Probe, | |
| 208 | 226 | ): Promise<Discovered | undefined> { |
| 209 | const doc = await getJson(`${web}/api/v1/version`, web); | |
| 227 | const doc = await getJson(`${web}/api/v1/version`, web, probe); | |
| 210 | 228 | if (!doc.ok) return undefined; |
| 211 | 229 | const version = asString(field(doc.value, "version")); |
| 212 | 230 | if (version === "") return undefined; |
| @@ -220,8 +238,11 @@ async function viaVersionEndpoint( | ||
| 220 | 238 | }; |
| 221 | 239 | } |
| 222 | 240 | |
| 223 | async function viaNodeInfo(web: string): Promise<Discovered | undefined> { | |
| 224 | const index = await getJson(`${web}/.well-known/nodeinfo`, web); | |
| 241 | async function viaNodeInfo( | |
| 242 | web: string, | |
| 243 | probe: Probe, | |
| 244 | ): Promise<Discovered | undefined> { | |
| 245 | const index = await getJson(`${web}/.well-known/nodeinfo`, web, probe); | |
| 225 | 246 | if (!index.ok) return undefined; |
| 226 | 247 | const links = asArray(field(index.value, "links")); |
| 227 | 248 | // Prefer the newest schema the host offers; any of them carries `software`. |
| @@ -234,7 +255,7 @@ async function viaNodeInfo(web: string): Promise<Discovered | undefined> { | ||
| 234 | 255 | for (const href of hrefs.slice(0, 3)) { |
| 235 | 256 | // A NodeInfo document on another host is a redirect we did not consent to. |
| 236 | 257 | if (!sameOrigin(href, web)) continue; |
| 237 | const doc = await getJson(href, web); | |
| 258 | const doc = await getJson(href, web, probe); | |
| 238 | 259 | if (!doc.ok) continue; |
| 239 | 260 | const name = asString(field(field(doc.value, "software"), "name")) |
| 240 | 261 | .trim() |
| @@ -255,6 +276,28 @@ async function viaNodeInfo(web: string): Promise<Discovered | undefined> { | ||
| 255 | 276 | |
| 256 | 277 | // ---- careful fetching ---- |
| 257 | 278 | |
| 279 | /** What one discovery carries between its requests. */ | |
| 280 | interface Probe { | |
| 281 | env: Record<string, string | undefined>; | |
| 282 | options: OutboundOptions; | |
| 283 | /** Set when any hop was refused for its address. */ | |
| 284 | refused: boolean; | |
| 285 | } | |
| 286 | ||
| 287 | /** An operator-named host, or loopback in dev: dialled as typed, unvetted. */ | |
| 288 | function exempt( | |
| 289 | href: string, | |
| 290 | env: Record<string, string | undefined>, | |
| 291 | ): boolean { | |
| 292 | const url = new URL(href); | |
| 293 | const allowed = allowedHosts(env); | |
| 294 | return ( | |
| 295 | allowed.has(url.host.toLowerCase()) || | |
| 296 | allowed.has(url.hostname.toLowerCase()) || | |
| 297 | (isDev(env) && isLoopback(url.hostname)) | |
| 298 | ); | |
| 299 | } | |
| 300 | ||
| 258 | 301 | function sameOrigin(href: string, web: string): boolean { |
| 259 | 302 | try { |
| 260 | 303 | return new URL(href).origin === web; |
| @@ -271,18 +314,31 @@ function sameOrigin(href: string, web: string): boolean { | ||
| 271 | 314 | async function getJson( |
| 272 | 315 | url: string, |
| 273 | 316 | web: string, |
| 317 | probe: Probe, | |
| 274 | 318 | ): Promise<Result<unknown, "failed">> { |
| 275 | 319 | let target = url; |
| 276 | 320 | for (let hop = 0; hop < MAX_HOPS; hop++) { |
| 277 | if (!sameOrigin(target, web)) return err("failed"); | |
| 321 | if (!sameOrigin(target, web)) { | |
| 322 | // Say so when the hop it was sent to is inside the network. | |
| 323 | if (!exempt(target, probe.env)) { | |
| 324 | const vetted = await vetHost(new URL(target).hostname, probe.options); | |
| 325 | if (!vetted.ok && vetted.error === "refused") probe.refused = true; | |
| 326 | } | |
| 327 | return err("failed"); | |
| 328 | } | |
| 329 | const init = { | |
| 330 | headers: { accept: "application/json", "user-agent": "forge" }, | |
| 331 | redirect: "manual", | |
| 332 | signal: AbortSignal.timeout(TIMEOUT_MS), | |
| 333 | } satisfies RequestInit; | |
| 278 | 334 | let response: Response; |
| 279 | 335 | try { |
| 280 | response = await fetch(target, { | |
| 281 | headers: { accept: "application/json", "user-agent": "forge" }, | |
| 282 | redirect: "manual", | |
| 283 | signal: AbortSignal.timeout(TIMEOUT_MS), | |
| 284 | }); | |
| 285 | } catch { | |
| 336 | // Vetted, pinned to the judged address, and never auto-followed. | |
| 337 | response = exempt(target, probe.env) | |
| 338 | ? await fetch(target, init) | |
| 339 | : await outboundFetch(target, init, probe.options); | |
| 340 | } catch (e) { | |
| 341 | if (e instanceof RefusedError) probe.refused = true; | |
| 286 | 342 | return err("failed"); |
| 287 | 343 | } |
| 288 | 344 | if (response.status >= 300 && response.status < 400) { |
src/tui/screens/common.ts +1
| @@ -882,6 +882,7 @@ const MESSAGES: Record<string, string> = { | ||
| 882 | 882 | already_connected: "that account is already connected", |
| 883 | 883 | already_mirrored: "that repository is already mirrored here", |
| 884 | 884 | unknown_host: "that does not look like a forge", |
| 885 | private_host: "that address is inside a network, not on the internet", | |
| 885 | 886 | unreachable: "that instance could not be reached", |
| 886 | 887 | bad_token: "the origin refused that token", |
| 887 | 888 | rate_limited: "too many connect attempts; wait a few minutes", |
src/web/pages/origins.ts +1
| @@ -819,6 +819,7 @@ export async function originConnectToken(ctx: Ctx): Promise<Response> { | ||
| 819 | 819 | function connectMessage(error: string): string { |
| 820 | 820 | const words: Record<string, string> = { |
| 821 | 821 | unknown_host: "That does not look like a forge this can talk to.", |
| 822 | private_host: "That address is inside a network, not on the internet.", | |
| 822 | 823 | unreachable: "That instance could not be reached.", |
| 823 | 824 | bad_token: "That token was refused by the origin.", |
| 824 | 825 | already_connected: "That account is already connected.", |
test/helpers/preload.ts +3
| @@ -10,3 +10,6 @@ installCheapHash(); | ||
| 10 | 10 | // Defaults sized from CPUs (#2549) read as on a 4-CPU pod — 4 shards, one |
| 11 | 11 | // check at once — whatever machine runs the suite. |
| 12 | 12 | provideCpusForTest(4); |
| 13 | // Loopback is reachable for origin discovery only in dev (#2993); the suite | |
| 14 | // serves its fake forges from this machine. | |
| 15 | process.env.FORGE_DEV = "1"; |
test/migrate.test.ts +14
| @@ -657,6 +657,20 @@ describe("migrate", () => { | ||
| 657 | 657 | } |
| 658 | 658 | }); |
| 659 | 659 | |
| 660 | test("a name that resolves to a private address is refused like the literal", async () => { | |
| 661 | const config = app.config as { migrateFromPrivateHosts?: boolean }; | |
| 662 | config.migrateFromPrivateHosts = false; | |
| 663 | try { | |
| 664 | const refused = await migrateRepo(app, bob, { | |
| 665 | source: { url: "https://looks-public.example/a/b.git" }, | |
| 666 | resolve: async () => ["10.0.0.5"], | |
| 667 | }); | |
| 668 | expect(refused).toEqual({ ok: false, error: "private_host" }); | |
| 669 | } finally { | |
| 670 | config.migrateFromPrivateHosts = true; | |
| 671 | } | |
| 672 | }); | |
| 673 | ||
| 660 | 674 | test("a clone follows no redirect: the address that was checked is the only one fetched", async () => { |
| 661 | 675 | let hits = 0; |
| 662 | 676 | const target = Bun.serve({ |
test/origins-oauth.test.ts +41 −2
| @@ -627,10 +627,10 @@ describe("discovery will not dial anything it is asked to", () => { | ||
| 627 | 627 | } |
| 628 | 628 | // Public https hosts are fine, and so is loopback (this machine). |
| 629 | 629 | expect(allowedOrigin("codeberg.org", env())).toBe("https://codeberg.org"); |
| 630 | expect(allowedOrigin("http://localhost:3000", env())).toBe( | |
| 630 | expect(allowedOrigin("http://localhost:3000", { FORGE_DEV: "1" })).toBe( | |
| 631 | 631 | "http://localhost:3000", |
| 632 | 632 | ); |
| 633 | expect(allowedOrigin("http://127.0.0.1:9", env())).toBe( | |
| 633 | expect(allowedOrigin("http://127.0.0.1:9", { FORGE_DEV: "1" })).toBe( | |
| 634 | 634 | "http://127.0.0.1:9", |
| 635 | 635 | ); |
| 636 | 636 | |
| @@ -657,3 +657,42 @@ describe("discovery will not dial anything it is asked to", () => { | ||
| 657 | 657 | ).toBeUndefined(); |
| 658 | 658 | }); |
| 659 | 659 | }); |
| 660 | ||
| 661 | describe("discovery vets every host and every hop (#2993)", () => { | |
| 662 | test("a name that resolves privately, and a redirect to the metadata address, are refused", async () => { | |
| 663 | const resolved = await discover("https://sneaky.example", {}, { | |
| 664 | resolve: async () => ["10.0.0.5"], | |
| 665 | }); | |
| 666 | expect(resolved).toEqual({ ok: false, error: "private_host" }); | |
| 667 | ||
| 668 | const hop = Bun.serve({ | |
| 669 | port: 0, | |
| 670 | hostname: "127.0.0.1", | |
| 671 | fetch: () => | |
| 672 | new Response(null, { | |
| 673 | status: 302, | |
| 674 | headers: { location: "http://169.254.169.254/latest/meta-data" }, | |
| 675 | }), | |
| 676 | }); | |
| 677 | try { | |
| 678 | const env = { FORGE_DEV: "1" }; | |
| 679 | const found = await discover(`http://127.0.0.1:${hop.port}`, env); | |
| 680 | expect(found).toEqual({ ok: false, error: "private_host" }); | |
| 681 | } finally { | |
| 682 | hop.stop(true); | |
| 683 | } | |
| 684 | }); | |
| 685 | ||
| 686 | test("loopback is reachable in dev only, and an operator can still name it", () => { | |
| 687 | expect(allowedOrigin("http://127.0.0.1:9", { FORGE_DEV: "1" })).toBe( | |
| 688 | "http://127.0.0.1:9", | |
| 689 | ); | |
| 690 | expect(allowedOrigin("http://127.0.0.1:9", {})).toBeUndefined(); | |
| 691 | expect(allowedOrigin("http://localhost:3000", {})).toBeUndefined(); | |
| 692 | expect( | |
| 693 | allowedOrigin("http://localhost:3000", { | |
| 694 | FORGE_ORIGIN_ALLOW: "localhost:3000", | |
| 695 | }), | |
| 696 | ).toBe("http://localhost:3000"); | |
| 697 | }); | |
| 698 | }); |
new above
offline
This is a resolve-then-judge check. git does its own lookup afterwards, so a rebinding name passes here and lands on 10.x. That is fine for this pull. The follow-up: pin git to the vetted address with
-c http.curloptResolve=<host>:<port>:<ip>, so the address git dials is the one that was judged.