Files
OmniRoute/scripts/build/tlsClientAssetCopy.mjs

447 lines
16 KiB
JavaScript

import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
const MAX_NATIVE_ASSET_BYTES = 64 * 1024 * 1024;
const READ_CHUNK_BYTES = 64 * 1024;
/**
* Convert the pinned manifest into copy entries while validating every name and
* digest. Keeping the digest on the entry makes the async and sync assemblers
* consume one source of truth.
*
* @param {Record<string, {file:string, sha256:string}>} nativeAssets
* @returns {{label:string, src:string[], dest:string[], tlsClientSha256:string}[]}
*/
export function createTlsClientNativeAssetEntries(nativeAssets) {
const entriesByFile = new Map();
for (const asset of Object.values(nativeAssets)) {
const file = asset?.file;
if (
typeof file !== "string" ||
file.length === 0 ||
file === "." ||
file === ".." ||
path.basename(file) !== file ||
file.includes("/") ||
file.includes("\\") ||
file.includes("\0")
) {
throw new Error(`Invalid tls-client native asset path: ${JSON.stringify(file)}`);
}
if (!/^[a-f0-9]{64}$/.test(asset.sha256)) {
throw new Error(`Invalid SHA-256 in tls-client native manifest for ${file}`);
}
const prior = entriesByFile.get(file);
if (prior && prior.tlsClientSha256 !== asset.sha256) {
throw new Error(`Conflicting SHA-256 values in tls-client native manifest for ${file}`);
}
entriesByFile.set(file, {
label: `manifest-declared tls-client native runtime seed (${file})`,
src: ["node_modules", "tls-client-node", "bin", file],
// Keep the public bootstrap seed outside DATA_DIR: Docker deployments
// commonly mount an empty /app/data volume, which must not hide it.
dest: ["runtime-assets", "tls-client", "bin", file],
tlsClientSha256: asset.sha256,
});
}
return [...entriesByFile.values()];
}
function lstatIfPresent(filePath) {
try {
return fs.lstatSync(filePath);
} catch (error) {
if (error?.code === "ENOENT") return undefined;
throw error;
}
}
function sameFileIdentity(left, right) {
return String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino);
}
function assertNativeAssetSize(size, filePath) {
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_NATIVE_ASSET_BYTES) {
throw new Error(
`[assembleStandalone] tls-client native asset exceeds the 64 MiB limit: ${filePath}`
);
}
}
/**
* Read a native seed through a no-follow descriptor and cap the read itself,
* not merely the initial stat. This also detects path replacement during the
* read before a digest can authorize the bytes.
*
* @param {string} filePath
* @returns {Buffer|undefined}
*/
function readBoundedRegularAsset(filePath) {
const pathStats = lstatIfPresent(filePath);
if (!pathStats) return undefined;
if (pathStats.isSymbolicLink() || !pathStats.isFile()) {
throw new Error(
`[assembleStandalone] unsafe tls-client native asset (symlink/non-regular file): ${filePath}`
);
}
const fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
try {
const openedStats = fs.fstatSync(fd);
if (!openedStats.isFile() || !sameFileIdentity(pathStats, openedStats)) {
throw new Error(
`[assembleStandalone] unsafe tls-client native asset changed before read: ${filePath}`
);
}
assertNativeAssetSize(openedStats.size, filePath);
const chunks = [];
let totalBytes = 0;
while (true) {
const remainingWithSentinel = MAX_NATIVE_ASSET_BYTES - totalBytes + 1;
const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, remainingWithSentinel));
const bytesRead = fs.readSync(fd, chunk, 0, chunk.byteLength, null);
if (bytesRead === 0) break;
totalBytes += bytesRead;
assertNativeAssetSize(totalBytes, filePath);
chunks.push(chunk.subarray(0, bytesRead));
}
const finalDescriptorStats = fs.fstatSync(fd);
const finalPathStats = fs.lstatSync(filePath);
if (
!finalDescriptorStats.isFile() ||
finalPathStats.isSymbolicLink() ||
!finalPathStats.isFile() ||
!sameFileIdentity(openedStats, finalDescriptorStats) ||
!sameFileIdentity(openedStats, finalPathStats)
) {
throw new Error(
`[assembleStandalone] unsafe tls-client native asset changed during read: ${filePath}`
);
}
assertNativeAssetSize(finalDescriptorStats.size, filePath);
if (finalDescriptorStats.size !== totalBytes) {
throw new Error(
`[assembleStandalone] tls-client native asset size changed during read: ${filePath}`
);
}
return Buffer.concat(chunks, totalBytes);
} finally {
fs.closeSync(fd);
}
}
function assertDigest(bytes, expectedSha256, filePath) {
const actualSha256 = createHash("sha256").update(bytes).digest("hex");
if (actualSha256 !== expectedSha256) {
throw new Error(
`[assembleStandalone] SHA-256 mismatch for tls-client native asset: ${filePath}`
);
}
}
function assertSafeExistingDestinationMode(filePath) {
if (process.platform === "win32") return;
const mode = fs.lstatSync(filePath).mode & 0o777;
if (mode !== 0o555) {
throw new Error(
`[assembleStandalone] unsafe tls-client native destination mode ` +
`(expected 0555, received ${mode.toString(8).padStart(4, "0")}): ${filePath}`
);
}
}
function assertContainedPath(rootPath, candidatePath, label) {
const root = path.resolve(rootPath);
const candidate = path.resolve(candidatePath);
const relativeCandidate = path.relative(root, candidate);
if (
relativeCandidate === "" ||
path.isAbsolute(relativeCandidate) ||
relativeCandidate === ".." ||
relativeCandidate.startsWith(`..${path.sep}`)
) {
throw new Error(`[assembleStandalone] unsafe tls-client native ${label}: ${candidate}`);
}
return { root, candidate, relativeCandidate };
}
function pathEscapesRoot(rootPath, candidatePath) {
const relativePath = path.relative(rootPath, candidatePath);
return (
path.isAbsolute(relativePath) ||
relativePath === ".." ||
relativePath.startsWith(`..${path.sep}`)
);
}
function readSafeDirectoryIdentity(directoryPath, label) {
const initialStats = lstatIfPresent(directoryPath);
if (!initialStats) return undefined;
if (initialStats.isSymbolicLink() || !initialStats.isDirectory()) {
throw new Error(
`[assembleStandalone] unsafe tls-client native ${label} ` +
`(symlink/non-directory): ${directoryPath}`
);
}
const canonicalPath = fs.realpathSync(directoryPath);
const finalStats = fs.lstatSync(directoryPath);
if (
finalStats.isSymbolicLink() ||
!finalStats.isDirectory() ||
!sameFileIdentity(initialStats, finalStats)
) {
throw new Error(
`[assembleStandalone] tls-client native ${label} changed during verification: ${directoryPath}`
);
}
return canonicalPath;
}
function assertSafeAncestorChain(
rootPath,
candidatePath,
{ label, createMissing = false, missingIsError = true }
) {
const { root, relativeCandidate } = assertContainedPath(
rootPath,
candidatePath,
`${label} outside trusted root`
);
if (!lstatIfPresent(root) && createMissing) fs.mkdirSync(root, { recursive: true });
const canonicalRoot = readSafeDirectoryIdentity(root, `${label} root`);
if (!canonicalRoot) {
if (!missingIsError) return false;
throw new Error(`[assembleStandalone] tls-client native ${label} root missing: ${root}`);
}
let current = root;
for (const component of path.dirname(relativeCandidate).split(path.sep).filter(Boolean)) {
current = path.join(current, component);
if (!lstatIfPresent(current) && createMissing) fs.mkdirSync(current);
const canonicalCurrent = readSafeDirectoryIdentity(current, `${label} ancestor`);
if (!canonicalCurrent) {
if (!missingIsError) return false;
throw new Error(
`[assembleStandalone] tls-client native ${label} ancestor missing: ${current}`
);
}
if (pathEscapesRoot(canonicalRoot, canonicalCurrent)) {
throw new Error(
`[assembleStandalone] unsafe tls-client native ${label} ancestor outside root: ${current}`
);
}
}
return true;
}
function assertSafeSourceAncestors(sourceRoot, sourcePath) {
assertSafeAncestorChain(sourceRoot, sourcePath, { label: "source" });
}
function ensureSafeDestinationParent(outDir, destinationPath) {
assertSafeAncestorChain(outDir, destinationPath, {
label: "destination",
createMissing: true,
});
}
function revalidateSafeDestinationParent(outDir, destinationPath) {
return assertSafeAncestorChain(outDir, destinationPath, {
label: "destination",
missingIsError: false,
});
}
function assertStillSafeDestinationParent(outDir, destinationPath) {
if (!revalidateSafeDestinationParent(outDir, destinationPath)) {
throw new Error(
`[assembleStandalone] tls-client native destination ancestor disappeared: ${destinationPath}`
);
}
}
function removeDirectDestinationSafely(outDir, destinationPath) {
try {
if (!revalidateSafeDestinationParent(outDir, destinationPath)) return false;
if (!lstatIfPresent(destinationPath)) return true;
if (!revalidateSafeDestinationParent(outDir, destinationPath)) return false;
fs.rmSync(destinationPath, { recursive: true, force: true });
return revalidateSafeDestinationParent(outDir, destinationPath);
} catch {
// The chain may now resolve through a symlink. Do not traverse it merely
// to clean up: preserving an external file is safer than an unsafe rmSync.
return false;
}
}
function auditNativeBin(outDir, binDir, assetsByFile) {
const auditSentinel = path.join(binDir, ".tls-client-native-audit");
if (!revalidateSafeDestinationParent(outDir, auditSentinel)) return;
for (const entryName of fs.readdirSync(binDir)) {
const entryPath = path.join(binDir, entryName);
try {
const expectedSha256 = assetsByFile.get(entryName);
if (!expectedSha256) {
throw new Error(`[assembleStandalone] unlisted tls-client native sibling: ${entryPath}`);
}
assertStillSafeDestinationParent(outDir, entryPath);
const bytes = readBoundedRegularAsset(entryPath);
assertStillSafeDestinationParent(outDir, entryPath);
if (!bytes) {
throw new Error(
`[assembleStandalone] tls-client native bundle entry disappeared: ${entryPath}`
);
}
assertDigest(bytes, expectedSha256, entryPath);
assertSafeExistingDestinationMode(entryPath);
assertStillSafeDestinationParent(outDir, entryPath);
} catch (error) {
removeDirectDestinationSafely(outDir, entryPath);
throw error;
}
}
assertStillSafeDestinationParent(outDir, auditSentinel);
}
/**
* Audit every tls-client bin topology that Next.js may bulk-copy into a
* standalone output. This prevents a valid manifest filename with unauthorized
* bytes from surviving outside runtime-assets merely because a later platform
* gate verifies only selected target arches.
*
* @param {{outDir:string, projectRoot:string, relativeNextDistDir:string, nativeAssets:Record<string, {file:string, sha256:string}>}} options
*/
export function auditTlsClientStandaloneBundle({
outDir,
projectRoot,
relativeNextDistDir,
nativeAssets,
}) {
const assetsByFile = new Map(
createTlsClientNativeAssetEntries(nativeAssets).map((entry) => [
entry.src.at(-1),
entry.tlsClientSha256,
])
);
const projectBasename = path.basename(path.resolve(projectRoot));
const binDirs = [
path.join(outDir, "node_modules", "tls-client-node", "bin"),
path.join(outDir, "projects", "OmniRoute", "node_modules", "tls-client-node", "bin"),
path.join(outDir, projectBasename, "node_modules", "tls-client-node", "bin"),
path.join(outDir, relativeNextDistDir, "node_modules", "tls-client-node", "bin"),
path.join(outDir, "runtime-assets", "tls-client", "bin"),
];
for (const binDir of new Set(binDirs)) auditNativeBin(outDir, binDir, assetsByFile);
}
/**
* Copy only bytes authorized by the pinned digest, then independently read and
* verify the emitted file. Any failed verification removes the direct output so
* a failed assembly cannot leave a distributable manifest-named seed behind.
*
* @param {{sourceRoot:string, sourcePath:string, destinationPath:string, expectedSha256:string, outDir:string}} options
* @returns {boolean} true only when source bytes were copied
*/
export function copyVerifiedTlsClientNativeAsset({
sourceRoot,
sourcePath,
destinationPath,
expectedSha256,
outDir,
}) {
ensureSafeDestinationParent(outDir, destinationPath);
try {
const sourcePathStats = lstatIfPresent(sourcePath);
let sourceBytes;
if (sourcePathStats) {
assertSafeSourceAncestors(sourceRoot, sourcePath);
sourceBytes = readBoundedRegularAsset(sourcePath);
assertSafeSourceAncestors(sourceRoot, sourcePath);
if (!sourceBytes) {
throw new Error(
`[assembleStandalone] tls-client native source disappeared during verification: ${sourcePath}`
);
}
}
if (!sourceBytes) {
// assembleStandalone first bulk-copies a prior Next standalone tree.
// If the source install is now absent, a manifest-named seed may still
// have arrived through that pass; authorize it independently or fail and
// remove it instead of silently distributing stale bytes.
assertStillSafeDestinationParent(outDir, destinationPath);
const existingDestinationBytes = readBoundedRegularAsset(destinationPath);
assertStillSafeDestinationParent(outDir, destinationPath);
if (!existingDestinationBytes) return false;
assertDigest(existingDestinationBytes, expectedSha256, destinationPath);
assertSafeExistingDestinationMode(destinationPath);
assertStillSafeDestinationParent(outDir, destinationPath);
return false;
}
assertDigest(sourceBytes, expectedSha256, sourcePath);
assertStillSafeDestinationParent(outDir, destinationPath);
const destinationStats = lstatIfPresent(destinationPath);
if (destinationStats?.isSymbolicLink() || (destinationStats && !destinationStats.isFile())) {
throw new Error(
`[assembleStandalone] unsafe tls-client native destination (symlink/non-regular file): ${destinationPath}`
);
}
if (!removeDirectDestinationSafely(outDir, destinationPath)) {
throw new Error(
`[assembleStandalone] unsafe tls-client native destination changed before removal: ${destinationPath}`
);
}
assertStillSafeDestinationParent(outDir, destinationPath);
const fd = fs.openSync(
destinationPath,
fs.constants.O_WRONLY |
fs.constants.O_CREAT |
fs.constants.O_EXCL |
(fs.constants.O_NOFOLLOW ?? 0),
0o555
);
try {
assertStillSafeDestinationParent(outDir, destinationPath);
let offset = 0;
while (offset < sourceBytes.length) {
const bytesWritten = fs.writeSync(fd, sourceBytes, offset, sourceBytes.length - offset);
if (bytesWritten <= 0) {
throw new Error(
`[assembleStandalone] failed to write tls-client native asset: ${destinationPath}`
);
}
offset += bytesWritten;
}
if (process.platform !== "win32") fs.fchmodSync(fd, 0o555);
assertStillSafeDestinationParent(outDir, destinationPath);
} finally {
fs.closeSync(fd);
}
assertStillSafeDestinationParent(outDir, destinationPath);
const destinationBytes = readBoundedRegularAsset(destinationPath);
assertStillSafeDestinationParent(outDir, destinationPath);
if (!destinationBytes) {
throw new Error(
`[assembleStandalone] copied tls-client native asset disappeared: ${destinationPath}`
);
}
assertDigest(destinationBytes, expectedSha256, destinationPath);
if (!sourceBytes.equals(destinationBytes)) {
throw new Error(
`[assembleStandalone] copied tls-client native asset differs from source: ${destinationPath}`
);
}
assertStillSafeDestinationParent(outDir, destinationPath);
return true;
} catch (error) {
removeDirectDestinationSafely(outDir, destinationPath);
throw error;
}
}