fix: harden prerender publication boundary

This commit is contained in:
2026-09-02 02:44:23 +03:00
parent 3dd4a818d0
commit c16c4c57da
22 changed files with 1275 additions and 231 deletions

View File

@@ -1,9 +1,22 @@
import { cp, lstat, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import { createHash, randomUUID } from 'node:crypto';
import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, relative, resolve, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { loadProductionInputs } from '../src/content/load-production-inputs.ts';
import { createPageMetadata, createStaticHostArtifacts } from '../src/content/metadata.ts';
import { serializeJsonLd } from '../src/security/serialize-json-ld.ts';
import {
createArtifactContract,
createBuildAuthority,
resolveViteAssetClosure,
serializeBuildAuthority,
} from './build-contract.mjs';
import { parseExactConfigArgument } from './config-argument.mjs';
import { captureDirectoryIdentity, publishStagedDirectory } from './safe-publication.mjs';
import { verifyArtifactDirectory } from './verify-artifacts.mjs';
export { parseExactConfigArgument } from './config-argument.mjs';
const scriptPath = fileURLToPath(import.meta.url);
const repositoryRoot = resolve(dirname(scriptPath), '..');
@@ -19,33 +32,6 @@ const exactlyOne = (source, expression, label) => {
if (matches.length !== 1) throw new Error(`Expected exactly one ${label}.`);
};
export const parseExactConfigArgument = (arguments_) => {
if (arguments_.length !== 2 || arguments_[0] !== '--config') {
throw new Error('Prerender accepts exactly --config <path>.');
}
const configPath = arguments_[1];
if (
configPath.length === 0 ||
configPath.startsWith('/') ||
configPath.includes('\\') ||
configPath.split('/').some((part) => part === '' || part === '.' || part === '..') ||
!/^config\/[a-z0-9][a-z0-9._-]*\.json$/u.test(configPath)
) {
throw new Error('Config must be a safe repository-relative JSON path inside config/.');
}
return configPath;
};
const manifestEntry = (manifest) => {
const entries = Object.values(manifest).filter((entry) => entry.isEntry === true);
if (entries.length !== 1) throw new Error('Vite manifest must contain exactly one client entry.');
const entry = entries[0];
if (typeof entry.file !== 'string' || !/^assets\/[a-zA-Z0-9._-]+\.js$/u.test(entry.file)) {
throw new Error('Vite manifest client entry is unsafe.');
}
return entry;
};
const metadataMarkup = (metadata) => {
const image = metadata.openGraph.image === undefined
? ''
@@ -63,8 +49,8 @@ const metadataMarkup = (metadata) => {
};
export const assemblePrerenderedHtml = (template, manifest, markup, inputs) => {
const entry = manifestEntry(manifest);
const entryUrl = `/${entry.file}`;
const closure = resolveViteAssetClosure(manifest);
const entryUrl = `/${closure.entryModule}`;
exactlyOne(template, /<title>[^<]*<\/title>/gu, 'title');
exactlyOne(template, /<div id="app"><\/div>/gu, 'empty app root');
exactlyOne(template, /<script type="module"[^>]*><\/script>/gu, 'client module script');
@@ -73,8 +59,7 @@ export const assemblePrerenderedHtml = (template, manifest, markup, inputs) => {
const metadata = createPageMetadata(inputs.content, inputs.manifest, inputs.site);
const snapshot = serializeJsonLd(inputs);
const preloads = [entry.file, ...(entry.imports ?? []).map((key) => manifest[key]?.file)]
.filter((file, index, files) => typeof file === 'string' && files.indexOf(file) === index)
const preloads = closure.modulePreloads
.map((file) => ` <link rel="modulepreload" href="/${file}" />`)
.join('\n');
const withoutModule = template.replace(/\s*<script type="module"[^>]*><\/script>/u, '');
@@ -89,62 +74,6 @@ export const assemblePrerenderedHtml = (template, manifest, markup, inputs) => {
return withHead.replace('<!--portfolio-body-->', clientBootstrap);
};
const assertDirectoryMembers = async (root, expected, label) => {
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
if (!expected.has(entry.name)) throw new Error(`Unexpected build output in ${label}: ${entry.name}`);
if (entry.isSymbolicLink()) throw new Error(`Unexpected linked build output in ${label}: ${entry.name}`);
}
for (const name of expected) {
if (!entries.some((entry) => entry.name === name)) throw new Error(`Missing build output in ${label}: ${name}`);
}
return entries;
};
const manifestAssetFiles = (manifest) => {
const files = new Set();
for (const entry of Object.values(manifest)) {
for (const file of [entry.file, ...(entry.css ?? []), ...(entry.assets ?? [])]) {
if (typeof file !== 'string' || !/^assets\/[a-zA-Z0-9._-]+$/u.test(file)) {
throw new Error('Vite manifest contains an unsafe output path.');
}
files.add(file.slice('assets/'.length));
}
}
return files;
};
export const assertExpectedDistInputs = async (distRoot) => {
const allowed = new Set([
'client', 'assets', 'index.html', 'robots.txt', 'sitemap.xml', '_headers.example', '_redirects.example',
]);
const rootEntries = await readdir(distRoot, { withFileTypes: true });
for (const entry of rootEntries) {
if (!allowed.has(entry.name)) throw new Error(`Unexpected build output: ${entry.name}`);
if (entry.isSymbolicLink()) throw new Error(`Unexpected linked build output: ${entry.name}`);
}
const clientStats = await lstat(resolve(distRoot, 'client'));
if (clientStats.isSymbolicLink() || !clientStats.isDirectory()) {
throw new Error('Client output must be a non-symlink directory.');
}
await assertDirectoryMembers(resolve(distRoot, 'client'), new Set(['.vite', 'assets', 'index.html']), 'client');
await assertDirectoryMembers(resolve(distRoot, 'client/.vite'), new Set(['manifest.json']), 'client/.vite');
const manifest = JSON.parse(await readFile(resolve(distRoot, 'client/.vite/manifest.json'), 'utf8'));
await assertDirectoryMembers(resolve(distRoot, 'client/assets'), manifestAssetFiles(manifest), 'client/assets');
if (rootEntries.some((entry) => entry.name === 'assets')) {
if (!rootEntries.some((entry) => entry.name === 'index.html')) {
throw new Error('Existing root assets require their generated index.html inventory.');
}
const existingHtml = await readFile(resolve(distRoot, 'index.html'), 'utf8');
const referencedAssets = new Set(
[...existingHtml.matchAll(/(?:src|href)="\/assets\/([a-zA-Z0-9._-]+)"/gu)].map((match) => match[1]),
);
await assertDirectoryMembers(resolve(distRoot, 'assets'), referencedAssets, 'assets');
}
};
const assertInsideRepository = (path) => {
const pathRelativeToRoot = relative(repositoryRoot, path);
if (pathRelativeToRoot === '..' || pathRelativeToRoot.startsWith(`..${sep}`)) {
@@ -152,12 +81,152 @@ const assertInsideRepository = (path) => {
}
};
const isMissing = (error) =>
typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
const fileIdentity = (stats) => ({
dev: stats.dev,
ino: stats.ino,
size: stats.size,
mtimeMs: stats.mtimeMs,
});
const sameFileIdentity = (left, right) =>
left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
const assertRegularFile = async (path, label) => {
const stats = await lstat(path);
if (stats.isSymbolicLink() || !stats.isFile() || stats.nlink !== 1) {
throw new Error(`${label} must be one regular, non-linked file.`);
}
return fileIdentity(stats);
};
const readSafeRegularFile = async (path, label) => {
const initialIdentity = await assertRegularFile(path, label);
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
try {
const openedStats = await handle.stat();
if (!openedStats.isFile() || openedStats.nlink !== 1 ||
!sameFileIdentity(initialIdentity, fileIdentity(openedStats))) {
throw new Error(`${label} identity changed while opening.`);
}
const bytes = await handle.readFile();
if (!sameFileIdentity(initialIdentity, await assertRegularFile(path, label))) {
throw new Error(`${label} identity changed while reading.`);
}
return bytes;
} finally {
await handle.close();
}
};
const assertExactDirectoryMembers = async (root, expectedNames, label) => {
await captureDirectoryIdentity(root);
const entries = await readdir(root, { withFileTypes: true });
const actualNames = entries.map((entry) => entry.name).sort();
const expected = [...expectedNames].sort();
if (JSON.stringify(actualNames) !== JSON.stringify(expected)) {
throw new Error(`${label} inventory is not exact.`);
}
if (entries.some((entry) => entry.isSymbolicLink())) throw new Error(`${label} contains a symlink.`);
return entries;
};
const loadTrustedClientBuild = async (clientRoot) => {
await assertExactDirectoryMembers(clientRoot, ['.vite', 'assets', 'index.html'], 'Client build');
const viteRoot = resolve(clientRoot, '.vite');
const assetsRoot = resolve(clientRoot, 'assets');
await assertExactDirectoryMembers(viteRoot, ['manifest.json'], 'Client Vite metadata');
await captureDirectoryIdentity(assetsRoot);
const template = (await readSafeRegularFile(resolve(clientRoot, 'index.html'), 'Built client template')).toString('utf8');
const manifestSource = (await readSafeRegularFile(resolve(viteRoot, 'manifest.json'), 'Vite manifest')).toString('utf8');
const manifest = JSON.parse(manifestSource);
const closure = resolveViteAssetClosure(manifest);
const clientPaths = [...new Set([
...closure.executableModules,
...closure.styles,
...closure.viteAssets,
])].sort();
await assertExactDirectoryMembers(
assetsRoot,
clientPaths.map((path) => path.slice('assets/'.length)),
'Client assets',
);
const bytesByPath = {};
for (const path of clientPaths) {
bytesByPath[path] = await readSafeRegularFile(resolve(clientRoot, path), `Client asset ${path}`);
}
return { template, manifest, closure, bytesByPath };
};
const loadPreparedPublicAssets = async (inputs) => {
const preparedRoot = resolve(repositoryRoot, 'inputs/prepared-assets');
await captureDirectoryIdentity(preparedRoot);
const bytesByPath = {};
for (const asset of inputs.manifest.assets) {
bytesByPath[`assets/${asset.fileName}`] = await readSafeRegularFile(
resolve(preparedRoot, asset.fileName),
`Prepared public asset ${asset.fileName}`,
);
}
return bytesByPath;
};
const writeFreshStagingArtifacts = async (stagingRoot, html, artifacts, bytesByPath) => {
await mkdir(resolve(stagingRoot, 'assets'));
for (const [path, bytes] of Object.entries(bytesByPath)) {
await writeFile(resolve(stagingRoot, path), bytes, { flag: 'wx' });
}
await Promise.all([
writeFile(resolve(stagingRoot, 'index.html'), html, { flag: 'wx' }),
writeFile(resolve(stagingRoot, 'robots.txt'), artifacts.robots, { flag: 'wx' }),
writeFile(resolve(stagingRoot, '_headers.example'), artifacts.headers, { flag: 'wx' }),
writeFile(resolve(stagingRoot, '_redirects.example'), artifacts.redirects, { flag: 'wx' }),
...(artifacts.sitemap === undefined
? []
: [writeFile(resolve(stagingRoot, 'sitemap.xml'), artifacts.sitemap, { flag: 'wx' })]),
]);
};
const removeOwnedStaging = async (stagingRoot) => {
if (dirname(stagingRoot) !== repositoryRoot || !stagingRoot.startsWith(resolve(repositoryRoot, '.dist-stage-'))) {
throw new Error('Refusing to remove an unowned staging path.');
}
try {
await captureDirectoryIdentity(stagingRoot);
} catch (error) {
if (isMissing(error) || /existing safe directory/iu.test(String(error))) return;
throw error;
}
await rm(stagingRoot, { recursive: true });
};
const writeAuthorityAtomically = async (authority) => {
const serverRoot = resolve(repositoryRoot, 'dist-ssr');
await captureDirectoryIdentity(serverRoot);
const authorityPath = resolve(serverRoot, 'artifact-authority.json');
try {
await assertRegularFile(authorityPath, 'Existing artifact authority');
} catch (error) {
if (!isMissing(error)) throw error;
}
const temporaryPath = resolve(serverRoot, `.artifact-authority-${randomUUID()}.tmp`);
try {
await writeFile(temporaryPath, serializeBuildAuthority(authority), { flag: 'wx' });
await assertRegularFile(temporaryPath, 'Temporary artifact authority');
await rename(temporaryPath, authorityPath);
} catch (error) {
await rm(temporaryPath, { force: true });
throw error;
}
};
export const prerender = async (configPath) => {
const distRoot = resolve(repositoryRoot, 'dist');
const clientRoot = resolve(distRoot, 'client');
const clientRoot = resolve(repositoryRoot, 'dist-client');
const configAbsolutePath = resolve(repositoryRoot, configPath);
assertInsideRepository(configAbsolutePath);
await assertExpectedDistInputs(distRoot);
if (await realpath(repositoryRoot) !== repositoryRoot) throw new Error('Repository root has a symlinked ancestor.');
const inputs = await loadProductionInputs({
contentJson: pathToFileURL(resolve(repositoryRoot, 'content/public/portfolio-content.json')),
@@ -165,32 +234,47 @@ export const prerender = async (configPath) => {
preparedAssetRoot: pathToFileURL(`${resolve(repositoryRoot, 'inputs/prepared-assets')}${sep}`),
siteConfigJson: pathToFileURL(configAbsolutePath),
});
const [{ render }, template, manifestSource] = await Promise.all([
import(pathToFileURL(resolve(repositoryRoot, 'dist-ssr/entry-server.js')).href),
readFile(resolve(clientRoot, 'index.html'), 'utf8'),
readFile(resolve(clientRoot, '.vite/manifest.json'), 'utf8'),
]);
const clientBuild = await loadTrustedClientBuild(clientRoot);
const serverEntryPath = resolve(repositoryRoot, 'dist-ssr/entry-server.js');
const serverEntryIdentity = await assertRegularFile(serverEntryPath, 'SSR entry');
const { render } = await import(`${pathToFileURL(serverEntryPath).href}?build=${encodeURIComponent(inputs.site.buildId)}`);
if (typeof render !== 'function') throw new Error('SSR entry does not export render().');
const manifest = JSON.parse(manifestSource);
if (!sameFileIdentity(serverEntryIdentity, await assertRegularFile(serverEntryPath, 'SSR entry'))) {
throw new Error('SSR entry identity changed during import.');
}
const markup = await render('/', inputs);
const html = assemblePrerenderedHtml(template, manifest, markup, inputs);
const html = assemblePrerenderedHtml(clientBuild.template, clientBuild.manifest, markup, inputs);
const metadata = createPageMetadata(inputs.content, inputs.manifest, inputs.site);
const artifacts = createStaticHostArtifacts(metadata, inputs.site);
for (const path of ['assets', 'index.html', 'robots.txt', 'sitemap.xml', '_headers.example', '_redirects.example']) {
await rm(resolve(distRoot, path), { recursive: true, force: true });
const publicBytes = await loadPreparedPublicAssets(inputs);
const bytesByPath = { ...clientBuild.bytesByPath, ...publicBytes };
const digestByPath = Object.fromEntries(Object.entries(bytesByPath).map(([path, bytes]) => [
path,
createHash('sha256').update(bytes).digest('hex'),
]));
const contract = createArtifactContract(
clientBuild.closure,
inputs.manifest,
digestByPath,
inputs.site.buildId,
);
const authority = createBuildAuthority(clientBuild.manifest, contract);
const stagingRoot = await mkdtemp(resolve(repositoryRoot, '.dist-stage-'));
try {
await writeFreshStagingArtifacts(stagingRoot, html, artifacts, bytesByPath);
await verifyArtifactDirectory(stagingRoot, inputs, contract);
const stagingIdentity = await captureDirectoryIdentity(stagingRoot);
await publishStagedDirectory({
repositoryRoot,
finalRoot: distRoot,
stagingRoot,
expectedStagingIdentity: stagingIdentity,
beforeCommit: async () => writeAuthorityAtomically(authority),
});
} catch (error) {
await removeOwnedStaging(stagingRoot);
throw error;
}
await cp(resolve(clientRoot, 'assets'), resolve(distRoot, 'assets'), { recursive: true, errorOnExist: true });
await Promise.all([
writeFile(resolve(distRoot, 'index.html'), html),
writeFile(resolve(distRoot, 'robots.txt'), artifacts.robots),
writeFile(resolve(distRoot, '_headers.example'), artifacts.headers),
writeFile(resolve(distRoot, '_redirects.example'), artifacts.redirects),
...(artifacts.sitemap === undefined
? []
: [writeFile(resolve(distRoot, 'sitemap.xml'), artifacts.sitemap)]),
]);
await rm(clientRoot, { recursive: true });
};
if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) {