From 6f7d06726c92319b2a11f09026079dc04a810122 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 16:53:26 +0200 Subject: [PATCH] gitea serves the registry's public root, and the lab trusts a tarball only at it or the forge's own origin (hq ADR 0346, issue 624) The forge writes every tarball at its public name and the lab reaches it at a private address, so npm 12 refused every locked @novox package. The public name now comes from the mesh's own composition of the forge's route, through the lab's binding, and the lab compares scheme, host and port. --- modules/gitea/README.md | 7 ++++ modules/gitea/module.json | 3 +- modules/lab/test/runs.test.ts | 63 +++++++++++++++++++++++++++++++---- modules/lab/tools/index.ts | 14 ++++++-- modules/lab/tools/runs.ts | 58 ++++++++++++++++++++++++++------ 5 files changed, 124 insertions(+), 21 deletions(-) diff --git a/modules/gitea/README.md b/modules/gitea/README.md index 8d474811..cb5edb23 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -4,6 +4,13 @@ The forge, and the mesh's npm package registry: it claims the `git` and `npm-pac Its tools, its events and the provisioner of registry accounts are the TypeScript bundle (`index.ts`, `tools/`, `provisioner/`). This README covers the two Go bundles beside it. +**What a registry consumer is told** (`serves.npm-package-registry`): where the registry answers on the +private network (`scheme`, `port`, `npm-path`), and its `public-root`, the address the forge writes every +tarball at: `https://` and the forge's route's composed public name, under the npm path +(`${bound:route:name-web}`, novox/hq ADR 0346). A lock file holds that address, and npm 12 installs a +locked tarball only from the registry it is configured with, so a consumer that installs from lock files +trusts that root. On a machine with no public domain the key is left out, and the plan says so. + ## Announcing pull requests to the merge check (novox/hq to-be 45 §9, issue 347) Every new head of an open pull request is announced as `pull.updated`, once, and the controller has it checked. diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 6280acff..5f751f90 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -68,7 +68,8 @@ "npm-package-registry": { "scheme": "http", "port": 3000, - "npm-path": "/api/packages/novox/npm/" + "npm-path": "/api/packages/novox/npm/", + "public-root": "https://${bound:route:name-web}/api/packages/novox/npm/" }, "git": { "scheme": "http", diff --git a/modules/lab/test/runs.test.ts b/modules/lab/test/runs.test.ts index 87dc1d12..196a9c45 100644 --- a/modules/lab/test/runs.test.ts +++ b/modules/lab/test/runs.test.ts @@ -275,7 +275,7 @@ const packument = (tarball: string) => new Response(JSON.stringify({ test("a run names the package registry by the address the registry gives its tarballs", async () => { const r = aRegistry(() => packument("https://forge.example.org/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")); - const got = await npmRegistry("http://forge.example.org:3000/", r.get); + const got = await npmRegistry("http://forge.example.org:3000/", r.get, "https://forge.example.org/api/packages/novox/npm/"); assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/novox/npm/" }); assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/novox/npm/@novox%2fmesh-sdk"]); // The forge's own scheme is taken too, with its port. @@ -285,8 +285,8 @@ test("a run names the package registry by the address the registry gives its tar }); // What the registry answers may come over plain HTTP, so it never decides where packages come from: only -// the forge's own host, at https or the forge's scheme, under the npm path, with nothing after it -// (review of mesh-catalog #275). +// the forge's public root or its own address, scheme, host and port, under the npm path, with nothing after +// it (review of mesh-catalog #275, ADR 0346). test("a registry that does not give an address the lab takes leaves the forge's, and the reason is said", async () => { const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"; for (const [answer, why] of [ @@ -294,13 +294,13 @@ test("a registry that does not give an address the lab takes leaves the forge's, [() => new Response("{}", { status: 200 }), /no tarball/], [() => { throw new Error("connect ECONNREFUSED"); }, /ECONNREFUSED/], [() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/novox\/npm\//], - [() => packument(`https://attacker.example.net${sdk}`), /attacker\.example\.net, which is not the forge's host novox\.internal/], + [() => packument(`https://attacker.example.net${sdk}`), /at https:\/\/attacker\.example\.net, which is neither the forge's public root nor its own address/], [() => packument(`https://attacker.example.net/x?u=https://novox.internal${sdk}`), /query or a fragment/], [() => packument(`https://novox.internal${sdk}?x=1`), /query or a fragment/], [() => packument(`https://novox.internal${sdk}#frag`), /query or a fragment/], [() => packument(`https://attacker.example.net/#https://novox.internal${sdk}`), /query or a fragment/], - [() => packument(`ftp://novox.internal${sdk}`), /ftp: — neither https: nor the forge's http:/], - [() => packument(`file:///api/packages/novox/npm/x.tgz`), /file: — neither/], + [() => packument(`ftp://novox.internal${sdk}`), /at ftp:\/\/novox\.internal, which is neither/], + [() => packument(`file:///api/packages/novox/npm/x.tgz`), /which is neither/], [() => packument(`https://novox.internal/api/packages/novox/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/], [() => packument(`https://user:pw@novox.internal${sdk}`), /user or password/], ] as [() => Response, RegExp][]) { @@ -319,3 +319,54 @@ test("a run's npm configuration names the registry it was given, and the forge's const none = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}); assert.match(none, /'@novox:registry=http:\/\/novox\.internal:20000\/api\/packages\/novox\/npm\/'/); }); + +// The public root the forge serves in the lab's binding (novox/hq ADR 0346) is the one other origin a +// tarball may come from, by scheme, host and port: the review of #275 found the port ignored. +import { publicRootOf } from "../tools/runs.ts"; + +test("a tarball is taken at the forge's public root, and at no other port or scheme of its host", async () => { + const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"; + const root = "https://git.example.org/api/packages/novox/npm/"; + const at = (tarball: string, publicRoot?: string) => + npmRegistry("http://novox.internal:20000", aRegistry(() => packument(tarball)).get, publicRoot); + assert.deepEqual(await at(`https://git.example.org${sdk}`, root), { registry: root }); + for (const tarball of [`https://git.example.org:8443${sdk}`, `http://git.example.org${sdk}`, `https://example.org${sdk}`]) { + const got = await at(tarball, root); + assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", tarball); + assert.match(got.problem ?? "", /neither the forge's public root nor its own address/, tarball); + } + // Without a public root, the forge's public name is not trusted on its own word. + assert.match((await at(`https://git.example.org${sdk}`)).problem ?? "", /neither/); + // And the forge's own address only at its own port. + assert.match((await at(`http://novox.internal${sdk}`, root)).problem ?? "", /neither/); +}); + +test("the public root is read from the binding, and only an https root at the npm path is taken", () => { + const dir = mkdtempSync(join(tmpdir(), "lab-root-")); + const binding = (root: unknown) => { + const f = join(dir, `b-${Math.random()}.json`); + writeFileSync(f, JSON.stringify({ provision: "npm-package-registry", at: "home", serves: { scheme: "http", port: 3000, + "npm-path": "/api/packages/novox/npm/", ...(root === undefined ? {} : { "public-root": root }) } })); + return f; + }; + assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/novox/npm/")), + { root: "https://git.example.org/api/packages/novox/npm/" }); + assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/novox/npm/")), + { root: "https://git.example.org:8443/api/packages/novox/npm/" }); + for (const [root, why] of [ + [undefined, /serves no public-root/], + ["http://git.example.org/api/packages/novox/npm/", /is not https:\/\//], + ["https://git.example.org/elsewhere/", /is not https:\/\//], + ["https://git.example.org/api/packages/novox/npm/?x=1", /is not https:\/\//], + ["https://u:p@git.example.org/api/packages/novox/npm/", /is not https:\/\//], + ["https://git.example.org/api/packages/novox/npm/\nallow-remote=all", /whitespace or a control character/], + ["https://${bound:route:name-web}/api/packages/novox/npm/", /not a URL|is not https/], + ] as [unknown, RegExp][]) { + const got = publicRootOf(binding(root)); + assert.equal(got.root, undefined, String(root)); + assert.match(got.problem ?? "", why, String(root)); + } + assert.match(publicRootOf(join(dir, "none.json")).problem ?? "", /could not be read/); + assert.match(publicRootOf(undefined).problem ?? "", /no npm-package-registry binding/); + rmSync(dir, { recursive: true, force: true }); +}); diff --git a/modules/lab/tools/index.ts b/modules/lab/tools/index.ts index 7a3013e9..4511164a 100644 --- a/modules/lab/tools/index.ts +++ b/modules/lab/tools/index.ts @@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { - checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, readStatus, REPOSITORIES, running, start, + checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start, stop, tail, writeCredential, } from "./runs.js"; @@ -19,6 +19,14 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { // without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance. const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, ""); // The forge credential the mesh binds and seals to the lab (novox/hq issue 624), read at each call. + // The registry's public root the forge serves in the lab's binding (novox/hq ADR 0346), read at each call. + const bindingOf = (): string | undefined => + env.MESH_LAB_FORGE_BINDING ?? (wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING") || undefined); + const registryOf = async (forge: string) => { + const root = publicRootOf(bindingOf()); + const registry = await npmRegistry(forge, fetch, root.root); + return registry.problem && root.problem ? { ...registry, problem: `${registry.problem}; ${root.problem}` } : registry; + }; const credentialOf = (forge: string): string | undefined => forgeCredential(forge, env.MESH_LAB_FORGE_BINDING ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING"), env.MESH_LAB_FORGE_SECRET ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_SECRET")); @@ -43,7 +51,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" }); problems = setupProblems(forge, `${dir}/setup`, credentialOf(forge)); // And the address a run names the package registry by (npmRegistry): said when the registry does not say it. - const registry = await npmRegistry(forge); + const registry = await registryOf(forge); if (registry.problem) problems.push(registry.problem); } finally { rmSync(dir, { recursive: true, force: true }); @@ -90,7 +98,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` }; const busy = running(work); if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy }; - const registry = await npmRegistry(forge); + const registry = await registryOf(forge); return { started: true, run: start(work, forge, tests, refs, credentialOf(forge), registry.registry), registry: registry.registry, ...(registry.problem ? { problem: registry.problem } : {}) }; }, diff --git a/modules/lab/tools/runs.ts b/modules/lab/tools/runs.ts index 5572b843..c048fa60 100644 --- a/modules/lab/tools/runs.ts +++ b/modules/lab/tools/runs.ts @@ -63,9 +63,38 @@ fi`; /** Where the forge serves the mesh's npm packages, under its own address (gitea's npm registry for the novox org). */ export const NPM_PATH = "/api/packages/novox/npm/"; +/** + * The registry's public root, as the forge serves it in the lab's npm-package-registry binding + * (`serves.public-root`, novox/hq ADR 0346): the forge's route's composed public name under the npm path. + * The mesh writes the binding, and it is checked all the same: an https URL whose path is the npm path and + * nothing else. Undefined, with why, when the binding has none or it is not that shape — a forge on a + * machine with no public domain serves none (ADR 0346 §3). + */ +export function publicRootOf(bindingFile: string | undefined): { root?: string; problem?: string } { + if (!bindingFile) return { problem: "the lab has no npm-package-registry binding" }; + let said: unknown; + try { + said = (JSON.parse(readFileSync(bindingFile, "utf8")) as { serves?: Record }).serves?.["public-root"]; + } catch (e) { + return { problem: `its npm-package-registry binding could not be read: ${e instanceof Error ? e.message : String(e)}` }; + } + if (typeof said !== "string" || !said) return { problem: "its npm-package-registry binding serves no public-root" }; + if (/[\s\u0000-\u001f\u007f]/.test(said)) return { problem: "the binding's public-root holds whitespace or a control character" }; + let u: URL; + try { + u = new URL(said); + } catch { + return { problem: `the binding's public-root ${JSON.stringify(said)} is not a URL` }; + } + if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash || u.pathname !== NPM_PATH) { + return { problem: `the binding's public-root ${JSON.stringify(said)} is not https://${NPM_PATH}` }; + } + return { root: `${u.origin}${NPM_PATH}` }; +} + /** * The address a run names the @novox registry by: the one the registry names itself by, when that is the - * forge's own host (novox/hq issue 624). + * forge's public root or the forge's own address (novox/hq issue 624, ADR 0346). * * **npm 12 installs a locked tarball only from the registry it is configured with.** Its `allow-remote` * defaults to `none`, and a lock file's `resolved` URL counts as the registry's only when its origin and @@ -75,18 +104,16 @@ export const NPM_PATH = "/api/packages/novox/npm/"; * for the SDK's newest tarball and names the registry by the origin of that URL. * * **What the registry answers is not trusted for where packages come from** (review of mesh-catalog #275): - * it may be fetched over plain HTTP. The tarball URL is parsed, and taken only when it is https (or the - * forge's own scheme), its path is under the npm path, it carries no query or fragment, and its host is - * the forge's own host. The registry is then built from + * it may be fetched over plain HTTP. The tarball URL is parsed, and taken only when it carries no + * whitespace, control character, user, password, query or fragment, its path is under the npm path, and + * its origin — scheme, host and port — is one the lab already trusts: the public root the forge serves in + * the lab's binding (`publicRoot`, ADR 0346), or the forge setting's own. The registry is then built from * the parsed origin and the npm path alone, never from the string answered, so nothing the registry says * reaches the run's npm configuration but a scheme, a host and a port. Anything else falls back to the * forge's own address and says why, so a run still starts and lab_check names it. - * - * So the lab's forge setting is the address the forge names itself by (its public https name), not a - * second name for it: a forge set as another name of the same server is refused here, named by lab_check, - * and its runs stop at the first locked @novox package. */ -export async function npmRegistry(forge: string, get: typeof fetch = fetch): Promise<{ registry: string; problem?: string }> { +export async function npmRegistry(forge: string, get: typeof fetch = fetch, publicRoot?: string): + Promise<{ registry: string; problem?: string }> { const asked = `${forge.replace(/\/+$/, "")}${NPM_PATH}`; const url = `${asked}@novox%2fmesh-sdk`; const fallback = (why: string) => ({ @@ -99,6 +126,14 @@ export async function npmRegistry(forge: string, get: typeof fetch = fetch): Pro } catch { return fallback(`the forge ${JSON.stringify(forge)} is not a URL`); } + const trusted = [own.origin]; + if (publicRoot) { + try { + trusted.unshift(new URL(publicRoot).origin); + } catch { + // publicRootOf hands only a parsed root; a caller's other string is not trusted. + } + } try { const res = await get(url, { signal: AbortSignal.timeout(15_000) }); if (!res.ok) return fallback(`it answered ${res.status}`); @@ -113,11 +148,12 @@ export async function npmRegistry(forge: string, get: typeof fetch = fetch): Pro } catch { return fallback("its tarball is not a URL"); } - if (t.protocol !== "https:" && t.protocol !== own.protocol) return fallback(`its tarball is ${t.protocol} — neither https: nor the forge's ${own.protocol}`); if (t.username || t.password) return fallback("its tarball URL carries a user or password"); if (t.search || t.hash) return fallback("its tarball URL carries a query or a fragment"); if (!t.pathname.startsWith(NPM_PATH)) return fallback(`its tarball is not under ${NPM_PATH}`); - if (t.hostname !== own.hostname) return fallback(`its tarball is at ${t.hostname}, which is not the forge's host ${own.hostname}`); + if (!trusted.includes(t.origin)) { + return fallback(`its tarball is at ${t.origin}, which is neither the forge's public root nor its own address (${trusted.join(", ")})`); + } return { registry: `${t.origin}${NPM_PATH}` }; } catch (e) { return fallback(e instanceof Error ? e.message : String(e));