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

Origin sync to the owner's own Forgejo works again: an admin-trusted host list for outbound git (git.open-platform.sh refused as private) #3297

Merged opened this in answer to

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

Merged

scout/forge:scout/3290-origin-sync-to-the-owner-s-own-forgejo-w → main 2 commits fork 6 threads · 6 unresolved

  • Merged. scout/3290-origin-sync-to-the-owner-s-own-forgejo-w was merged into main as 0d7ce61.

Review

Sign in to review this change.
13 files changed +556 −4
README.md +2
@@ -614,6 +614,8 @@ paged. Every other cause (`drill`, `mirror`, `stuck:*`, `red:*`, `snapshots`,
614614
615615 ## Origins
616616
617 Outbound git refuses a host that resolves to a private address. A site admin can vouch for exact host names (the owner's own Forgejo, reached by its in-cluster address): `FORGE_OUTBOUND_TRUSTED_HOSTS=git.example.test` (comma or space separated), plus the same list editable by an admin on **Settings → Origins**. A trusted host may resolve privately for origin sync, mirror, migrate and write-through; trust is by name, never by address range, and webhooks never read it. The origin row says "trusted internal host". In the terminal, the palette's **Trusted hosts** reads and saves the same list (admins only, same audit). An entry that is not a bare host name (an address, a wildcard, a URL) is refused with its name, on the page and in the terminal, never silently dropped.
618
617619 Your forge is the index of everything you work on, wherever it actually lives.
618620 An **origin** is a connected account on another forge — GitHub, Forgejo, Gitea.
619621 Mirror a repository in and its issues, pulls, comments and labels come with the
src/actions/migrate.ts +5 −2
@@ -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 { isTrustedHost } from "../origins/trusted-hosts.ts";
3132 import { type Resolve, vetHost } from "../kernel/outbound.ts";
3233 import { err, ok, type Result } from "../kernel/result.ts";
3334 import { providerForOrigin } from "../origins/credential.ts";
@@ -325,7 +326,7 @@ function nameFromUrl(url: string): string {
325326 return last.replace(/\.git$/i, "");
326327 }
327328
328 async function resolveSource(
329 export async function resolveSource(
329330 app: App,
330331 user: User,
331332 source: MigrateSource,
@@ -339,7 +340,9 @@ async function resolveSource(
339340 // become a way to knock on it. An admin may; a forge run for one person
340341 // may say so once (`FORGE_MIGRATE_PRIVATE_HOSTS=1`).
341342 const mayReachPrivate =
342 user.isAdmin || app.config.migrateFromPrivateHosts === true;
343 user.isAdmin ||
344 app.config.migrateFromPrivateHosts === true ||
345 isTrustedHost(new URL(url).hostname);
343346 if (!mayReachPrivate) {
344347 const hostname = new URL(url).hostname;
345348 if (isPrivateHost(hostname)) return err("private_host");
src/http/server.ts +2
@@ -446,6 +446,7 @@ import {
446446 originReposPage,
447447 originSyncSubmit,
448448 originsPage,
449 originTrustedHosts,
449450 } from "../web/pages/origins.ts";
450451 import { pushThrough } from "../origins/push-through.ts";
451452 import { notFoundPage } from "../web/chrome.ts";
@@ -951,6 +952,7 @@ export function buildRouter(): Router<Handler> {
951952 router.add("POST", "/settings/origins/token", originConnectToken);
952953 router.add("POST", "/settings/origins/oauth", originOAuthStart);
953954 router.add("POST", "/settings/origins/apps", originAppCreate);
955 router.add("POST", "/settings/origins/trusted-hosts", originTrustedHosts);
954956 router.add("POST", "/settings/origins/apps/:id/delete", originAppDelete);
955957 router.add("GET", "/settings/origins/flows/:id", originFlowPage);
956958 router.add("POST", "/settings/origins/flows/:id/poll", originFlowPoll);
src/kernel/outbound.ts +10
@@ -39,6 +39,11 @@ export interface OutboundOptions {
3939 resolve?: Resolve;
4040 /** Addresses exempt from the refusal list. For tests only; never read from the environment. */
4141 allow?: readonly string[];
42 /**
43 * Exact host names an admin vouched for: they may resolve to a private
44 * address. Origins pass this; webhooks never do.
45 */
46 trusted?: readonly string[];
4247 /** Permit plain http. For tests only; never read from the environment. */
4348 plain?: boolean;
4449 /** Extra trusted CA certificate (PEM) for https. For tests only. */
@@ -73,6 +78,11 @@ export async function vetHost(
7378 }
7479 }
7580 if (found.length === 0) return err("unresolved");
81 if (
82 isIP(bare) === 0 &&
83 (options.trusted ?? []).includes(bare.toLowerCase())
84 )
85 return ok([...found]);

Medium, hardening. A trusted name currently skips every refused range, including loopback, link-local and 169.254.169.254 (cloud metadata), not only the private cluster ranges. Trust stops at whoever controls that name's DNS. If the name is ever pointed at the metadata address or 127.0.0.1, an origin sync, or a non-admin's migrate (which skips vetHost for a trusted name), reaches it. Fix: on the trusted path, keep refusing loopback, link-local/metadata and unspecified addresses, and lift only RFC1918, CGNAT and ULA. Add a test where a trusted name resolving to 169.254.169.254 is still refused.

7686 const allow = options.allow ?? [];
7787 if (found.some((a) => isRefusedAddress(a) && !allow.includes(a)))
7888 return err("refused");
src/main.ts +2
@@ -13,6 +13,7 @@ import { parentUrlProblem } from "./core/parent_releases.ts";
1313 import { releaseEnv } from "./release/env.ts";
1414 import { createApp } from "./app.ts";
1515 import { backfillCarried } from "./core/carried.ts";
16 import { loadTrustedHosts } from "./origins/trusted-hosts.ts";
1617 import { flushFlagUses, loadFlags } from "./core/flags.ts";
1718 import { reconcile } from "./core/reconcile.ts";
1819 import { offerStartingBoards } from "./actions/boards.ts";
@@ -261,6 +262,7 @@ const app = createApp({
261262 });
262263
263264 loadFlags(app.db);
265 loadTrustedHosts(app.db);
264266 // FORGE_SIGNUP (checked in configProblem) sets who may sign up; kept like any change.
265267 const bootSignup = signupFromEnv(process.env.FORGE_SIGNUP);
266268 if (bootSignup.ok && bootSignup.value !== null)
src/origins/discover.ts +3 −1
@@ -22,6 +22,7 @@ import {
2222 RefusedError,
2323 vetHost,
2424 } from "../kernel/outbound.ts";
25 import { trustedHosts } from "./trusted-hosts.ts";
2526 import { err, ok, type Result } from "../kernel/result.ts";
2627 import { readCapped } from "./providers/http.ts";
2728 import type { Discovered, OriginKind } from "./types.ts";
@@ -160,8 +161,9 @@ export function allowedOrigin(
160161 export async function discover(
161162 input: string | DiscoverInput,
162163 env: Record<string, string | undefined> = process.env,
163 options: OutboundOptions = {},
164 given: OutboundOptions = {},
164165 ): Promise<Result<Discovered, DiscoverError>> {
166 const options: OutboundOptions = { trusted: trustedHosts(), ...given };
165167 const wanted: DiscoverInput =
166168 typeof input === "string" ? { host: input } : input;
167169 const typed = wanted.host.trim().toLowerCase().replace(/\/+$/, "");
src/origins/git-mirror.ts +2 −1
@@ -24,6 +24,7 @@ import { runGitCapture, type RunOptions } from "../git/env.ts";
2424 import { listRefs } from "../git/read.ts";
2525 import { ZERO_OID } from "../git/hooks.ts";
2626 import { err, ok, type Result } from "../kernel/result.ts";
27 import { trustedHosts } from "./trusted-hosts.ts";
2728 import { vetHost, type OutboundOptions } from "../kernel/outbound.ts";
2829 import { pinFor, remoteGitEnv } from "../git/remote-env.ts";
2930 import { redact, safeRemoteUrl, secretsIn } from "./credentials.ts";
@@ -61,7 +62,7 @@ async function vettedEnv(
6162 authEnv: Record<string, string>,
6263 ): Promise<Result<Record<string, string>, GitMirrorError>> {
6364 const host = new URL(url).hostname;
64 const vetted = await vetHost(host, outbound);
65 const vetted = await vetHost(host, { ...outbound, trusted: trustedHosts() });
6566 if (!vetted.ok)
6667 return err({
6768 message: vetted.error === "refused" ? MIRROR_REFUSED : MIRROR_UNRESOLVED,
src/origins/trusted-hosts.ts added +131
@@ -0,0 +1,131 @@
1 /**
2 * Hosts a site admin has vouched for: an origin on one of them may resolve to
3 * a private address (the owner's own Forgejo, reached by its in-cluster
4 * service). Trust is by exact host name — never by address range — and only
5 * origin traffic reads it; webhooks never do (#2922 H3).
6 *
7 * The list is `FORGE_OUTBOUND_TRUSTED_HOSTS` (comma or space separated) plus
8 * the `outbound.trusted_hosts` row in `forge_setting`, edited on
9 * /settings/origins by an admin. Read once into memory; `load` re-reads.
10 */
11
12 import type { Database } from "bun:sqlite";
13
14 export const TRUSTED_KEY = "outbound.trusted_hosts";
15 const MAX_HOSTS = 50;
16 const HOST = /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/;
17
18 /**
19 * Host names from free text (commas, spaces or lines), and the words that
outdated

Medium: entries dropped here never reach the admin, and the flash still says saved. Return the rejected words too (e.g. { hosts, rejected }) and show them.

20 * were not names: an address literal, a wildcard or a URL is refused, not
21 * silently dropped, so whoever typed it knows it is not trusted.
22 */
23 export function parseHosts(text: string): { hosts: string[]; rejected: string[] } {
24 const hosts = new Set<string>();
25 const rejected: string[] = [];
26 for (const word of text.toLowerCase().split(/[\s,]+/)) {
27 if (word === "") continue;
28 if (!HOST.test(word) || /^[0-9.]+$/.test(word) || hosts.size >= MAX_HOSTS && !hosts.has(word))
29 rejected.push(word.slice(0, 60));
30 else hosts.add(word);
31 }
32 return { hosts: [...hosts], rejected };
33 }
34
35 export function parseHostList(text: string): string[] {
36 return parseHosts(text).hosts;
37 }
38
39 /** Who may edit the list: a site admin who is not an agent, on the page and in the terminal alike. */
40 export function mayEditTrustedHosts(
41 user: { isAdmin: boolean; kind: string } | undefined,
42 ): boolean {
43 return user !== undefined && user.isAdmin && user.kind !== "agent";
44 }
45
46 export type TrustedUpdate =
47 | { ok: true; hosts: string[] }
48 | { ok: false; error: "forbidden" }
49 | { ok: false; error: "rejected"; rejected: string[] };
50
51 /** The one door both the page and the terminal use: check, save, audit, go live. */
52 export function updateTrustedHosts(
53 db: Database,
54 user: { id: string; isAdmin: boolean; kind: string } | undefined,
55 text: string,
56 ): TrustedUpdate {
57 if (user === undefined || !mayEditTrustedHosts(user)) return { ok: false, error: "forbidden" };
58 const { hosts, rejected } = parseHosts(text.slice(0, 4000));
59 if (rejected.length > 0) return { ok: false, error: "rejected", rejected };

Low. text.slice(0, 4000) can cut the last word in half, and the cut-off part is still a valid name (for example git.example.te), so it gets saved without a word. 50 names fit well inside 4000 characters. Fix: when the text is over the cap, refuse it with a sentence that says so, instead of truncating before parsing.

60 saveTrustedHosts(db, hosts, user.id);
61 return { ok: true, hosts };
62 }
63
64 export function rejectedSentence(rejected: string[]): string {
65 return `Not host names, so not trusted: ${rejected.slice(0, 5).join(", ")}${rejected.length > 5 ? " …" : ""}. Use bare names like git.example.test.`;
66 }
67
68 let fromEnv: string[] = [];
69 let fromDb: string[] = [];
70
71 /** Every host trusted right now, env and admin-edited together. */
72 export function trustedHosts(): string[] {
73 return [...new Set([...fromEnv, ...fromDb])];
74 }
75
76 export function isTrustedHost(host: string): boolean {
77 return trustedHosts().includes(host.toLowerCase().replace(/^\[|\]$/g, ""));
78 }
79
80 /** The part an admin edits on the page (the env part is read-only there). */
81 export function editableHosts(): string[] {
82 return [...fromDb];
83 }
84
85 export function envHosts(): string[] {
86 return [...fromEnv];
87 }
88
89 export function loadTrustedHosts(
90 db: Database | undefined,
91 env: Record<string, string | undefined> = process.env,
92 ): void {
93 fromEnv = parseHostList(env.FORGE_OUTBOUND_TRUSTED_HOSTS ?? "");
94 loadStored(db);
95 }
96
97 function loadStored(db: Database | undefined): void {
98 fromDb = [];
99 if (!db) return;
100 try {
101 const row = db
102 .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1")
103 .get(TRUSTED_KEY);
104 const parsed: unknown = row ? JSON.parse(row.value) : [];
105 if (Array.isArray(parsed))
106 fromDb = parseHostList(parsed.filter((v) => typeof v === "string").join(" "));
107 } catch {
108 fromDb = [];
109 }
110 }
111
112 /** Replace the admin-edited list, record the change, and make it live. */
113 export function saveTrustedHosts(db: Database, hosts: string[], actorId: string): void {
114 const at = new Date().toISOString();
115 const to = JSON.stringify(hosts);
116 db.transaction(() => {
117 const from = db
118 .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1")
119 .get(TRUSTED_KEY)?.value;
120 db.query(
121 `INSERT INTO forge_setting (key, value, updated_by, updated_at) VALUES (?1, ?2, ?3, ?4)
122 ON CONFLICT (key) DO UPDATE SET value = excluded.value,
123 updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
124 ).run(TRUSTED_KEY, to, actorId, at);
125 db.query(
126 `INSERT INTO forge_setting_change (key, from_value, to_value, actor_id, at)
127 VALUES (?1, ?2, ?3, ?4, ?5)`,
128 ).run(TRUSTED_KEY, from ?? "[]", to, actorId, at);
129 })();
130 loadStored(db);
131 }
src/tui/forge.ts +10
@@ -46,6 +46,8 @@ import { blobScreen } from "./screens/repo.ts";
4646 import { dashboardScreen } from "./screens/dashboard.ts";
4747 import { mayManageFlags } from "../core/flags.ts";
4848 import { accessScreen } from "./screens/access.ts";
49 import { mayEditTrustedHosts } from "../origins/trusted-hosts.ts";
50 import { trustedHostsPrompt } from "./screens/trustedhosts.ts";
4951 import { flagsScreen } from "./screens/flags.ts";
5052 import { scorecardScreen } from "./screens/scorecard.ts";
5153 import { cloudScreen } from "./screens/cloud.ts";
@@ -363,6 +365,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
363365 hint: "declared flags and their live values",
364366 run: () => ctx.push(flagsScreen(session, ctx)),
365367 });
368 // Settings → Origins' trusted internal hosts: the same admin gate as the page.
369 if (mayEditTrustedHosts(session.user))
370 commands.push({
371 id: "trusted-hosts",
372 title: "Trusted hosts",
373 hint: "internal hosts an origin may reach",
374 run: () => ctx.push(trustedHostsPrompt(session, ctx, blank("Trusted hosts"))),
375 });
366376 // Settings → Usage, as the page has it: the same gate as Flags (#1565).
367377 if (mayManageFlags(session.user))
368378 commands.push({
src/tui/screens/trustedhosts.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * Trusted internal hosts in the terminal, a forge admin's: the list Settings →
3 * Origins edits, read and saved through the page's own `updateTrustedHosts`,
4 * so a save is audited and live at once. Anyone else is not offered it.
5 */
6
7 import { editableHosts, envHosts, mayEditTrustedHosts, rejectedSentence, updateTrustedHosts } from "../../origins/trusted-hosts.ts";
8 import type { Ctx, Screen } from "../app/app.ts";
9 import type { Session } from "../session.ts";
10 import { promptScreen } from "./common.ts";
11
12 export function trustedHostsPrompt(f: Session, ctx: Ctx, parent: Screen): Screen {
13 const fixed = envHosts();
14 return promptScreen(ctx, parent, {
15 title: "Trusted internal hosts",
16 label: "host names, space separated",
17 value: mayEditTrustedHosts(f.user) ? editableHosts().join(" ") : "",
18 hint: fixed.length > 0 ? `from the environment: ${fixed.join(" ")}` : "origins on these may resolve privately",
19 onSubmit: (text) => {
20 const saved = updateTrustedHosts(f.db, f.user, text);
21 if (!saved.ok) return saved.error === "forbidden" ? "Only a forge admin sets this." : rejectedSentence(saved.rejected);
22 ctx.toast(saved.hosts.length === 0 ? "No trusted internal hosts." : `Trusted: ${saved.hosts.join(", ")}.`);
23 return undefined;
24 },
25 });
26 }
src/web/pages/origins.ts +81
@@ -30,6 +30,14 @@ import {
3030 syncOriginNow,
3131 type Reattachment,
3232 } from "../../actions/origins.ts";
33 import {
34 editableHosts,
35 envHosts,
36 isTrustedHost,
37 mayEditTrustedHosts,
38 rejectedSentence,
39 updateTrustedHosts,
40 } from "../../origins/trusted-hosts.ts";
3341 import { reattachMessage } from "./frozen.ts";
3442 import { getFlowFor } from "../../origins/oauth.ts";
3543 import {
@@ -319,6 +327,13 @@ function originCard(origin: Origin, mirrors: number): Html {
319327 ? html`never synced`
320328 : html`synced ${time(origin.lastSyncAt)}`}</span
321329 >
330 ${when(
331 isTrustedHost(URL.parse(origin.webUrl)?.hostname ?? ""),
332 () =>
333 html`<span class="row-meta muted small"
334 >trusted internal host</span
335 >`,
336 )}
322337 </span>
323338 ${healthBadge(origin)}
324339 </div>
@@ -358,6 +373,8 @@ interface ConnectValues {
358373 tokenHost: string;
359374 tokenError?: string;
360375 registerError?: string;
376 trustedText?: string;
377 trustedError?: string;
361378 }
362379
363380 const blankValues = (): ConnectValues => ({
@@ -610,6 +627,41 @@ function tokenSection(values: ConnectValues): Html {
610627 </div>`;
611628 }
outdated

Low: the copy says "One name per line" but commas and spaces are accepted as well. Either say so or keep it to one form. Also check this card uses the field/form-actions pattern from docs/DESIGN.md without the hand-added mono class on the textarea if the design system has no such variant.

612629
630 /** The admin's list of hosts an origin may reach inside the network. */
631 function trustedHostsCard(text: string, error?: string): Html {
632 const fixed = envHosts();
633 return card(
634 html`<h3>${icon("lock")}Trusted internal hosts</h3>
635 <p class="muted small">
636 Origins on these exact host names may resolve to a private address
637 (your own Forgejo, reached inside the cluster). Webhooks never do.
638 Separate names with a space, comma or new line; addresses, wildcards and
639 URLs are not accepted.
640 </p>
641 ${when(
642 fixed.length > 0,
643 () =>
644 html`<p class="muted small">
645 Set by the environment:
646 <span class="mono">${fixed.join(", ")}</span>
647 </p>`,
648 )}
649 <form method="post" action="/settings/origins/trusted-hosts">
650 ${uiField({
651 label: "Host names",
652 name: "hosts",
653 type: "textarea",
654 rows: 3,
655 value: text,
656 mono: true,
657 spellcheck: false,
658 ...(error ? { error } : {}),
659 })}
660 <div class="form-actions">${btn("Save", { variant: "primary" })}</div>
661 </form>`,
662 );
663 }
664
613665 // ---- the page ----
614666
615667 function originsView(ctx: Ctx, values: ConnectValues): Html {
@@ -640,6 +692,12 @@ function originsView(ctx: Ctx, values: ConnectValues): Html {
640692 ${origins.map((o) => originCard(o, mirrors.get(o.id)?.count ?? 0))}
641693 </div>`}
642694 ${frozenCard(ctx)}
695 ${mayEditTrustedHosts(user)
696 ? trustedHostsCard(
697 values.trustedText ?? editableHosts().join("\n"),
698 values.trustedError,
699 )
700 : null}
643701 ${card(
644702 html`${tokenSection(values)}
645703 <details
@@ -930,6 +988,29 @@ export async function originAppCreate(ctx: Ctx): Promise<Response> {
930988 });
931989 }
932990
991 // POST /settings/origins/trusted-hosts
992 export async function originTrustedHosts(ctx: Ctx): Promise<Response> {
993 const denied = accountOnly(ctx);
994 if (denied) return denied;
995 if (!mayEditTrustedHosts(ctx.auth.user))
996 return new Response("forbidden", { status: 403 });
997 const form = await readForm(ctx, "admin");
998 if (form instanceof Response) return form;
999 const text = field(form, "hosts");
1000 const saved = updateTrustedHosts(ctx.app.db, ctx.auth.user, text);
1001 if (!saved.ok) {
1002 if (saved.error === "forbidden")
1003 return new Response("forbidden", { status: 403 });
1004 const values = blankValues();
1005 values.trustedText = text.slice(0, 4000);
1006 values.trustedError = rejectedSentence(saved.rejected);
1007 return shell(ctx, originsView(ctx, values), 422);
1008 }
1009 return flashTo(ctx, "/settings/origins", {
1010 message: "Trusted internal hosts saved.",
1011 });
1012 }
1013
9331014 // POST /settings/origins/apps/:id/delete
9341015 export async function originAppDelete(ctx: Ctx): Promise<Response> {
9351016 const denied = accountOnly(ctx);
test/trusted-hosts-ui.test.ts added +200
@@ -0,0 +1,200 @@
1 /** The trusted-host list's two doors (page, terminal) and the migrate path (#3290). */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { resolveSource } from "../src/actions/migrate.ts";
9 import { createOrigin } from "../src/core/origins.ts";
10 import { createUser, getUserByName } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { loadTrustedHosts, parseHosts, trustedHosts } from "../src/origins/trusted-hosts.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import type { TuiIdentity } from "../src/tui/session.ts";
15 import { headlessTerminal } from "../src/tui/term/terminal.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17 import { settleApp } from "./helpers/tui.ts";
18
19 quietLogs();
20
21 describe("trusted internal hosts", () => {
22 let dataDir: string;
23 let app: App;
24 let servers: Servers;
25
26 const login = async (username: string): Promise<string> => {
27 const res = await fetch(`${servers.url}/signin`, {
28 method: "POST",
29 redirect: "manual",
30 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
31 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
32 });
33 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
34 };
35 const post = (cookie: string, hosts: string, origin = servers.url) =>
36 fetch(`${servers.url}/settings/origins/trusted-hosts`, {
37 method: "POST",
38 redirect: "manual",
39 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin },
40 body: new URLSearchParams({ hosts }),
41 });
42 const changes = () =>
43 app.db
44 .query<{ to_value: string }, []>(
45 "SELECT to_value FROM forge_setting_change WHERE key = 'outbound.trusted_hosts' ORDER BY rowid",
46 )
47 .all()
48 .map((r) => r.to_value);
49
50 beforeAll(async () => {
51 dataDir = await mkdtemp(join(tmpdir(), "forge-trusted-"));
52 app = createApp({ dataDir, port: 0 });
53 servers = await serve(app);
54 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
55 const made = await createUser(app.db, {
56 origin: "seed",
57 trusted: true,
58 username,
59 email: `${username}@forge.local`,
60 password: "a-good-passphrase",
61 adminIfFirst,
62 });
63 if (!made.ok) throw new Error(made.error);
64 }
65 loadTrustedHosts(app.db, {});
66 });
67 afterAll(async () => {
68 loadTrustedHosts(undefined, {});
69 servers.stop();
70 app.close();
71 await rm(dataDir, { recursive: true, force: true });
72 });
73
74 test("parseHosts names what it refuses", () => {
75 expect(parseHosts("a.test 10.0.0.1 *.b.test https://c.test")).toEqual({
76 hosts: ["a.test"],
77 rejected: ["10.0.0.1", "*.b.test", "https://c.test"],
78 });
79 });
80
81 test("a non-admin gets 403, sees no card, and nothing changes", async () => {
82 const bob = await login("bob");
83 expect((await post(bob, "git.internal.test")).status).toBe(403);
84 expect(trustedHosts()).toEqual([]);
85 const page = await (await fetch(`${servers.url}/settings/origins`, { headers: { cookie: bob } })).text();
86 expect(page).not.toContain("Trusted internal hosts");
87 });
88
89 test("a cross-origin post is refused", async () => {
90 const alice = await login("alice");
91 const res = await post(alice, "git.internal.test", "https://evil.test");
92 expect(res.status).toBeGreaterThanOrEqual(400);
93 expect(trustedHosts()).toEqual([]);
94 });
95
96 test("entries that are not names are refused aloud and nothing is saved", async () => {
97 const alice = await login("alice");
98 const res = await post(alice, "git.internal.test 10.0.0.1");
99 expect(res.status).toBe(422);
100 const text = await res.text();
101 expect(text).toContain("Not host names");
102 expect(text).toContain("10.0.0.1");
103 expect(trustedHosts()).toEqual([]);
104 expect(changes()).toEqual([]);
105 });
106
107 test("an admin save is live, audited, shown, and marks the origin row", async () => {
108 const alice = await login("alice");
109 const res = await post(alice, "git.internal.test");
110 expect(res.status).toBe(303);
111 expect(trustedHosts()).toEqual(["git.internal.test"]);
112 expect(changes()).toEqual(['["git.internal.test"]']);
113
114 const user = getUserByName(app.db, "alice")!;
115 for (const [login, host] of [["me", "git.internal.test"], ["you", "other.test"]] as const) {
116 const made = createOrigin(app.db, dataDir, {
117 userId: user.id,
118 kind: "forgejo",
119 apiUrl: `https://${host}/api/v1`,
120 webUrl: `https://${host}`,
121 login,
122 token: "t0ken",
123 });
124 expect(made.ok).toBe(true);
125 }
126 const page = await (await fetch(`${servers.url}/settings/origins`, { headers: { cookie: alice } })).text();
127 expect(page).toContain("Trusted internal hosts");
128 expect(page).toContain("git.internal.test");
129 expect(page.match(/trusted internal host/g)?.length).toBe(1);
130 });
131
132 test("migrate lets a non-admin reach a trusted host that resolves privately, not an untrusted one", async () => {
133 const bob = getUserByName(app.db, "bob")!;
134 const resolve = async () => ["10.43.191.15"];
135 const trusted = await resolveSource(app, bob, { url: "https://git.internal.test/a/b.git" }, resolve);
136 expect(trusted.ok).toBe(true);
137 const other = await resolveSource(app, bob, { url: "https://nope.test/a/b.git" }, resolve);
138 expect(other).toEqual({ ok: false, error: "private_host" });
139 });
140
141 describe("in the terminal", () => {
142 function boot(username: string) {
143 const terminal = headlessTerminal(140, 32);
144 const user = getUserByName(app.db, username)!;
145 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
146 const { app: tui } = createForgeApp(app, identity, { terminal, exitHooks: false });
147 tui.start();
148 const key = (k: string, ctrl = false) =>
149 tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
150 return {
151 text: () => tui.screen.toText(),
152 async palette() {
153 key("k", true);
154 for (const ch of "Trusted hosts") key(ch);
155 await settleApp(tui);
156 },
157 async type(text: string) {
158 for (const ch of text) key(ch);
159 await settleApp(tui);
160 },
161 async enter() {
162 key("enter");
163 await settleApp(tui);
164 },
165 stop: () => tui.stop(),
166 };
167 }
168
169 test("a non-admin is not offered it", async () => {
170 const h = boot("bob");
171 await h.palette();
172 expect(h.text()).not.toContain("internal hosts an origin may reach");
173 h.stop();
174 });
175
176 test("an admin reads and saves the list through the same door", async () => {
177 const h = boot("alice");
178 await h.palette();
179 expect(h.text()).toContain("internal hosts an origin may reach");
180 await h.enter();
181 expect(h.text()).toContain("git.internal.test");
182 await h.type(" 10.1.1.1");
183 await h.enter();
184 expect(h.text()).toContain("Not host names");
185 expect(trustedHosts()).toEqual(["git.internal.test"]);
186 h.stop();
187 });
188
189 test("an admin saves from the terminal: live and audited", async () => {
190 const h = boot("alice");
191 await h.palette();
192 await h.enter();
193 await h.type(" second.test");
194 await h.enter();
195 expect(trustedHosts().sort()).toEqual(["git.internal.test", "second.test"]);
196 expect(changes().at(-1)).toBe('["git.internal.test","second.test"]');
197 h.stop();
198 });
199 });
200 });
test/trusted-hosts.test.ts added +82
@@ -0,0 +1,82 @@
1 import { afterEach, expect, test } from "bun:test";

The kernel-level tests are good. Missing: the migrate path (a non-admin URL migrate to a trusted host that resolves privately gets past private_host, and an untrusted one is still refused) and the page/route behaviours listed in the review body.

2 import { Database } from "bun:sqlite";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { outboundFetch, RefusedError, vetHost } from "../src/kernel/outbound.ts";
7 import {
8 fetchMirror,
9 MIRROR_REFUSED,
10 provideMirrorOutboundForTest,
11 } from "../src/origins/git-mirror.ts";
12 import {
13 isTrustedHost,
14 loadTrustedHosts,
15 parseHostList,
16 saveTrustedHosts,
17 trustedHosts,
18 } from "../src/origins/trusted-hosts.ts";
19
20 const inCluster = async () => ["10.43.191.15"];
21
22 afterEach(() => {
23 loadTrustedHosts(undefined, {});
24 provideMirrorOutboundForTest({ allow: ["127.0.0.1", "::1"] });
25 });
26
27 test("a host resolving to 10.x is refused by default and passes once trusted by name", async () => {
28 const host = "git.open-platform.sh";
29 expect(await vetHost(host, { resolve: inCluster })).toEqual({ ok: false, error: "refused" });
30 const trusted = await vetHost(host, { resolve: inCluster, trusted: [host] });
31 expect(trusted).toEqual({ ok: true, value: ["10.43.191.15"] });
32 // Exact name only: a sibling or a subdomain is not the host.
33 for (const other of ["evil.open-platform.sh", `x.${host}`, "open-platform.sh"])
34 expect(await vetHost(other, { resolve: inCluster, trusted: [host] })).toEqual({
35 ok: false,
36 error: "refused",
37 });
38 // Never by address.
39 expect(await vetHost("10.43.191.15", { trusted: ["10.43.191.15"] })).toEqual({
40 ok: false,
41 error: "refused",
42 });
43 });
44
45 test("origin sync to a trusted host gets past the vet; an untrusted one is refused", async () => {
46 const dir = await mkdtemp(join(tmpdir(), "trusted-"));
47 try {
48 provideMirrorOutboundForTest({ resolve: inCluster });
49 const refused = await fetchMirror(dir, "https://git.open-platform.sh/a/b.git", {});
50 expect(!refused.ok && refused.error.message).toBe(MIRROR_REFUSED);
51
52 loadTrustedHosts(undefined, { FORGE_OUTBOUND_TRUSTED_HOSTS: "git.open-platform.sh" });
53 const past = await fetchMirror(dir, "https://git.open-platform.sh/a/b.git", {});
54 // Not a repository, so git itself fails — but not the refusal.
55 expect(!past.ok && past.error.message).not.toBe(MIRROR_REFUSED);
56 } finally {
57 await rm(dir, { recursive: true, force: true });
58 }
59 });
60
61 test("a webhook to a trusted host is still refused", async () => {
62 loadTrustedHosts(undefined, { FORGE_OUTBOUND_TRUSTED_HOSTS: "git.open-platform.sh" });
63 expect(isTrustedHost("git.open-platform.sh")).toBe(true);
64 await expect(
65 outboundFetch("https://git.open-platform.sh/hook", {}, { resolve: inCluster }),
66 ).rejects.toBeInstanceOf(RefusedError);
67 });
68
69 test("the list keeps only host names, from the environment and the admin's row", () => {
70 expect(parseHostList("Git.Example.test, 10.0.0.1 https://x.test *.y.test\nlocalhost a_b")).toEqual([
71 "git.example.test",
72 "localhost",
73 ]);
74 const db = new Database(":memory:");
75 db.run(`CREATE TABLE forge_setting (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_by TEXT, updated_at TEXT);
76 CREATE TABLE forge_setting_change (key TEXT, from_value TEXT, to_value TEXT, actor_id TEXT, at TEXT)`);
77 loadTrustedHosts(db, { FORGE_OUTBOUND_TRUSTED_HOSTS: "a.test" });
78 saveTrustedHosts(db, ["b.test"], "u1");
79 expect(trustedHosts().sort()).toEqual(["a.test", "b.test"]);
80 loadTrustedHosts(db, {});
81 expect(trustedHosts()).toEqual(["b.test"]);
82 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs