// ── Installing and removing a module bundle ──────────────────────────────── // // Phase 4, slice 1 of docs/website/MODULE_SYSTEM.md §2.7.2 — the consumer half // of a release the module's own CI already publishes. Nothing here touches the // database or the loader: this file moves bytes onto the volume and off it, and // the caller (router/v1/admin/modules.controller.js) writes down what happened. // // The shape of an install, and why it is this shape: // // 1. The operator pastes the URL of a release's install manifest — a small // JSON document naming the artifact, its sha256 and its size (decision 2). // There is no catalog, because a catalog would make core's release cadence // decide which modules can exist. // 2. Every URL fetched — the manifest, the artifact, and every redirect hop — // is checked against the admin-managed host allowlist (decision 6). That is // what keeps a pasted URL from also being an SSRF primitive. // 3. The artifact is streamed to a temporary file under a byte cap, hashed as // it arrives, and compared against the manifest's `sha256`. The hash is the // trust anchor; releases are unsigned and say so. // 4. modules/archive.js inspects the archive in full before a single byte is // unpacked, and only then unpacks it — into a TEMPORARY directory. // 5. The unpacked tree's own `module.json` must agree with the manifest about // what it is. A manifest that promises `uo` and delivers something else is // refused rather than installed under the name it was promised. // 6. Only then is anything moved into `modules//`, and the directory it // replaces is kept aside until the move has succeeded. // // **Nothing is written into the modules directory until every check has passed**, // and that is not belt-and-braces. Verified against node-tar 7.5.22 while writing // this: extracting an archive whose fourth member escapes upward throws — and // leaves the first three members on disk. A loader scans that directory at // require time on the next boot; a half-unpacked module is a module. // // One thing this file deliberately does NOT do: mount anything. Installing puts // a directory on the volume, and §1.12 means the volume is read at require time, // so the module appears when the process restarts. The admin screen offers that // restart (decision 1); it is not implied here. const crypto = require('crypto') const fs = require('fs') const fsp = require('fs/promises') const path = require('path') const { Readable, Transform } = require('stream') const { pipeline } = require('stream/promises') const archive = require('./archive') const loader = require('./loader') const log = require('../utils/logger')('modules') // An install manifest is a small JSON document. A megabyte of it is not a // manifest, and reading it into memory unbounded is the one place this file // would otherwise trust a remote length. const MAX_MANIFEST_BYTES = 256 * 1024 // The artifact cap is the archive's own unpacked cap — a compressed bundle // larger than what it is allowed to unpack to has nothing to offer. const MAX_ARTIFACT_BYTES = archive.MAX_BYTES const FETCH_TIMEOUT_MS = 30000 // Gitea serves a release asset through at least one redirect. Following them is // necessary; following them blindly is how an allowlist gets bypassed, so each // hop is re-checked and the chain is bounded. const MAX_REDIRECTS = 5 // Same id rule the loader enforces when scanning, restated rather than imported // so an install cannot put a directory on the volume that the loader would then // refuse to look at. const ID = /^[a-z][a-z0-9-]{1,31}$/ const SHA256 = /^[0-9a-f]{64}$/ class InstallError extends Error { constructor(message, { status = 400 } = {}) { super(message) this.name = 'InstallError' // Carried so the controller can answer 400 for "your URL is wrong" and 502 // for "the host you named misbehaved" without re-deriving it from the text. this.status = status } } // ── The allowlist ────────────────────────────────────────────────────────── // The settings row the allowlist lives in (decision 6): seeded from // MODULE_SOURCE_HOSTS on a fresh install and admin-managed from then on. The KEY // lives here rather than in the admin controller because it is now read from two // places — the controller, and the boot-time resolution of the declared module // set (modules/declared.js), which has no route and no request. const HOSTS_SETTING = 'module_source_hosts' /** * Parse the stored allowlist setting into hostnames. * * Comma or whitespace separated, case-insensitive, empty entries dropped. An * empty list means nothing may be installed from anywhere — a refusal, never a * wildcard. That direction matters: a setting an admin accidentally blanks * should stop installs, not permit every host on the internet. */ function parseHosts(value) { return String(value || '') .split(/[,\s]+/) .map((h) => h.trim().toLowerCase()) .filter(Boolean) } /** * Check one URL against the allowlist, and return it parsed. * * `https` only. A plaintext fetch of code this process is going to execute is * not something an operator should be able to opt into by typing a URL, and the * sha256 does not help — whoever can rewrite the artifact in flight can rewrite * the manifest that declares its hash. */ function checkUrl(raw, hosts) { let url try { url = new URL(String(raw)) } catch { throw new InstallError(`"${raw}" is not a valid URL`) } if (url.protocol !== 'https:') { throw new InstallError(`only https URLs may be installed from (got "${url.protocol}")`) } if (!hosts.length) { throw new InstallError( 'no module source hosts are allowed — set one in Admin → Modules before installing', ) } if (!hosts.includes(url.hostname.toLowerCase())) { throw new InstallError( `"${url.hostname}" is not an allowed module source host (allowed: ${hosts.join(', ')})`, ) } return url } // ── Fetching ─────────────────────────────────────────────────────────────── /** * GET a URL, following redirects by hand so every hop is re-checked. * * `fetch`'s own redirect following would take the first hop off the allowlist * and the rest wherever it was pointed, which is exactly the hole the allowlist * exists to close. * * `fetchImpl` is the same injection seam `replayFragments({query})` and * `lifecycle.boot({model})` use, and it exists for the same reason: the rules * this file enforces — https only, allowlisted host, allowlisted REDIRECT host, * bounded body, matching hash — are all decisions about a response, and testing * them against a real TLS server would mean testing Node's certificate handling * instead. Production never passes it. * * @returns {Promise} a response whose body has not been read. */ async function get(rawUrl, hosts, fetchImpl = fetch) { let url = checkUrl(rawUrl, hosts) for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) { let res try { res = await fetchImpl(url, { redirect: 'manual', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: '*/*' }, }) } catch (err) { throw new InstallError(`could not reach ${url.hostname}: ${err.message}`, { status: 502 }) } if (res.status >= 300 && res.status < 400) { const location = res.headers.get('location') if (!location) throw new InstallError(`${url.hostname} redirected without a location`, { status: 502 }) // Resolved against the current URL, then re-checked — a relative redirect // is normal and a cross-host one is the thing being guarded against. url = checkUrl(new URL(location, url).toString(), hosts) continue } if (!res.ok) { throw new InstallError(`${url.href} returned ${res.status}`, { status: 502 }) } return res } throw new InstallError(`too many redirects (more than ${MAX_REDIRECTS})`, { status: 502 }) } /** Read a bounded response body as text. */ async function readText(res, cap, what) { const declared = Number(res.headers.get('content-length')) if (Number.isFinite(declared) && declared > cap) { throw new InstallError(`${what} is larger than ${cap} bytes`) } const buf = Buffer.from(await res.arrayBuffer()) // Checked again after reading: content-length is the server's claim, not a // limit it is obliged to honour. if (buf.length > cap) throw new InstallError(`${what} is larger than ${cap} bytes`) return buf.toString('utf8') } /** * Stream a response body to a file, hashing as it goes and stopping at the cap. * * The hash is computed from the bytes that were written rather than by re-reading * the file, so there is no window in which the file could differ from what was * verified. */ async function download(res, dest, cap) { const hash = crypto.createHash('sha256') let bytes = 0 const body = Readable.fromWeb(res.body) const counter = new Transform({ transform(chunk, _enc, cb) { bytes += chunk.length if (bytes > cap) { cb(new InstallError(`the artifact is larger than ${Math.round(cap / 1024 / 1024)} MB`)) return } hash.update(chunk) cb(null, chunk) }, }) await pipeline(body, counter, fs.createWriteStream(dest)) return { sha256: hash.digest('hex'), bytes } } // ── The install manifest ─────────────────────────────────────────────────── /** * Fetch and validate a release's install manifest. * * The shape is the one module-uo's release workflow already writes: * `{schema, id, name, version, coreApi, artifact, url, sha256, size}`. Only the * fields this side needs are required — a module publisher may carry more. */ async function fetchManifest(manifestUrl, hosts, fetchImpl = fetch) { const res = await get(manifestUrl, hosts, fetchImpl) const text = await readText(res, MAX_MANIFEST_BYTES, 'the install manifest') let manifest try { manifest = JSON.parse(text) } catch (err) { throw new InstallError(`the install manifest is not valid JSON: ${err.message}`) } if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { throw new InstallError('the install manifest is not a JSON object') } const { id, name, version, sha256 } = manifest if (!ID.test(String(id || ''))) { throw new InstallError(`the install manifest has an invalid module id: ${JSON.stringify(id)}`) } if (!name || !version) throw new InstallError('the install manifest is missing name or version') if (!SHA256.test(String(sha256 || '').toLowerCase())) { throw new InstallError('the install manifest has no valid sha256 for its artifact') } // `url` is the absolute artifact URL the publisher wrote; `artifact` is its // filename. Prefer the URL, fall back to resolving the filename beside the // manifest — which is where a release's assets sit — so a manifest that // travelled without its absolute URL still installs. const artifactUrl = new URL(manifest.url || manifest.artifact || '', manifestUrl).toString() return { id: String(id), name: String(name), version: String(version), sha256: String(sha256).toLowerCase(), size: Number(manifest.size) || null, artifactUrl, coreApi: manifest.coreApi ? String(manifest.coreApi) : null, } } // ── Paths on the volume ──────────────────────────────────────────────────── /** Where a module with this id lives. Rejects an id that is not one. */ function moduleDir(id) { if (!ID.test(String(id || ''))) throw new InstallError(`invalid module id: ${JSON.stringify(id)}`) return path.join(loader.dir(), String(id)) } /** Is there a directory for this module on the volume right now? */ function isInstalled(id) { try { return fs.statSync(moduleDir(id)).isDirectory() } catch { return false } } /** * The absolute path of a module's `purge.sql`, or null. * * Read from the module's own `module.json` on disk rather than from the loader, * because purge has to work for a module that never loaded — a `startup_failed` * one is exactly when an operator wants its tables gone. */ function purgeFile(id) { try { const manifest = JSON.parse(fs.readFileSync(path.join(moduleDir(id), 'module.json'), 'utf8')) if (!manifest.purge) return null const file = path.resolve(moduleDir(id), manifest.purge) // The same containment check the loader applies to `client.entry`: a // manifest may not point core at a file outside the module. if (!file.startsWith(moduleDir(id) + path.sep)) return null return fs.existsSync(file) ? file : null } catch { return null } } // ── Install ──────────────────────────────────────────────────────────────── /** * Download, verify, unpack and install a module from an install-manifest URL. * * Never leaves a partial module on the volume: everything happens under a * scratch directory that is removed on any failure, and the move into place is * the last step. * * `expect` is what the CALLER was promised, as opposed to what the manifest * promises about itself — the declared module set (modules/declared.js) pins an * id and a version in the environment, and a URL that resolves to something else * has to be refused rather than installed. Checked against the manifest, before * a byte is downloaded: catching it after the unpack would mean the undeclared * module is already on the volume when the objection is raised. The admin panel * passes nothing, because there a URL is the whole of what was asked for. * * @param {object} args * @param {string} args.url the install manifest URL the admin pasted * @param {string[]} args.hosts the allowlist, already parsed * @param {{id?: string, version?: string}} [args.expect] what the caller pinned * @returns {Promise<{id,name,version,sha256,source,bytes,replaced}>} */ async function install({ url, hosts, expect = null, fetchImpl = fetch }) { const manifest = await fetchManifest(url, hosts, fetchImpl) if (expect && expect.id && manifest.id !== expect.id) { throw new InstallError( `that URL installs the module "${manifest.id}", but "${expect.id}" was asked for`, ) } if (expect && expect.version && manifest.version !== expect.version) { throw new InstallError( `that URL installs ${manifest.id} v${manifest.version}, but v${expect.version} was asked for`, ) } const target = moduleDir(manifest.id) const scratch = await fsp.mkdtemp(path.join(loader.dir(), `.install-${manifest.id}-`)) const tarball = path.join(scratch, 'bundle.tar.gz') const unpacked = path.join(scratch, 'unpacked') try { const res = await get(manifest.artifactUrl, hosts, fetchImpl) const { sha256, bytes } = await download(res, tarball, MAX_ARTIFACT_BYTES) if (sha256 !== manifest.sha256) { // The whole trust model in one comparison. Deliberately does not name the // computed hash in a way that reads like a value to copy into the // manifest — a mismatch means stop, not reconcile. throw new InstallError( `the downloaded artifact does not match the sha256 in the install manifest — refusing to install`, ) } if (manifest.size && bytes !== manifest.size) { throw new InstallError(`the artifact is ${bytes} bytes but the manifest declares ${manifest.size}`) } await archive.unpack(tarball, unpacked) // What the bundle says it is, checked against what the manifest promised. let inner try { inner = JSON.parse(await fsp.readFile(path.join(unpacked, 'module.json'), 'utf8')) } catch { throw new InstallError('the bundle has no readable module.json at its root') } if (inner.id !== manifest.id) { throw new InstallError( `the bundle declares module id "${inner.id}" but the install manifest promised "${manifest.id}"`, ) } if (inner.version !== manifest.version) { throw new InstallError( `the bundle declares version "${inner.version}" but the install manifest promised "${manifest.version}"`, ) } // The swap. The outgoing directory is moved aside rather than deleted first, // so a failed rename leaves the previous version recoverable instead of // leaving no module at all — the same reasoning the installer repo applies // to a ServUO tree. const replaced = fs.existsSync(target) const aside = `${target}.replaced-${Date.now()}` if (replaced) await fsp.rename(target, aside) try { await fsp.rename(unpacked, target) } catch (err) { if (replaced) await fsp.rename(aside, target).catch(() => {}) throw err } if (replaced) await fsp.rm(aside, { recursive: true, force: true }) log.info(`installed module "${manifest.id}" v${manifest.version}`, { source: url, sha256: manifest.sha256, bytes, replaced, }) return { ...manifest, source: url, bytes, replaced } } finally { await fsp.rm(scratch, { recursive: true, force: true }).catch(() => {}) } } /** * Remove a module's directory from the volume. * * Returns whether there was one. Does not touch the database, does not run * purge.sql, and does not stop the running module — the controller sequences * all three, because the order matters and only it knows what the operator * asked for. */ async function removeDir(id) { const dir = moduleDir(id) if (!fs.existsSync(dir)) return false await fsp.rm(dir, { recursive: true, force: true }) log.info(`removed module directory for "${id}"`) return true } module.exports = { InstallError, HOSTS_SETTING, parseHosts, checkUrl, get, fetchManifest, install, removeDir, moduleDir, isInstalled, purgeFile, MAX_MANIFEST_BYTES, MAX_ARTIFACT_BYTES, }