Compare commits

..
2 Commits
Author SHA1 Message Date
jochen 1faee6e279 lab: a forge setting that is not http(s) is never a trusted origin (hq issue 624, review 222)
mesh/merge-gate head 1faee6e27976 merged into trunk 9c166398f204 — fail: builds gitea, lab → novox, shanks; no bus step; 4 problem(s) the change brings; th…
mesh/repo-check head 1faee6e27976 merged into trunk 9c166398f204 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery rejected: the gate failed or could not run, or the repository's own check failed
2026-10-13 20:33:13 +02:00
jochen 6f7d06726c 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.
2026-10-13 20:33:02 +02:00
5 changed files with 137 additions and 21 deletions
+7
View File
@@ -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.
+2 -1
View File
@@ -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",
+68 -6
View File
@@ -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,65 @@ 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:\/\/<host>/],
["https://git.example.org/elsewhere/", /is not https:\/\/<host>/],
["https://git.example.org/api/packages/novox/npm/?x=1", /is not https:\/\/<host>/],
["https://u:p@git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
["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 });
});
// A forge setting that is not http(s) is never a trusted origin (review of #280): a `file:` tarball's origin
// is "null", as is a `file:` forge's, and the two must not match.
test("a forge setting that is not http(s) trusts no origin of its own", async () => {
const got = await npmRegistry("file:///srv/forge", aRegistry(() => packument("file:///api/packages/novox/npm/x.tgz")).get);
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/);
const root = "https://git.example.org/api/packages/novox/npm/";
const viaRoot = await npmRegistry("ftp://novox.internal", aRegistry(() =>
packument("ftp://novox.internal/api/packages/novox/npm/x.tgz")).get, root);
assert.match(viaRoot.problem ?? "", /neither/);
});
+11 -3
View File
@@ -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 } : {}) };
},
+49 -11
View File
@@ -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<string, unknown> }).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://<host>${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,16 @@ export async function npmRegistry(forge: string, get: typeof fetch = fetch): Pro
} catch {
return fallback(`the forge ${JSON.stringify(forge)} is not a URL`);
}
// The forge setting's own origin is trusted only as an http(s) one: any other scheme's origin is
// "null", or names no registry npm would fetch from.
const trusted = own.protocol === "https:" || own.protocol === "http:" ? [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 +150,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));