// modules/archive.js — the hardened bundle extractor (MODULE_SYSTEM.md §2.7.2). // // Every rejection case is exercised against a REAL archive rather than a mocked // tar parser, because the thing being tested is what the parser reports for a // given sequence of bytes. A fake that returns `{type: 'SymbolicLink'}` proves // only that the `if` is spelled correctly. // // The hostile archives are written here as raw ustar headers instead of being // produced with `tar`, for two reasons that both bit during this slice: // // 1. `ln -s` needs a privilege Windows does not hand out by default, so a // symlink fixture built with the shell is a fixture that silently is not // one, and the test passes for the wrong reason on the machine most of this // work happens on. // 2. GNU tar will not emit `../escape` or `/etc/passwd` as a member name — it // strips them and tells you so. The archives worth defending against are // exactly the ones a cooperative archiver refuses to produce. // // A ustar header is 512 bytes of fixed-offset fields, so writing one is less // code than persuading a tool to misbehave. const test = require('node:test') const assert = require('node:assert/strict') const fs = require('fs') const os = require('os') const path = require('path') const zlib = require('zlib') const archive = require('../src/modules/archive') // ── A minimal ustar writer ───────────────────────────────────────────────── const BLOCK = 512 function octal(value, width) { // ustar numeric fields are NUL-terminated octal, right-aligned with zeros. return Number(value).toString(8).padStart(width - 1, '0') + '\0' } /** * One 512-byte header plus its padded data. * * @param {object} entry * @param {string} entry.name member path * @param {string} [entry.type] '0' file · '5' dir · '2' symlink · '1' hardlink * · '3' char dev · '6' FIFO * @param {string} [entry.linkname] target, for the link types * @param {string} [entry.body] file contents * @param {number} [entry.size] declared size — defaults to the body's, and * may be set independently to build a header * that lies about its payload */ function member({ name, type = '0', linkname = '', body = '', size = null }) { const header = Buffer.alloc(BLOCK, 0) const data = Buffer.from(body, 'utf8') const declared = size === null ? data.length : size header.write(name, 0, 100, 'utf8') header.write(octal(0o644, 8), 100, 8, 'ascii') // mode header.write(octal(0, 8), 108, 8, 'ascii') // uid header.write(octal(0, 8), 116, 8, 'ascii') // gid header.write(octal(declared, 12), 124, 12, 'ascii') header.write(octal(0, 12), 136, 12, 'ascii') // mtime header.write(' ', 148, 8, 'ascii') // checksum field is spaces while summing header.write(type, 156, 1, 'ascii') header.write(linkname, 157, 100, 'utf8') header.write('ustar\0', 257, 6, 'ascii') header.write('00', 263, 2, 'ascii') let sum = 0 for (const byte of header) sum += byte header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'ascii') const padding = Buffer.alloc((BLOCK - (data.length % BLOCK)) % BLOCK, 0) return Buffer.concat([header, data, padding]) } /** Gzip a set of members into a .tar.gz on disk, and return its path. */ function writeArchive(dir, filename, members) { const tarball = Buffer.concat([ ...members.map(member), Buffer.alloc(BLOCK * 2, 0), // two zero blocks end the archive ]) const file = path.join(dir, filename) fs.writeFileSync(file, zlib.gzipSync(tarball)) return file } /** A well-formed bundle: one top-level directory, ordinary files inside it. */ function goodMembers(root = 'module-uo-1.0.0') { return [ { name: `${root}/`, type: '5' }, { name: `${root}/module.json`, body: '{"id":"uo","name":"UO","version":"1.0.0"}' }, { name: `${root}/server/`, type: '5' }, { name: `${root}/server/index.js`, body: 'module.exports = () => {}\n' }, ] } // One scratch directory for the whole file, removed at the end. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-archive-')) test.after(() => fs.rmSync(tmp, { recursive: true, force: true })) /** Assert that inspecting `members` fails, and that the message says why. */ async function rejects(name, members, matcher) { const file = writeArchive(tmp, `${name}.tar.gz`, members) await assert.rejects( () => archive.inspect(file), (err) => { assert.equal(err.name, 'ArchiveError', `expected an ArchiveError, got ${err.name}: ${err.message}`) assert.match(err.message, matcher) return true }, ) } // ── What a good bundle does ──────────────────────────────────────────────── test('inspect accepts a well-formed bundle and reports its single root', async () => { const file = writeArchive(tmp, 'good.tar.gz', goodMembers()) const stats = await archive.inspect(file) assert.equal(stats.root, 'module-uo-1.0.0') assert.equal(stats.entries, 4) assert.ok(stats.bytes > 0) }) test('extract strips the top level, so the bundle lands as the module id', async () => { // The wrapper directory is the publisher's naming (module-uo's release // workflow packs `module-uo-/`); the directory it lands in is core's, // and has to be the id the loader scans for. const file = writeArchive(tmp, 'strip.tar.gz', goodMembers()) const dest = path.join(tmp, 'unpacked-uo') await archive.unpack(file, dest) assert.ok(fs.existsSync(path.join(dest, 'module.json')), 'module.json should be at the root of the destination') assert.ok(fs.existsSync(path.join(dest, 'server', 'index.js'))) assert.ok(!fs.existsSync(path.join(dest, 'module-uo-1.0.0')), 'the wrapper directory should not survive') }) test('extract refuses a destination that already exists', async () => { const file = writeArchive(tmp, 'exists.tar.gz', goodMembers()) const dest = path.join(tmp, 'already-there') fs.mkdirSync(dest) await assert.rejects(() => archive.extract(file, dest), /already exists/) }) // ── What a hostile bundle does ───────────────────────────────────────────── test('an absolute member path is refused', async () => { await rejects('absolute', [ { name: 'mod/', type: '5' }, { name: '/etc/cron.d/pwned', body: '* * * * * root sh\n' }, ], /absolute/) }) test('a drive-absolute member path is refused', async () => { // Its own node-tar advisory, and invisible to a leading-slash check. await rejects('drive', [ { name: 'mod/', type: '5' }, { name: 'C:\\Windows\\Temp\\pwned', body: 'x' }, ], /backslash|drive-absolute/) }) test('an upward-escaping member path is refused', async () => { await rejects('escape', [ { name: 'mod/', type: '5' }, { name: 'mod/../../../etc/passwd', body: 'root::0:0\n' }, ], /escapes upward/) }) test('a symlink member is refused, and the message names the type', async () => { await rejects('symlink', [ { name: 'mod/', type: '5' }, { name: 'mod/passwd', type: '2', linkname: '/etc/passwd' }, ], /SymbolicLink/) }) test('a hardlink member is refused', async () => { // The single most-published node-tar escape primitive. Refusing the type // outright is what keeps this file from depending on the library getting // hardlink containment right. await rejects('hardlink', [ { name: 'mod/', type: '5' }, { name: 'mod/shadow', type: '1', linkname: '../../../etc/shadow' }, ], /Link/) }) test('a device node is refused', async () => { await rejects('device', [ { name: 'mod/', type: '5' }, { name: 'mod/zero', type: '3', linkname: '' }, ], /may only contain files and directories/) }) test('a FIFO is refused', async () => { await rejects('fifo', [ { name: 'mod/', type: '5' }, { name: 'mod/pipe', type: '6' }, ], /may only contain files and directories/) }) test('two top-level directories are refused', async () => { await rejects('two-roots', [ { name: 'mod-a/', type: '5' }, { name: 'mod-a/module.json', body: '{}' }, { name: 'mod-b/', type: '5' }, { name: 'mod-b/module.json', body: '{}' }, ], /exactly one top-level directory, found 2/) }) test('a bundle whose declared sizes exceed the cap is refused before it is read to the end', async () => { // The header lies: it declares a gigabyte and carries nothing. That is the // decompression-bomb shape, and the point is that inspect() decides on the // DECLARED size without ever materialising the payload. await rejects('bomb', [ { name: 'mod/', type: '5' }, { name: 'mod/big', size: archive.MAX_BYTES + 1, body: '' }, ], /unpacks to more than/) }) test('an empty archive is refused', async () => { await rejects('empty', [], /empty/) }) // ── The path check on its own ────────────────────────────────────────────── // // pathProblem is exported so the cases that are awkward to express as archive // bytes can still be asserted directly. test('pathProblem accepts ordinary bundle paths', () => { for (const ok of ['mod/module.json', 'mod/server/router/x.js', 'mod/a.b-c_d/e.js']) { assert.equal(archive.pathProblem(ok), null, `${ok} should be accepted`) } }) test('pathProblem rejects the escape shapes', () => { assert.match(archive.pathProblem('/etc/passwd'), /absolute/) assert.match(archive.pathProblem('C:/Windows/x'), /drive-absolute/) assert.match(archive.pathProblem('mod/../../x'), /escapes upward/) assert.match(archive.pathProblem('..'), /escapes upward/) assert.match(archive.pathProblem('mod\\x'), /backslash/) assert.match(archive.pathProblem('mod/\0/x'), /NUL byte/) }) test('pathProblem does not reject a filename that merely contains two dots', () => { // `..` is a SEGMENT, not a substring — a file called `version..js` is fine, // and a check written with `includes('..')` would refuse it. assert.equal(archive.pathProblem('mod/version..js'), null) assert.equal(archive.pathProblem('mod/..hidden'), null) })