Files
OmniRoute/tests/unit/fix-tls-client-node-binary-security.test.ts

494 lines
18 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
existsSync,
lstatSync,
mkdtempSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { fixTlsClientNodeBinary } from "../../scripts/build/fixTlsClientNodeBinary.mjs";
function makeRoot() {
return mkdtempSync(join(tmpdir(), "fix-tls-client-node-binary-security-"));
}
test("strict mode rejects a symlinked node_modules ancestor before recovery writes outside root", async () => {
const rootDir = makeRoot();
const outsideDir = makeRoot();
try {
const bytes = "verified-native-must-stay-inside-root";
const asset = {
file: "tls-client-linux-ubuntu-amd64-1.15.1.so",
sha256: createHash("sha256").update(bytes).digest("hex"),
};
const outsideNodeModules = join(outsideDir, "node_modules");
const tlsClientDir = join(outsideNodeModules, "tls-client-node");
const rootBin = join(tlsClientDir, "bin");
const scriptsDir = join(tlsClientDir, "scripts");
const outsideBinary = join(rootBin, asset.file);
const marker = join(tlsClientDir, ".postinstall-ran");
mkdirSync(rootBin, { recursive: true });
mkdirSync(scriptsDir, { recursive: true });
symlinkSync(outsideNodeModules, join(rootDir, "node_modules"), "dir");
writeFileSync(
join(scriptsDir, "postinstall.js"),
`require("fs").writeFileSync(${JSON.stringify(marker)}, "ran");
require("fs").writeFileSync(${JSON.stringify(outsideBinary)}, ${JSON.stringify(bytes)});`
);
await assert.rejects(
fixTlsClientNodeBinary({
rootDir,
asset,
strict: true,
retryDelaysMs: [],
log() {},
}),
/unsafe.*(ancestor|outside)|symlink/i
);
assert.equal(existsSync(marker), false);
assert.equal(existsSync(outsideBinary), false);
} finally {
rmSync(rootDir, { recursive: true, force: true });
rmSync(outsideDir, { recursive: true, force: true });
}
});
test("strict mode revalidates root ancestors after fetch before writing recovered bytes", async () => {
const rootDir = makeRoot();
const outsideDir = makeRoot();
try {
const bytes = Buffer.from("verified-native-after-ancestor-race");
const asset = {
file: "tls-client-race-test.so",
sha256: createHash("sha256").update(bytes).digest("hex"),
};
const rootNodeModules = join(rootDir, "node_modules");
const originalNodeModules = join(rootDir, "node_modules-before-race");
const outsideNodeModules = join(outsideDir, "node_modules");
const outsideBin = join(outsideNodeModules, "tls-client-node", "bin");
const outsideBinary = join(outsideBin, asset.file);
mkdirSync(join(rootNodeModules, "tls-client-node", "bin"), { recursive: true });
mkdirSync(outsideBin, { recursive: true });
const targetArch = process.arch === "arm64" ? "x64" : "arm64";
await assert.rejects(
fixTlsClientNodeBinary({
rootDir,
asset,
platform: process.platform,
arches: [targetArch],
fetchImpl: async () => {
renameSync(rootNodeModules, originalNodeModules);
symlinkSync(outsideNodeModules, rootNodeModules, "dir");
return new Response(bytes, { status: 200 });
},
strict: true,
retryDelaysMs: [],
log() {},
}),
/unsafe.*(ancestor|outside)|symlink/i
);
assert.equal(existsSync(outsideBinary), false);
} finally {
rmSync(rootDir, { recursive: true, force: true });
rmSync(outsideDir, { recursive: true, force: true });
}
});
test("strict mode revalidates root ancestors before recovery can remove an external file", async () => {
const rootDir = makeRoot();
const outsideDir = makeRoot();
try {
const bytes = Buffer.from("verified-native-after-pre-recovery-check");
const asset = {
file: "tls-client-pre-recovery-race-test.so",
sha256: createHash("sha256").update(bytes).digest("hex"),
};
const rootNodeModules = join(rootDir, "node_modules");
const originalNodeModules = join(rootDir, "node_modules-before-pre-recovery-race");
const rootBin = join(rootNodeModules, "tls-client-node", "bin");
const outsideNodeModules = join(outsideDir, "node_modules");
const outsideBin = join(outsideNodeModules, "tls-client-node", "bin");
const outsideBinary = join(outsideBin, asset.file);
mkdirSync(rootBin, { recursive: true });
mkdirSync(outsideBin, { recursive: true });
writeFileSync(join(rootBin, asset.file), "tampered-root-binary");
writeFileSync(outsideBinary, "external-file-must-not-be-removed");
let swappedAncestor = false;
await assert.rejects(
fixTlsClientNodeBinary({
rootDir,
asset,
fetchImpl: async () => new Response(bytes, { status: 200 }),
strict: true,
retryDelaysMs: [],
log(message) {
if (!swappedAncestor && message.includes("missing or unverified")) {
renameSync(rootNodeModules, originalNodeModules);
symlinkSync(outsideNodeModules, rootNodeModules, "dir");
swappedAncestor = true;
}
},
}),
/unsafe.*(ancestor|outside)|symlink/i
);
assert.equal(swappedAncestor, true);
assert.equal(readFileSync(outsideBinary, "utf8"), "external-file-must-not-be-removed");
} finally {
rmSync(rootDir, { recursive: true, force: true });
rmSync(outsideDir, { recursive: true, force: true });
}
});
test("strict mode revalidates root ancestors after recovery before post-verification", async () => {
const rootDir = makeRoot();
const outsideDir = makeRoot();
try {
const bytes = Buffer.from("verified-native-before-post-recovery-race");
const asset = {
file: "tls-client-post-recovery-race-test.so",
sha256: createHash("sha256").update(bytes).digest("hex"),
};
const rootNodeModules = join(rootDir, "node_modules");
const originalNodeModules = join(rootDir, "node_modules-before-post-recovery-race");
const outsideNodeModules = join(outsideDir, "node_modules");
const outsideBin = join(outsideNodeModules, "tls-client-node", "bin");
mkdirSync(join(rootNodeModules, "tls-client-node", "bin"), { recursive: true });
mkdirSync(outsideBin, { recursive: true });
writeFileSync(join(outsideBin, asset.file), bytes);
const targetArch = process.arch === "arm64" ? "x64" : "arm64";
let swappedAncestor = false;
await assert.rejects(
fixTlsClientNodeBinary({
rootDir,
asset,
platform: process.platform,
arches: [targetArch],
fetchImpl: async () => new Response(bytes, { status: 200 }),
strict: true,
retryDelaysMs: [],
log(message) {
if (message.includes("fetched successfully")) {
renameSync(rootNodeModules, originalNodeModules);
symlinkSync(outsideNodeModules, rootNodeModules, "dir");
swappedAncestor = true;
}
},
}),
/unsafe.*(ancestor|outside)|symlink/i
);
assert.equal(swappedAncestor, true);
} finally {
rmSync(rootDir, { recursive: true, force: true });
rmSync(outsideDir, { recursive: true, force: true });
}
});
test("strict mode revalidates dist ancestors before copying verified bytes", async () => {
const rootDir = makeRoot();
const outsideDir = makeRoot();
try {
const bytes = Buffer.from("verified-native-before-dist-race");
const asset = {
file: "tls-client-dist-race-test.so",
sha256: createHash("sha256").update(bytes).digest("hex"),
};
const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin");
const rootDist = join(rootDir, "dist");
const originalDist = join(rootDir, "dist-before-race");
const outsideDist = join(outsideDir, "dist-target");
const outsideTlsClientDir = join(outsideDist, "node_modules", "tls-client-node");
const outsideBinary = join(outsideTlsClientDir, "bin", asset.file);
mkdirSync(rootBin, { recursive: true });
mkdirSync(join(rootDist, "node_modules", "tls-client-node"), { recursive: true });
mkdirSync(outsideTlsClientDir, { recursive: true });
writeFileSync(join(rootBin, asset.file), bytes);
let swappedAncestor = false;
await assert.rejects(
fixTlsClientNodeBinary({
rootDir,
asset,
strict: true,
retryDelaysMs: [],
afterSourceStat() {
if (!swappedAncestor) {
renameSync(rootDist, originalDist);
symlinkSync(outsideDist, rootDist, "dir");
swappedAncestor = true;
}
},
log() {},
}),
/unsafe.*(ancestor|outside)|symlink/i
);
assert.equal(swappedAncestor, true);
assert.equal(existsSync(outsideBinary), false);
} finally {
rmSync(rootDir, { recursive: true, force: true });
rmSync(outsideDir, { recursive: true, force: true });
}
});
test("strict mode rejects unmanifested native siblings in every packaged bin root", async () => {
const locations: Array<{
label: string;
binDir: (rootDir: string, standaloneDir: string) => string;
nextDistDir?: string | null;
}> = [
{
label: "root node_modules",
binDir: (rootDir: string, _standaloneDir: string) =>
join(rootDir, "node_modules", "tls-client-node", "bin"),
},
{
label: "dist node_modules",
binDir: (rootDir: string, _standaloneDir: string) =>
join(rootDir, "dist", "node_modules", "tls-client-node", "bin"),
},
{
label: "standalone node_modules",
binDir: (_rootDir: string, standaloneDir: string) =>
join(standaloneDir, "node_modules", "tls-client-node", "bin"),
},
{
label: "projects OmniRoute nested standalone node_modules",
binDir: (_rootDir: string, standaloneDir: string) =>
join(standaloneDir, "projects", "OmniRoute", "node_modules", "tls-client-node", "bin"),
},
{
label: "root-basename nested standalone node_modules",
binDir: (rootDir: string, standaloneDir: string) =>
join(standaloneDir, basename(rootDir), "node_modules", "tls-client-node", "bin"),
},
{
label: "standalone runtime assets",
binDir: (_rootDir: string, standaloneDir: string) =>
join(standaloneDir, "runtime-assets", "tls-client", "bin"),
},
{
label: "isolated next build node_modules",
nextDistDir: null,
binDir: (rootDir: string, _standaloneDir: string) =>
join(rootDir, ".build", "next", "node_modules", "tls-client-node", "bin"),
},
{
label: "configured isolated next build node_modules",
nextDistDir: ".custom-next",
binDir: (rootDir: string, _standaloneDir: string) =>
join(rootDir, ".custom-next", "node_modules", "tls-client-node", "bin"),
},
{
label: "nested standalone next build node_modules",
nextDistDir: null,
binDir: (_rootDir: string, standaloneDir: string) =>
join(standaloneDir, ".build", "next", "node_modules", "tls-client-node", "bin"),
},
{
label: "configured nested standalone next build node_modules",
nextDistDir: ".custom-next",
binDir: (_rootDir: string, standaloneDir: string) =>
join(standaloneDir, ".custom-next", "node_modules", "tls-client-node", "bin"),
},
];
for (const location of locations) {
const rootDir = makeRoot();
const hadNextDistDir = Object.prototype.hasOwnProperty.call(process.env, "NEXT_DIST_DIR");
const previousNextDistDir = process.env.NEXT_DIST_DIR;
try {
if (location.nextDistDir === null) delete process.env.NEXT_DIST_DIR;
else if (location.nextDistDir) process.env.NEXT_DIST_DIR = location.nextDistDir;
const binary = Buffer.from(`verified-target-for-${location.label}`);
const asset = {
file: "tls-client-target-test.so",
sha256: createHash("sha256").update(binary).digest("hex"),
};
const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin");
const standaloneDir = join(rootDir, ".build", "next", "standalone");
mkdirSync(rootBin, { recursive: true });
mkdirSync(standaloneDir, { recursive: true });
writeFileSync(join(rootBin, asset.file), binary);
const candidateBin = location.binDir(rootDir, standaloneDir);
mkdirSync(candidateBin, { recursive: true });
writeFileSync(join(candidateBin, "unlisted-native.so"), "must-not-ship");
await assert.rejects(
fixTlsClientNodeBinary({
rootDir,
asset,
standaloneDir,
requireStandalone: true,
strict: true,
retryDelaysMs: [],
fetchImpl: async () => {
throw new Error("verified root target must not trigger recovery");
},
log() {},
}),
/unlisted|not.*manifest|unexpected native sibling/i,
location.label
);
} finally {
if (hadNextDistDir) process.env.NEXT_DIST_DIR = previousNextDistDir;
else delete process.env.NEXT_DIST_DIR;
rmSync(rootDir, { recursive: true, force: true });
}
}
});
test("strict mode rejects a manifest-known non-target sibling with an invalid digest", async () => {
const rootDir = makeRoot();
try {
const targetBytes = Buffer.from("verified-linux-x64-target");
const siblingBytes = Buffer.from("verified-linux-arm64-sibling");
const targetAsset = {
file: "tls-client-linux-x64-test.so",
sha256: createHash("sha256").update(targetBytes).digest("hex"),
};
const siblingAsset = {
file: "tls-client-linux-arm64-test.so",
sha256: createHash("sha256").update(siblingBytes).digest("hex"),
};
const nativeAssets = {
"linux-x64": targetAsset,
"linux-arm64": siblingAsset,
};
const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin");
mkdirSync(rootBin, { recursive: true });
writeFileSync(join(rootBin, targetAsset.file), targetBytes);
writeFileSync(join(rootBin, siblingAsset.file), "tampered-known-sibling");
await assert.rejects(
fixTlsClientNodeBinary({
rootDir,
platform: "linux",
arches: ["x64"],
nativeAssets,
strict: true,
retryDelaysMs: [],
fetchImpl: async () => {
throw new Error("verified target must not trigger recovery");
},
log() {},
}),
/unverified|sha-?256|digest/i
);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});
test("strict mode allows a verified subset of manifest-known native siblings", async () => {
const rootDir = makeRoot();
try {
const targetBytes = Buffer.from("verified-subset-linux-x64");
const siblingBytes = Buffer.from("verified-subset-linux-arm64");
const targetAsset = {
file: "tls-client-subset-linux-x64.so",
sha256: createHash("sha256").update(targetBytes).digest("hex"),
};
const siblingAsset = {
file: "tls-client-subset-linux-arm64.so",
sha256: createHash("sha256").update(siblingBytes).digest("hex"),
};
const nativeAssets = {
"linux-x64": targetAsset,
"linux-arm64": siblingAsset,
};
const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin");
const targetPath = join(rootBin, targetAsset.file);
const siblingPath = join(rootBin, siblingAsset.file);
mkdirSync(rootBin, { recursive: true });
writeFileSync(targetPath, targetBytes);
writeFileSync(siblingPath, siblingBytes);
let fetchCalls = 0;
await fixTlsClientNodeBinary({
rootDir,
platform: "linux",
arches: ["x64"],
nativeAssets,
strict: true,
retryDelaysMs: [],
fetchImpl: async () => {
fetchCalls += 1;
throw new Error("verified subset must not trigger recovery");
},
log() {},
});
assert.equal(fetchCalls, 0);
assert.deepEqual(readFileSync(targetPath), targetBytes);
assert.deepEqual(readFileSync(siblingPath), siblingBytes);
if (process.platform !== "win32") {
assert.equal(lstatSync(targetPath).mode & 0o777, 0o555);
assert.equal(lstatSync(siblingPath).mode & 0o777, 0o555);
}
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});
test("strict mode rejects manifest-known siblings that are symlinks or non-files", async () => {
for (const variant of ["symlink", "directory"] as const) {
const rootDir = makeRoot();
try {
const targetBytes = Buffer.from(`verified-target-for-${variant}`);
const siblingBytes = Buffer.from(`verified-sibling-for-${variant}`);
const targetAsset = {
file: `tls-client-target-${variant}.so`,
sha256: createHash("sha256").update(targetBytes).digest("hex"),
};
const siblingAsset = {
file: `tls-client-sibling-${variant}.so`,
sha256: createHash("sha256").update(siblingBytes).digest("hex"),
};
const nativeAssets = {
"linux-x64": targetAsset,
"linux-arm64": siblingAsset,
};
const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin");
const siblingPath = join(rootBin, siblingAsset.file);
mkdirSync(rootBin, { recursive: true });
writeFileSync(join(rootBin, targetAsset.file), targetBytes);
if (variant === "symlink") {
const outsideFile = join(rootDir, "outside-native.so");
writeFileSync(outsideFile, siblingBytes);
symlinkSync(outsideFile, siblingPath);
} else {
mkdirSync(siblingPath);
}
await assert.rejects(
fixTlsClientNodeBinary({
rootDir,
platform: "linux",
arches: ["x64"],
nativeAssets,
strict: true,
retryDelaysMs: [],
log() {},
}),
/unsafe.*sibling|symlink|non-regular file/i,
variant
);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
}
});