Checkpoint web parity through Chromium input tasks
This commit is contained in:
188
tools/web/generate-malicious-archive-fixtures.mjs
Normal file
188
tools/web/generate-malicious-archive-fixtures.mjs
Normal file
@@ -0,0 +1,188 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const outputArgument = process.argv.indexOf("--output");
|
||||
const outputRoot = outputArgument === -1
|
||||
? path.join(repoRoot, "tests/files/web/archive-security")
|
||||
: path.resolve(process.argv[outputArgument + 1] ?? "");
|
||||
if (!outputRoot) throw new Error("--output requires a directory");
|
||||
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const text = (value) => Buffer.from(value, "utf8");
|
||||
|
||||
const crcTable = Array.from({ length: 256 }, (_, input) => {
|
||||
let value = input;
|
||||
for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ value >>> 1 : value >>> 1;
|
||||
return value >>> 0;
|
||||
});
|
||||
|
||||
function crc32(bytes) {
|
||||
let value = 0xffffffff;
|
||||
for (const byte of bytes) value = crcTable[(value ^ byte) & 0xff] ^ value >>> 8;
|
||||
return (value ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function zipArchive(entries) {
|
||||
const localParts = [];
|
||||
const centralParts = [];
|
||||
let localOffset = 0;
|
||||
for (const entry of entries) {
|
||||
const name = text(entry.path);
|
||||
const data = Buffer.from(entry.data);
|
||||
const uncompressedBytes = entry.uncompressedBytes ?? data.length;
|
||||
const crc = crc32(data);
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0x0800, 6);
|
||||
local.writeUInt16LE(0, 8);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(data.length, 18);
|
||||
local.writeUInt32LE(uncompressedBytes, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
localParts.push(local, name, data);
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(0x0314, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(0x0800, 8);
|
||||
central.writeUInt16LE(0, 10);
|
||||
central.writeUInt32LE(crc, 16);
|
||||
central.writeUInt32LE(data.length, 20);
|
||||
central.writeUInt32LE(uncompressedBytes, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt32LE((0o100644 << 16) >>> 0, 38);
|
||||
central.writeUInt32LE(localOffset, 42);
|
||||
centralParts.push(central, name);
|
||||
localOffset += local.length + name.length + data.length;
|
||||
}
|
||||
const centralDirectory = Buffer.concat(centralParts);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(entries.length, 8);
|
||||
end.writeUInt16LE(entries.length, 10);
|
||||
end.writeUInt32LE(centralDirectory.length, 12);
|
||||
end.writeUInt32LE(localOffset, 16);
|
||||
return Buffer.concat([...localParts, centralDirectory, end]);
|
||||
}
|
||||
|
||||
function writeTarText(header, value, offset, length) {
|
||||
const bytes = text(value);
|
||||
if (bytes.length > length) throw new Error(`tar field exceeds ${length} bytes`);
|
||||
bytes.copy(header, offset);
|
||||
}
|
||||
|
||||
function writeTarOctal(header, value, offset, length) {
|
||||
const encoded = value.toString(8).padStart(length - 2, "0");
|
||||
writeTarText(header, `${encoded}\0 `, offset, length);
|
||||
}
|
||||
|
||||
function tarHeader(entry) {
|
||||
const header = Buffer.alloc(512);
|
||||
writeTarText(header, entry.path, 0, 100);
|
||||
writeTarOctal(header, entry.type === "DIRECTORY" ? 0o755 : 0o644, 100, 8);
|
||||
writeTarOctal(header, 0, 108, 8);
|
||||
writeTarOctal(header, 0, 116, 8);
|
||||
const data = entry.type === "FILE" ? Buffer.from(entry.data) : Buffer.alloc(0);
|
||||
writeTarOctal(header, data.length, 124, 12);
|
||||
writeTarOctal(header, 0, 136, 12);
|
||||
header.fill(0x20, 148, 156);
|
||||
header[156] = { FILE: 0x30, SYMLINK: 0x32, HARDLINK: 0x31, DIRECTORY: 0x35 }[entry.type];
|
||||
if (entry.target) writeTarText(header, entry.target, 157, 100);
|
||||
writeTarText(header, "ustar\0", 257, 6);
|
||||
writeTarText(header, "00", 263, 2);
|
||||
writeTarText(header, "root", 265, 32);
|
||||
writeTarText(header, "root", 297, 32);
|
||||
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
||||
writeTarOctal(header, checksum, 148, 8);
|
||||
return { header, data };
|
||||
}
|
||||
|
||||
function tarArchive(entries) {
|
||||
const parts = [];
|
||||
for (const entry of entries) {
|
||||
const { header, data } = tarHeader(entry);
|
||||
parts.push(header, data);
|
||||
if (data.length % 512 !== 0) parts.push(Buffer.alloc(512 - data.length % 512));
|
||||
}
|
||||
parts.push(Buffer.alloc(1024));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
const definitions = [
|
||||
{
|
||||
id: "ZIP_PATH_TRAVERSAL",
|
||||
format: "ZIP",
|
||||
file: "zip-path-traversal.zip",
|
||||
threat: "ARCHIVE_ROOT_ESCAPE",
|
||||
gate: "LINK_SAFETY",
|
||||
bytes: zipArchive([{ path: "../outside.txt", data: text("escape") }]),
|
||||
},
|
||||
{
|
||||
id: "ZIP_COMPRESSION_BOMB",
|
||||
format: "ZIP",
|
||||
file: "zip-compression-bomb.zip",
|
||||
threat: "COMPRESSION_RATIO",
|
||||
gate: "CONFLICTS",
|
||||
bytes: zipArchive([{ path: "bomb.bin", data: Buffer.from([0]), uncompressedBytes: 101 }]),
|
||||
},
|
||||
{
|
||||
id: "ZIP_DUPLICATE_PATH",
|
||||
format: "ZIP",
|
||||
file: "zip-duplicate-path.zip",
|
||||
threat: "DUPLICATE_PATH",
|
||||
gate: "LINK_SAFETY",
|
||||
bytes: zipArchive([{ path: "same.bin", data: text("one") }, { path: "same.bin", data: text("two") }]),
|
||||
},
|
||||
{
|
||||
id: "TAR_PATH_TRAVERSAL",
|
||||
format: "TAR",
|
||||
file: "tar-path-traversal.tar",
|
||||
threat: "ARCHIVE_ROOT_ESCAPE",
|
||||
gate: "LINK_SAFETY",
|
||||
bytes: tarArchive([{ path: "../../outside.txt", type: "FILE", data: text("escape") }]),
|
||||
},
|
||||
{
|
||||
id: "TAR_SYMLINK_ESCAPE",
|
||||
format: "TAR",
|
||||
file: "tar-symlink-escape.tar",
|
||||
threat: "SYMLINK_ESCAPE",
|
||||
gate: "LINK_SAFETY",
|
||||
bytes: tarArchive([
|
||||
{ path: "safe", type: "DIRECTORY" },
|
||||
{ path: "safe/link", type: "SYMLINK", target: "../../outside" },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: "TAR_PREFIX_CONFLICT",
|
||||
format: "TAR",
|
||||
file: "tar-prefix-conflict.tar",
|
||||
threat: "FILE_DIRECTORY_PREFIX_CONFLICT",
|
||||
gate: "CONFLICTS",
|
||||
bytes: tarArchive([
|
||||
{ path: "folder", type: "FILE", data: text("one") },
|
||||
{ path: "folder/payload.bin", type: "FILE", data: text("two") },
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
fs.mkdirSync(outputRoot, { recursive: true });
|
||||
for (const definition of definitions) fs.writeFileSync(path.join(outputRoot, definition.file), definition.bytes);
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
task: "M12-04J",
|
||||
generator: "tools/web/generate-malicious-archive-fixtures.mjs",
|
||||
extractionAllowed: false,
|
||||
cases: definitions.map(({ bytes, ...definition }) => ({
|
||||
...definition,
|
||||
byteLength: bytes.length,
|
||||
sha256: sha256(bytes),
|
||||
expectedCode: "IO_ARCHIVE_UNSAFE",
|
||||
})),
|
||||
};
|
||||
fs.writeFileSync(path.join(outputRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
process.stdout.write(`malicious-archive-fixtures-generated cases=${definitions.length} output=${outputRoot}\n`);
|
||||
Reference in New Issue
Block a user