// ── The hardened bundle extractor ────────────────────────────────────────── // // Phase 4, slice 1 of docs/website/MODULE_SYSTEM.md §2.7.2. This is the part of // the install path that handles input an attacker chose, and it is separated // from install.js so that it can be tested against crafted archives without a // network, a database or a filesystem layout. // // **The download is not the dangerous part.** An allowlisted host, a declared // sha256 and a size cap between them settle where the bytes came from and that // they are the bytes that were published. Unpacking is different: the ARCHIVE // chooses the filenames, and core writes into a directory bind-mounted from the // host (MODULE_SYSTEM.md §2.5), so an escape is not confined to the container. // // The rule this file follows is **reject, never sanitise.** node-tar will // happily strip a leading `/` and drop a `..` for you, which turns a hostile // archive into a slightly different archive that then gets installed. An archive // that needs correcting is an archive that should not be trusted, so anything on // the list below fails the whole install and nothing is written. // // - an absolute path, POSIX (`/etc/…`) or Windows (`C:\…`, `\\server\…`) // - any `..` segment, anywhere // - anything that is not a regular file or a directory — so no symlinks, no // hardlinks, no devices, no FIFOs. A module bundle has no legitimate use for // any of them, and every one is a documented escape primitive // - more than one top-level entry // - more entries, or more unpacked bytes, than the caps below // // That third rule deserves its own note, because it is why the `tar` dependency // is pinned forward rather than merely present: **the majority of node-tar's // published advisories are hardlink or symlink path traversal**, several of them // through interpretation differences in PAX and GNU long-name headers rather // than through anything the calling code did wrong. Refusing those entry types // outright means this extractor is not relying on the library to get their // containment right. The remaining advisories are parser denial-of-service, and // those are what the caps and the two-pass shape address. // // Two passes, deliberately: `inspect()` reads the archive and decides, and only // an archive that survived that is handed to `extract()`. A `filter` callback // during extraction cannot reject — by the time it is asked about entry 400, the // first 399 are already on disk. const fs = require('fs') const fsp = require('fs/promises') const path = require('path') const tar = require('tar') // Caps. Generous against a real bundle (module-uo's is a few megabytes, a few // hundred files) and small enough that a decompression bomb is refused rather // than paged in. Both are checked DURING the inspect pass, not after it, so a // hostile archive stops being read at the limit instead of at its end. const MAX_BYTES = 128 * 1024 * 1024 const MAX_ENTRIES = 20000 // tar entry types worth naming. node-tar reports these as strings; anything not // in this set is refused, including the ones no one has thought of, which is the // point of an allowlist here rather than a list of the types known to be bad. const ALLOWED_TYPES = new Set(['File', 'Directory']) class ArchiveError extends Error { constructor(message) { super(message) this.name = 'ArchiveError' } } /** * Is this entry path safe to unpack anywhere? * * Returns a reason string, or null when the path is fine. Written as a reason * rather than a boolean because the operator pasting a URL needs to be told what * was wrong with what they were served, and "the archive is invalid" is not that. */ function pathProblem(entryPath) { const p = String(entryPath) // NUL is not a path character. node-tar has had uncaught-exception advisories // for NUL bytes inside PAX records, so this is checked here rather than left // to the parser. if (p.includes('\0')) return 'an entry path contains a NUL byte' // A backslash is a path separator on the platform this may be unpacked on, so // an entry that contains one is not the single path component it looks like. if (p.includes('\\')) return `an entry path contains a backslash: "${p}"` if (p.startsWith('/')) return `an entry path is absolute: "${p}"` // Drive-relative and UNC. Both are the subject of their own node-tar // advisories, and neither is refused by a leading-slash check. if (/^[a-zA-Z]:/.test(p)) return `an entry path is drive-absolute: "${p}"` const segments = p.split('/') if (segments.includes('..')) return `an entry path escapes upward: "${p}"` return null } /** * Read an archive without unpacking it, and decide whether it may be unpacked. * * @param {string} file absolute path to the .tar.gz * @returns {Promise<{root: string, entries: number, bytes: number}>} the single * top-level directory name, and what is inside it. * @throws {ArchiveError} on anything in this file's header list. */ async function inspect(file) { const roots = new Set() let entries = 0 let bytes = 0 // The first problem found, kept rather than thrown from inside the callback: // throwing out of `onentry` escapes through the parser's stream machinery and // arrives as an unhelpful wrapped error, when it arrives at all. let problem = null const note = (message) => { if (!problem) problem = message } await tar.t({ file, onentry(entry) { if (problem) return entries += 1 if (entries > MAX_ENTRIES) { note(`the archive has more than ${MAX_ENTRIES} entries`) return } if (!ALLOWED_TYPES.has(entry.type)) { // The message names the type because "symbolic link" is a much more // useful thing for an operator to read than "invalid entry". note(`the archive contains a ${entry.type} ("${entry.path}") — a module bundle may only contain files and directories`) return } const bad = pathProblem(entry.path) if (bad) { note(bad) return } // A negative or absurd size is a parser-confusion primitive in its own // right; clamping at zero keeps the running total honest. bytes += Math.max(0, Number(entry.size) || 0) if (bytes > MAX_BYTES) { note(`the archive unpacks to more than ${Math.round(MAX_BYTES / 1024 / 1024)} MB`) return } const [root] = String(entry.path).split('/') if (root) roots.add(root) }, }) if (problem) throw new ArchiveError(problem) if (entries === 0) throw new ArchiveError('the archive is empty') if (roots.size !== 1) { // A bundle is one directory. More than one top-level entry means `strip: 1` // below would silently merge or discard things, and a bundle that needs // interpreting is not a bundle. throw new ArchiveError( `the archive must contain exactly one top-level directory, found ${roots.size}` + (roots.size > 1 ? ` (${[...roots].slice(0, 4).join(', ')})` : ''), ) } return { root: [...roots][0], entries, bytes } } /** * Unpack an inspected archive into `dest`, stripping its single top level. * * The top-level directory is stripped rather than kept, because its name belongs * to whoever published the bundle — module-uo's release workflow packs * `module-uo-/`, not `uo/` — while the directory it lands in is core's * decision and has to be the module id the loader scans for. What is inside the * bundle is the module; what the wrapper is called is packaging. * * `dest` must not exist. Callers unpack to a temporary directory and move it * into place, so a failure halfway through leaves nothing for the next boot's * scan to find. */ async function extract(file, dest) { if (fs.existsSync(dest)) throw new ArchiveError(`extraction target already exists: ${dest}`) await fsp.mkdir(dest, { recursive: true }) await tar.x({ file, cwd: dest, strip: 1, // Belt and braces on top of inspect(): the library's own refusal to write // outside cwd, and its refusal to overwrite through a link. Neither is // load-bearing here — inspect() has already rejected every entry that could // exercise them — but a second lock on a door that is already locked costs // nothing, and this is the door. preservePaths: false, // Reject rather than warn on anything the parser itself objects to. strict: true, }) return dest } /** * Inspect, then extract. The only entry point install.js uses. */ async function unpack(file, dest) { const stats = await inspect(file) await extract(file, dest) return stats } module.exports = { ArchiveError, inspect, extract, unpack, pathProblem, MAX_BYTES, MAX_ENTRIES, ALLOWED_TYPES, }