Files
workinf_Blender_Wasm/tools/web/check-malicious-archive-fixtures.mjs
mes123456 380cbed4ff
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Checkpoint web parity through Chromium input tasks
2026-08-19 10:39:03 -04:00

183 lines
9.9 KiB
JavaScript

import assert from "node:assert/strict";
import crypto from "node:crypto";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL, fileURLToPath } from "node:url";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const fixtureRoot = path.join(repoRoot, "tests/files/web/archive-security");
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "malicious-archive-fixtures-"));
const moduleRoot = path.join(temporary, "modules");
const regeneratedRoot = path.join(temporary, "regenerated");
fs.mkdirSync(moduleRoot, { recursive: true });
try {
for (const sourceName of ["archive-link-safety.ts", "archive-conflicts.ts"]) {
const sourcePath = path.join(repoRoot, "web/protocol", sourceName);
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
fs.writeFileSync(path.join(moduleRoot, sourceName.replace(".ts", ".mjs")), transpiled.outputText);
}
const safety = await import(pathToFileURL(path.join(moduleRoot, "archive-link-safety.mjs")));
const conflicts = await import(pathToFileURL(path.join(moduleRoot, "archive-conflicts.mjs")));
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
const decoder = new TextDecoder("utf-8", { fatal: true });
const evidence = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-04J/manifest.json"), "utf8"));
assert.equal(evidence.task, "M12-04J");
assert.equal(evidence.parentTask, "M12-04I");
assert.equal(evidence.nextTask, "M12-05A");
for (const artifact of Object.values(evidence.artifacts)) {
assert.equal(sha256(fs.readFileSync(path.join(repoRoot, artifact.path))), artifact.sha256, artifact.path);
}
function decode(bytes, offset, length) {
return decoder.decode(bytes.subarray(offset, offset + length));
}
function zipMetadata(bytes) {
assert.ok(bytes.length >= 22, "ZIP fixture is shorter than EOCD");
const endOffset = bytes.length - 22;
assert.equal(bytes.readUInt32LE(endOffset), 0x06054b50, "ZIP fixture EOCD is missing");
assert.equal(bytes.readUInt16LE(endOffset + 4), 0, "multi-disk ZIP fixture is forbidden");
assert.equal(bytes.readUInt16LE(endOffset + 6), 0, "multi-disk ZIP fixture is forbidden");
const entries = bytes.readUInt16LE(endOffset + 10);
assert.equal(bytes.readUInt16LE(endOffset + 8), entries, "ZIP entry counts disagree");
const centralBytes = bytes.readUInt32LE(endOffset + 12);
const centralOffset = bytes.readUInt32LE(endOffset + 16);
assert.equal(centralOffset + centralBytes, endOffset, "ZIP central directory is not the first bounded metadata region");
const links = [];
const ranges = [];
let offset = centralOffset;
for (let index = 0; index < entries; index++) {
assert.equal(bytes.readUInt32LE(offset), 0x02014b50, `ZIP central entry ${index} is invalid`);
const method = bytes.readUInt16LE(offset + 10);
const compressedBytes = bytes.readUInt32LE(offset + 20);
const uncompressedBytes = bytes.readUInt32LE(offset + 24);
const nameBytes = bytes.readUInt16LE(offset + 28);
const extraBytes = bytes.readUInt16LE(offset + 30);
const commentBytes = bytes.readUInt16LE(offset + 32);
const localOffset = bytes.readUInt32LE(offset + 42);
const entryPath = decode(bytes, offset + 46, nameBytes);
assert.equal(method, 0, "fixture ZIP entries must use deterministic STORE metadata");
assert.equal(bytes.readUInt32LE(localOffset), 0x04034b50, `ZIP local entry ${index} is invalid`);
assert.equal(bytes.readUInt16LE(localOffset + 8), method, "ZIP local/central methods disagree");
assert.equal(bytes.readUInt32LE(localOffset + 18), compressedBytes, "ZIP local/central compressed sizes disagree");
assert.equal(bytes.readUInt32LE(localOffset + 22), uncompressedBytes, "ZIP local/central uncompressed sizes disagree");
const localNameBytes = bytes.readUInt16LE(localOffset + 26);
const localExtraBytes = bytes.readUInt16LE(localOffset + 28);
assert.equal(decode(bytes, localOffset + 30, localNameBytes), entryPath, "ZIP local/central names disagree");
const compressedOffset = localOffset + 30 + localNameBytes + localExtraBytes;
assert.ok(compressedOffset + compressedBytes <= centralOffset, "ZIP payload range overlaps central metadata");
links.push({ path: entryPath, type: entryPath.endsWith("/") ? "DIRECTORY" : "FILE", target: null });
ranges.push({ path: entryPath, compressedOffset, compressedBytes, uncompressedBytes });
offset += 46 + nameBytes + extraBytes + commentBytes;
}
assert.equal(offset, endOffset, "ZIP central directory length disagrees with EOCD");
return { links, ranges };
}
function tarString(bytes, offset, length) {
const field = bytes.subarray(offset, offset + length);
const end = field.indexOf(0);
return decoder.decode(end === -1 ? field : field.subarray(0, end));
}
function tarOctal(bytes, offset, length) {
const value = tarString(bytes, offset, length).trim();
assert.match(value, /^[0-7]+$/, "TAR numeric field is not octal");
return Number.parseInt(value, 8);
}
function tarMetadata(bytes) {
assert.equal(bytes.length % 512, 0, "TAR fixture is not block aligned");
const links = [];
const ranges = [];
let offset = 0;
let zeroBlocks = 0;
while (offset < bytes.length) {
const header = bytes.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) {
zeroBlocks++;
offset += 512;
if (zeroBlocks === 2) break;
continue;
}
assert.equal(zeroBlocks, 0, "TAR has data after an end marker");
assert.equal(tarString(header, 257, 6), "ustar", "TAR fixture is not USTAR");
const expectedChecksum = tarOctal(header, 148, 8);
const checksumHeader = Buffer.from(header);
checksumHeader.fill(0x20, 148, 156);
assert.equal(checksumHeader.reduce((sum, byte) => sum + byte, 0), expectedChecksum, "TAR header checksum mismatch");
const prefix = tarString(header, 345, 155);
const name = tarString(header, 0, 100);
const entryPath = prefix ? `${prefix}/${name}` : name;
const uncompressedBytes = tarOctal(header, 124, 12);
const typeFlag = String.fromCharCode(header[156] || 0x30);
const type = { "0": "FILE", "1": "HARDLINK", "2": "SYMLINK", "5": "DIRECTORY" }[typeFlag];
assert.ok(type, `TAR entry type ${typeFlag} is unsupported by the fixture gate`);
const target = type === "SYMLINK" || type === "HARDLINK" ? tarString(header, 157, 100) : null;
const compressedOffset = offset + 512;
assert.ok(compressedOffset + uncompressedBytes <= bytes.length, "TAR payload exceeds the fixture");
links.push({ path: entryPath, type, target });
ranges.push({ path: entryPath, compressedOffset, compressedBytes: uncompressedBytes, uncompressedBytes });
offset = compressedOffset + Math.ceil(uncompressedBytes / 512) * 512;
}
assert.equal(zeroBlocks, 2, "TAR fixture has no two-block end marker");
assert.equal(offset, bytes.length, "TAR fixture has trailing data after the end marker");
return { links, ranges };
}
const manifestBytes = fs.readFileSync(path.join(fixtureRoot, "manifest.json"));
const manifest = JSON.parse(manifestBytes);
assert.equal(manifest.schemaVersion, 1);
assert.equal(manifest.task, "M12-04J");
assert.equal(manifest.generator, "tools/web/generate-malicious-archive-fixtures.mjs");
assert.equal(manifest.extractionAllowed, false);
const expected = [
["ZIP_PATH_TRAVERSAL", "ZIP", "ARCHIVE_ROOT_ESCAPE", "LINK_SAFETY"],
["ZIP_COMPRESSION_BOMB", "ZIP", "COMPRESSION_RATIO", "CONFLICTS"],
["ZIP_DUPLICATE_PATH", "ZIP", "DUPLICATE_PATH", "LINK_SAFETY"],
["TAR_PATH_TRAVERSAL", "TAR", "ARCHIVE_ROOT_ESCAPE", "LINK_SAFETY"],
["TAR_SYMLINK_ESCAPE", "TAR", "SYMLINK_ESCAPE", "LINK_SAFETY"],
["TAR_PREFIX_CONFLICT", "TAR", "FILE_DIRECTORY_PREFIX_CONFLICT", "CONFLICTS"],
];
assert.deepEqual(manifest.cases.map((item) => [item.id, item.format, item.threat, item.gate]), expected);
execFileSync(process.execPath, [path.join(repoRoot, manifest.generator), "--output", regeneratedRoot], { cwd: repoRoot });
assert.deepEqual(fs.readFileSync(path.join(regeneratedRoot, "manifest.json")), manifestBytes, "fixture manifest is not deterministic");
for (const fixture of manifest.cases) {
assert.equal(fixture.file, path.basename(fixture.file), `${fixture.id} fixture path escapes its root`);
assert.equal(fixture.expectedCode, "IO_ARCHIVE_UNSAFE");
const archiveBytes = fs.readFileSync(path.join(fixtureRoot, fixture.file));
assert.equal(archiveBytes.length, fixture.byteLength, `${fixture.id} byte length drifted`);
assert.equal(sha256(archiveBytes), fixture.sha256, `${fixture.id} SHA-256 drifted`);
assert.deepEqual(fs.readFileSync(path.join(regeneratedRoot, fixture.file)), archiveBytes, `${fixture.id} is not deterministic`);
const metadata = fixture.format === "ZIP" ? zipMetadata(archiveBytes) : tarMetadata(archiveBytes);
let failure;
try {
if (fixture.gate === "LINK_SAFETY") {
safety.resolveArchiveLinkEntries({ schemaVersion: 1, temporaryRootId: `fixture:${fixture.id}`, entries: metadata.links });
}
else {
conflicts.validateArchiveConflicts({ schemaVersion: 1, byteLength: archiveBytes.length, ranges: metadata.ranges });
}
}
catch (error) {
failure = error;
}
assert.equal(failure?.code, fixture.expectedCode, `${fixture.id} did not fail closed`);
}
process.stdout.write(`malicious-archive-fixtures-ok cases=${manifest.cases.length} zip=3 tar=3 extraction=disabled\n`);
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}