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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3290-origin-sync-to-the-owner-s-own-forgejo-w → main 2 commits fork 6 threads · 6 unresolved
README.md +2
| @@ -614,6 +614,8 @@ paged. Every other cause (`drill`, `mirror`, `stuck:*`, `red:*`, `snapshots`, | ||
| 614 | 614 | |
| 615 | 615 | ## Origins |
| 616 | 616 | |
| 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 | ||
| 617 | 619 | Your forge is the index of everything you work on, wherever it actually lives. |
| 618 | 620 | An **origin** is a connected account on another forge — GitHub, Forgejo, Gitea. |
| 619 | 621 | 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"; | ||
| 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 { isTrustedHost } from "../origins/trusted-hosts.ts"; | |
| 31 | 32 | import { type Resolve, vetHost } from "../kernel/outbound.ts"; |
| 32 | 33 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 33 | 34 | import { providerForOrigin } from "../origins/credential.ts"; |
| @@ -325,7 +326,7 @@ function nameFromUrl(url: string): string { | ||
| 325 | 326 | return last.replace(/\.git$/i, ""); |
| 326 | 327 | } |
| 327 | 328 | |
| 328 | async function resolveSource( | |
| 329 | export async function resolveSource( | |
| 329 | 330 | app: App, |
| 330 | 331 | user: User, |
| 331 | 332 | source: MigrateSource, |
| @@ -339,7 +340,9 @@ async function resolveSource( | ||
| 339 | 340 | // become a way to knock on it. An admin may; a forge run for one person |
| 340 | 341 | // may say so once (`FORGE_MIGRATE_PRIVATE_HOSTS=1`). |
| 341 | 342 | const mayReachPrivate = |
| 342 | user.isAdmin || app.config.migrateFromPrivateHosts === true; | |
| 343 | user.isAdmin || | |
| 344 | app.config.migrateFromPrivateHosts === true || | |
| 345 | isTrustedHost(new URL(url).hostname); | |
| 343 | 346 | if (!mayReachPrivate) { |
| 344 | 347 | const hostname = new URL(url).hostname; |
| 345 | 348 | if (isPrivateHost(hostname)) return err("private_host"); |
src/http/server.ts +2
| @@ -446,6 +446,7 @@ import { | ||
| 446 | 446 | originReposPage, |
| 447 | 447 | originSyncSubmit, |
| 448 | 448 | originsPage, |
| 449 | originTrustedHosts, | |
| 449 | 450 | } from "../web/pages/origins.ts"; |
| 450 | 451 | import { pushThrough } from "../origins/push-through.ts"; |
| 451 | 452 | import { notFoundPage } from "../web/chrome.ts"; |
| @@ -951,6 +952,7 @@ export function buildRouter(): Router<Handler> { | ||
| 951 | 952 | router.add("POST", "/settings/origins/token", originConnectToken); |
| 952 | 953 | router.add("POST", "/settings/origins/oauth", originOAuthStart); |
| 953 | 954 | router.add("POST", "/settings/origins/apps", originAppCreate); |
| 955 | router.add("POST", "/settings/origins/trusted-hosts", originTrustedHosts); | |
| 954 | 956 | router.add("POST", "/settings/origins/apps/:id/delete", originAppDelete); |
| 955 | 957 | router.add("GET", "/settings/origins/flows/:id", originFlowPage); |
| 956 | 958 | router.add("POST", "/settings/origins/flows/:id/poll", originFlowPoll); |
src/kernel/outbound.ts +10
| @@ -39,6 +39,11 @@ export interface OutboundOptions { | ||
| 39 | 39 | resolve?: Resolve; |
| 40 | 40 | /** Addresses exempt from the refusal list. For tests only; never read from the environment. */ |
| 41 | 41 | 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[]; | |
| 42 | 47 | /** Permit plain http. For tests only; never read from the environment. */ |
| 43 | 48 | plain?: boolean; |
| 44 | 49 | /** Extra trusted CA certificate (PEM) for https. For tests only. */ |
| @@ -73,6 +78,11 @@ export async function vetHost( | ||
| 73 | 78 | } |
| 74 | 79 | } |
| 75 | 80 | if (found.length === 0) return err("unresolved"); |
| 81 | if ( | |
| 82 | isIP(bare) === 0 && | |
| 83 | (options.trusted ?? []).includes(bare.toLowerCase()) | |
| 84 | ) | |
| 85 | return ok([...found]); | |
| 76 | 86 | const allow = options.allow ?? []; |
| 77 | 87 | if (found.some((a) => isRefusedAddress(a) && !allow.includes(a))) |
| 78 | 88 | return err("refused"); |
src/main.ts +2
| @@ -13,6 +13,7 @@ import { parentUrlProblem } from "./core/parent_releases.ts"; | ||
| 13 | 13 | import { releaseEnv } from "./release/env.ts"; |
| 14 | 14 | import { createApp } from "./app.ts"; |
| 15 | 15 | import { backfillCarried } from "./core/carried.ts"; |
| 16 | import { loadTrustedHosts } from "./origins/trusted-hosts.ts"; | |
| 16 | 17 | import { flushFlagUses, loadFlags } from "./core/flags.ts"; |
| 17 | 18 | import { reconcile } from "./core/reconcile.ts"; |
| 18 | 19 | import { offerStartingBoards } from "./actions/boards.ts"; |
| @@ -261,6 +262,7 @@ const app = createApp({ | ||
| 261 | 262 | }); |
| 262 | 263 | |
| 263 | 264 | loadFlags(app.db); |
| 265 | loadTrustedHosts(app.db); | |
| 264 | 266 | // FORGE_SIGNUP (checked in configProblem) sets who may sign up; kept like any change. |
| 265 | 267 | const bootSignup = signupFromEnv(process.env.FORGE_SIGNUP); |
| 266 | 268 | if (bootSignup.ok && bootSignup.value !== null) |
src/origins/discover.ts +3 −1
| @@ -22,6 +22,7 @@ import { | ||
| 22 | 22 | RefusedError, |
| 23 | 23 | vetHost, |
| 24 | 24 | } from "../kernel/outbound.ts"; |
| 25 | import { trustedHosts } from "./trusted-hosts.ts"; | |
| 25 | 26 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 26 | 27 | import { readCapped } from "./providers/http.ts"; |
| 27 | 28 | import type { Discovered, OriginKind } from "./types.ts"; |
| @@ -160,8 +161,9 @@ export function allowedOrigin( | ||
| 160 | 161 | export async function discover( |
| 161 | 162 | input: string | DiscoverInput, |
| 162 | 163 | env: Record<string, string | undefined> = process.env, |
| 163 | options: OutboundOptions = {}, | |
| 164 | given: OutboundOptions = {}, | |
| 164 | 165 | ): Promise<Result<Discovered, DiscoverError>> { |
| 166 | const options: OutboundOptions = { trusted: trustedHosts(), ...given }; | |
| 165 | 167 | const wanted: DiscoverInput = |
| 166 | 168 | typeof input === "string" ? { host: input } : input; |
| 167 | 169 | 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"; | ||
| 24 | 24 | import { listRefs } from "../git/read.ts"; |
| 25 | 25 | import { ZERO_OID } from "../git/hooks.ts"; |
| 26 | 26 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 27 | import { trustedHosts } from "./trusted-hosts.ts"; | |
| 27 | 28 | import { vetHost, type OutboundOptions } from "../kernel/outbound.ts"; |
| 28 | 29 | import { pinFor, remoteGitEnv } from "../git/remote-env.ts"; |
| 29 | 30 | import { redact, safeRemoteUrl, secretsIn } from "./credentials.ts"; |
| @@ -61,7 +62,7 @@ async function vettedEnv( | ||
| 61 | 62 | authEnv: Record<string, string>, |
| 62 | 63 | ): Promise<Result<Record<string, string>, GitMirrorError>> { |
| 63 | 64 | const host = new URL(url).hostname; |
| 64 | const vetted = await vetHost(host, outbound); | |
| 65 | const vetted = await vetHost(host, { ...outbound, trusted: trustedHosts() }); | |
| 65 | 66 | if (!vetted.ok) |
| 66 | 67 | return err({ |
| 67 | 68 | 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. |
||
| 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. |
||
| 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"; | ||
| 46 | 46 | import { dashboardScreen } from "./screens/dashboard.ts"; |
| 47 | 47 | import { mayManageFlags } from "../core/flags.ts"; |
| 48 | 48 | import { accessScreen } from "./screens/access.ts"; |
| 49 | import { mayEditTrustedHosts } from "../origins/trusted-hosts.ts"; | |
| 50 | import { trustedHostsPrompt } from "./screens/trustedhosts.ts"; | |
| 49 | 51 | import { flagsScreen } from "./screens/flags.ts"; |
| 50 | 52 | import { scorecardScreen } from "./screens/scorecard.ts"; |
| 51 | 53 | import { cloudScreen } from "./screens/cloud.ts"; |
| @@ -363,6 +365,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 363 | 365 | hint: "declared flags and their live values", |
| 364 | 366 | run: () => ctx.push(flagsScreen(session, ctx)), |
| 365 | 367 | }); |
| 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 | }); | |
| 366 | 376 | // Settings → Usage, as the page has it: the same gate as Flags (#1565). |
| 367 | 377 | if (mayManageFlags(session.user)) |
| 368 | 378 | 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 { | ||
| 30 | 30 | syncOriginNow, |
| 31 | 31 | type Reattachment, |
| 32 | 32 | } from "../../actions/origins.ts"; |
| 33 | import { | |
| 34 | editableHosts, | |
| 35 | envHosts, | |
| 36 | isTrustedHost, | |
| 37 | mayEditTrustedHosts, | |
| 38 | rejectedSentence, | |
| 39 | updateTrustedHosts, | |
| 40 | } from "../../origins/trusted-hosts.ts"; | |
| 33 | 41 | import { reattachMessage } from "./frozen.ts"; |
| 34 | 42 | import { getFlowFor } from "../../origins/oauth.ts"; |
| 35 | 43 | import { |
| @@ -319,6 +327,13 @@ function originCard(origin: Origin, mirrors: number): Html { | ||
| 319 | 327 | ? html`never synced` |
| 320 | 328 | : html`synced ${time(origin.lastSyncAt)}`}</span |
| 321 | 329 | > |
| 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 | )} | |
| 322 | 337 | </span> |
| 323 | 338 | ${healthBadge(origin)} |
| 324 | 339 | </div> |
| @@ -358,6 +373,8 @@ interface ConnectValues { | ||
| 358 | 373 | tokenHost: string; |
| 359 | 374 | tokenError?: string; |
| 360 | 375 | registerError?: string; |
| 376 | trustedText?: string; | |
| 377 | trustedError?: string; | |
| 361 | 378 | } |
| 362 | 379 | |
| 363 | 380 | const blankValues = (): ConnectValues => ({ |
| @@ -610,6 +627,41 @@ function tokenSection(values: ConnectValues): Html { | ||
| 610 | 627 | </div>`; |
| 611 | 628 | } |
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 |
||
| 612 | 629 | |
| 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 | ||
| 613 | 665 | // ---- the page ---- |
| 614 | 666 | |
| 615 | 667 | function originsView(ctx: Ctx, values: ConnectValues): Html { |
| @@ -640,6 +692,12 @@ function originsView(ctx: Ctx, values: ConnectValues): Html { | ||
| 640 | 692 | ${origins.map((o) => originCard(o, mirrors.get(o.id)?.count ?? 0))} |
| 641 | 693 | </div>`} |
| 642 | 694 | ${frozenCard(ctx)} |
| 695 | ${mayEditTrustedHosts(user) | |
| 696 | ? trustedHostsCard( | |
| 697 | values.trustedText ?? editableHosts().join("\n"), | |
| 698 | values.trustedError, | |
| 699 | ) | |
| 700 | : null} | |
| 643 | 701 | ${card( |
| 644 | 702 | html`${tokenSection(values)} |
| 645 | 703 | <details |
| @@ -930,6 +988,29 @@ export async function originAppCreate(ctx: Ctx): Promise<Response> { | ||
| 930 | 988 | }); |
| 931 | 989 | } |
| 932 | 990 | |
| 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 | ||
| 933 | 1014 | // POST /settings/origins/apps/:id/delete |
| 934 | 1015 | export async function originAppDelete(ctx: Ctx): Promise<Response> { |
| 935 | 1016 | 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 |
||
| 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 | }); |
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
vetHostfor 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.