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

Merged opened this

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

Merged

scout/forge:scout/2993-origin-discovery-and-migrate-refuse-a-ho → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/2993-origin-discovery-and-migrate-refuse-a-ho was merged into main as 030606f.

Review

Sign in to review this change.
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 →
111111 `/api/v3`). `whoami()` then validates the credential and fixes `login`.
112112
113113 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
116117 or plaintext host with `FORGE_ORIGIN_ALLOW=host[,host…]`; there is no
117118 per-user toggle, because the risk it accepts belongs to the server.
118119
src/actions/migrate.ts +16 −9
@@ -28,6 +28,7 @@ import type { User } from "../core/users.ts";
2828 import { runGitCapture } from "../git/env.ts";
2929 import { isSafeRef } from "../git/guard.ts";
3030 import { repoDir } from "../git/repos.ts";
31 import { type Resolve, vetHost } from "../kernel/outbound.ts";
3132 import { err, ok, type Result } from "../kernel/result.ts";
3233 import { providerForOrigin } from "../origins/credential.ts";
3334 import { redact, safeRemoteUrl, secretsIn } from "../origins/credentials.ts";
@@ -70,6 +71,8 @@ export interface MigrateInput {
7071 readonly visibility?: "public" | "private";
7172 /** Put an agent on it: the crew makes it run here. */
7273 readonly crew?: { readonly agent: string; readonly autonomous: boolean };
74 /** Name → addresses, for the private-address check. Tests inject it. */
75 readonly resolve?: Resolve;
7376 }
7477
7578 export interface MigrateStarted {
@@ -113,7 +116,7 @@ export async function migrateRepo(
113116 const proven = requireVerified(app, user.value);
114117 if (!proven.ok) return proven;
115118
116 const source = await resolveSource(app, user.value, input.source);
119 const source = await resolveSource(app, user.value, input.source, input.resolve);
117120 if (!source.ok) return source;
118121 if (input.crew !== undefined && !getAgentByName(app.db, input.crew.agent))
119122 return err("no_such_agent");
@@ -235,8 +238,8 @@ export function migrateUrl(raw: string): string | undefined {
235238 /**
236239 * Whether a hostname names something inside the network rather than on the
237240 * 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.
240243 */
241244 export function isPrivateHost(hostname: string): boolean {
242245 const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
@@ -340,6 +343,7 @@ async function resolveSource(
340343 app: App,
341344 user: User,
342345 source: MigrateSource,
346 resolve?: Resolve,
343347 ): Promise<Result<Resolved, MigrateError>> {
344348 if ("url" in source) {
345349 const url = migrateUrl(source.url);
@@ -348,12 +352,15 @@ async function resolveSource(
348352 // range — is not a public repository, and a pasted address must not
349353 // become a way to knock on it. An admin may; a forge run for one person
350354 // 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 } : {});

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.

362 if (!vetted.ok && vetted.error === "refused") return err("private_host");
363 }
357364 return ok({
358365 url,
359366 label: url,
src/actions/origins.ts +6 −1
@@ -136,6 +136,7 @@ function tooManyAttempts(app: App, userId: string): boolean {
136136 export type ConnectError =
137137 | "unauthorized"
138138 | "unknown_host"
139 | "private_host"
139140 | "unreachable"
140141 | "bad_token"
141142 | "already_connected"
@@ -330,6 +331,7 @@ export function listUserOrigins(app: App, actor: User): Origin[] {
330331 export type OAuthStartError =
331332 | "unauthorized"
332333 | "unknown_host"
334 | "private_host"
333335 | "unreachable"
334336 | "no_app"
335337 | "rate_limited"
@@ -661,7 +663,10 @@ export function registerOriginApp(
661663 clientId: string;
662664 clientSecret?: string;
663665 },
664 ): Promise<Result<AppRow, "unauthorized" | "unknown_host" | "unreachable">> {
666 ): Promise<Result<
667 AppRow,
668 "unauthorized" | "unknown_host" | "private_host" | "unreachable"
669 >> {
665670 return (async () => {
666671 const user = requireUser(actor);
667672 if (!user.ok) return user;
src/http/api.ts +2
@@ -5895,6 +5895,8 @@ export function forgeBaseUrl(
58955895
58965896 function connectMessage(code: string): string {
58975897 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";
58985900 if (code === "unreachable") return "that host could not be reached";
58995901 if (code === "bad_token") return "the origin rejected that token";
59005902 if (code === "already_connected")
src/origins/discover.ts +74 −18
@@ -16,6 +16,12 @@
1616 * a redirect may never leave the host we asked.
1717 */
1818
19 import {
20 outboundFetch,
21 type OutboundOptions,
22 RefusedError,
23 vetHost,
24 } from "../kernel/outbound.ts";
1925 import { err, ok, type Result } from "../kernel/result.ts";
2026 import { readCapped } from "./providers/http.ts";
2127 import type { Discovered, OriginKind } from "./types.ts";
@@ -24,7 +30,7 @@ const TIMEOUT_MS = 5_000;
2430 const MAX_BYTES = 64 * 1024;
2531 const MAX_HOPS = 3;
2632
27 export type DiscoverError = "unknown_host" | "unreachable";
33 export type DiscoverError = "unknown_host" | "unreachable" | "private_host";
2834
2935 export interface DiscoverInput {
3036 /** What the user typed: `github`, `github.com`, `https://codeberg.org`. */
@@ -47,8 +53,13 @@ function allowedHosts(env: Record<string, string | undefined>): Set<string> {
4753 );
4854 }
4955
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. */
5263 function isLoopback(hostname: string): boolean {
5364 const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
5465 return (
@@ -140,7 +151,7 @@ export function allowedOrigin(
140151 allowed.has(url.host.toLowerCase()) ||
141152 allowed.has(url.hostname.toLowerCase());
142153 if (named) return url.origin;
143 if (isLoopback(url.hostname)) return url.origin;
154 if (isDev(env) && isLoopback(url.hostname)) return url.origin;
144155 if (url.protocol !== "https:") return undefined;
145156 if (isPrivateLiteral(url.hostname)) return undefined;
146157 return url.origin;
@@ -149,6 +160,7 @@ export function allowedOrigin(
149160 export async function discover(
150161 input: string | DiscoverInput,
151162 env: Record<string, string | undefined> = process.env,
163 options: OutboundOptions = {},
152164 ): Promise<Result<Discovered, DiscoverError>> {
153165 const wanted: DiscoverInput =
154166 typeof input === "string" ? { host: input } : input;
@@ -173,6 +185,11 @@ export async function discover(
173185
174186 const web = allowedOrigin(wanted.host, env);
175187 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 }
176193
177194 // GitHub Enterprise publishes neither NodeInfo nor a version endpoint we
178195 // can read unauthenticated; the user says so, and we believe them.
@@ -187,13 +204,13 @@ export async function discover(
187204 }
188205
189206 // 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);
191208 if (node) return ok(node);
192209
193 const version = await viaVersionEndpoint(web);
210 const version = await viaVersionEndpoint(web, probe);
194211 if (version) return ok(version);
195212
196 return err("unreachable");
213 return err(probe.refused ? "private_host" : "unreachable");
197214 }
198215
199216 // ---- probes ----
@@ -205,8 +222,9 @@ export async function discover(
205222 */
206223 async function viaVersionEndpoint(
207224 web: string,
225 probe: Probe,
208226 ): Promise<Discovered | undefined> {
209 const doc = await getJson(`${web}/api/v1/version`, web);
227 const doc = await getJson(`${web}/api/v1/version`, web, probe);
210228 if (!doc.ok) return undefined;
211229 const version = asString(field(doc.value, "version"));
212230 if (version === "") return undefined;
@@ -220,8 +238,11 @@ async function viaVersionEndpoint(
220238 };
221239 }
222240
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);
225246 if (!index.ok) return undefined;
226247 const links = asArray(field(index.value, "links"));
227248 // Prefer the newest schema the host offers; any of them carries `software`.
@@ -234,7 +255,7 @@ async function viaNodeInfo(web: string): Promise<Discovered | undefined> {
234255 for (const href of hrefs.slice(0, 3)) {
235256 // A NodeInfo document on another host is a redirect we did not consent to.
236257 if (!sameOrigin(href, web)) continue;
237 const doc = await getJson(href, web);
258 const doc = await getJson(href, web, probe);
238259 if (!doc.ok) continue;
239260 const name = asString(field(field(doc.value, "software"), "name"))
240261 .trim()
@@ -255,6 +276,28 @@ async function viaNodeInfo(web: string): Promise<Discovered | undefined> {
255276
256277 // ---- careful fetching ----
257278
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
258301 function sameOrigin(href: string, web: string): boolean {
259302 try {
260303 return new URL(href).origin === web;
@@ -271,18 +314,31 @@ function sameOrigin(href: string, web: string): boolean {
271314 async function getJson(
272315 url: string,
273316 web: string,
317 probe: Probe,
274318 ): Promise<Result<unknown, "failed">> {
275319 let target = url;
276320 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;
278334 let response: Response;
279335 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;
286342 return err("failed");
287343 }
288344 if (response.status >= 300 && response.status < 400) {
src/tui/screens/common.ts +1
@@ -882,6 +882,7 @@ const MESSAGES: Record<string, string> = {
882882 already_connected: "that account is already connected",
883883 already_mirrored: "that repository is already mirrored here",
884884 unknown_host: "that does not look like a forge",
885 private_host: "that address is inside a network, not on the internet",
885886 unreachable: "that instance could not be reached",
886887 bad_token: "the origin refused that token",
887888 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> {
819819 function connectMessage(error: string): string {
820820 const words: Record<string, string> = {
821821 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.",
822823 unreachable: "That instance could not be reached.",
823824 bad_token: "That token was refused by the origin.",
824825 already_connected: "That account is already connected.",
test/helpers/preload.ts +3
@@ -10,3 +10,6 @@ installCheapHash();
1010 // Defaults sized from CPUs (#2549) read as on a 4-CPU pod — 4 shards, one
1111 // check at once — whatever machine runs the suite.
1212 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", () => {
657657 }
658658 });
659659
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
660674 test("a clone follows no redirect: the address that was checked is the only one fetched", async () => {
661675 let hits = 0;
662676 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", () => {
627627 }
628628 // Public https hosts are fine, and so is loopback (this machine).
629629 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(
631631 "http://localhost:3000",
632632 );
633 expect(allowedOrigin("http://127.0.0.1:9", env())).toBe(
633 expect(allowedOrigin("http://127.0.0.1:9", { FORGE_DEV: "1" })).toBe(
634634 "http://127.0.0.1:9",
635635 );
636636
@@ -657,3 +657,42 @@ describe("discovery will not dial anything it is asked to", () => {
657657 ).toBeUndefined();
658658 });
659659 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs