fix: harden prerender publication boundary
This commit is contained in:
@@ -3,7 +3,10 @@ import { lstat, readFile, readdir } 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 { parseExactConfigArgument } from './prerender.mjs';
|
||||
import { createPageMetadata, createStaticHostArtifacts } from '../src/content/metadata.ts';
|
||||
import { Window } from 'happy-dom';
|
||||
import { parseArtifactContract, parseBuildAuthority } from './build-contract.mjs';
|
||||
import { parseExactConfigArgument } from './config-argument.mjs';
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const repositoryRoot = resolve(dirname(scriptPath), '..');
|
||||
@@ -59,18 +62,20 @@ const listArtifacts = async (root) => {
|
||||
return files.sort();
|
||||
};
|
||||
|
||||
const assertArtifactInventory = (files, html, inputs) => {
|
||||
const assertArtifactInventory = (files, inputs, contract) => {
|
||||
const expectedRootFiles = new Set(['index.html', 'robots.txt', '_headers.example', '_redirects.example']);
|
||||
if (inputs.site.environment === 'production') expectedRootFiles.add('sitemap.xml');
|
||||
const referencedAssets = new Set(
|
||||
[...html.matchAll(/(?:src|href)="\/assets\/([a-zA-Z0-9._-]+)"/gu)].map((match) => `assets/${match[1]}`),
|
||||
);
|
||||
for (const asset of inputs.manifest.assets) referencedAssets.add(`assets/${asset.fileName}`);
|
||||
const expectedAssets = new Set(contract.files.map((record) => record.path));
|
||||
const expectedPublicAssets = inputs.manifest.assets.map((asset) => `assets/${asset.fileName}`).sort();
|
||||
if (contract.buildId !== inputs.site.buildId ||
|
||||
JSON.stringify(contract.publicAssets) !== JSON.stringify(expectedPublicAssets)) {
|
||||
throw new Error('Artifact contract does not match the validated production inputs.');
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.map')) throw new Error(`Source map artifact is prohibited: ${file}`);
|
||||
if (file.startsWith('assets/')) {
|
||||
if (!referencedAssets.has(file)) throw new Error(`Unexpected artifact: ${file}`);
|
||||
if (!expectedAssets.has(file)) throw new Error(`Unexpected artifact outside trusted contract: ${file}`);
|
||||
} else if (!expectedRootFiles.has(file)) {
|
||||
if (inputs.site.environment === 'preview' && file === 'sitemap.xml') {
|
||||
throw new Error('Preview artifacts must not publish a sitemap.');
|
||||
@@ -78,11 +83,48 @@ const assertArtifactInventory = (files, html, inputs) => {
|
||||
throw new Error(`Unexpected artifact: ${file}`);
|
||||
}
|
||||
}
|
||||
for (const required of [...expectedRootFiles, ...referencedAssets]) {
|
||||
for (const required of [...expectedRootFiles, ...expectedAssets]) {
|
||||
if (!files.includes(required)) throw new Error(`Missing required artifact: ${required}`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertArtifactDigests = async (root, contract) => {
|
||||
for (const record of contract.files) {
|
||||
const digest = createHash('sha256').update(await readFile(resolve(root, record.path))).digest('hex');
|
||||
if (digest !== record.sha256) throw new Error(`Artifact digest does not match trusted contract: ${record.path}`);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedAttributeValues = (elements, attribute) =>
|
||||
[...elements].map((element) => element.getAttribute(attribute)).filter((value) => value !== null).sort();
|
||||
|
||||
const assertTrustedHtmlAssets = (html, contract) => {
|
||||
const window = new Window();
|
||||
try {
|
||||
window.document.write(html);
|
||||
const scripts = [...window.document.querySelectorAll('script')];
|
||||
const jsonLd = scripts.filter((script) => script.type === 'application/ld+json' && !script.hasAttribute('src'));
|
||||
const snapshot = scripts.filter((script) =>
|
||||
script.id === 'portfolio-inputs' && script.type === 'application/json' && !script.hasAttribute('src'));
|
||||
const modules = scripts.filter((script) => script.type === 'module' && script.getAttribute('src') === `/${contract.entryModule}`);
|
||||
if (scripts.length !== 3 || jsonLd.length !== 1 || snapshot.length !== 1 || modules.length !== 1) {
|
||||
throw new Error('Unexpected executable script outside the exact JSON-LD, inert snapshot, and trusted client module set.');
|
||||
}
|
||||
const preloads = sortedAttributeValues(window.document.querySelectorAll('link[rel="modulepreload"]'), 'href');
|
||||
const expectedPreloads = contract.modulePreloads.map((path) => `/${path}`).sort();
|
||||
if (JSON.stringify(preloads) !== JSON.stringify(expectedPreloads)) {
|
||||
throw new Error('Final HTML module preload set does not match the trusted contract.');
|
||||
}
|
||||
const styles = sortedAttributeValues(window.document.querySelectorAll('link[rel="stylesheet"]'), 'href');
|
||||
const expectedStyles = contract.styles.map((path) => `/${path}`).sort();
|
||||
if (JSON.stringify(styles) !== JSON.stringify(expectedStyles)) {
|
||||
throw new Error('Final HTML style set does not match the trusted contract.');
|
||||
}
|
||||
} finally {
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
|
||||
const assertNoProhibitedSurfaces = async (root, files) => {
|
||||
for (const file of files) {
|
||||
const bytes = await readFile(resolve(root, file));
|
||||
@@ -99,56 +141,110 @@ const exactlyOneMatch = (text, expression, label) => {
|
||||
return matches[0];
|
||||
};
|
||||
|
||||
const assertSemanticHtml = (html, inputs) => {
|
||||
if (!/<html lang="en">/u.test(html)) throw new Error('Final HTML must declare lang=en.');
|
||||
const h1 = exactlyOneMatch(html, /<h1\b[^>]*>([\s\S]*?)<\/h1>/gu, 'H1');
|
||||
if (h1[1] !== escapeHtml(inputs.content.identity.name)) throw new Error('H1 identity does not match public content.');
|
||||
const adjacentIdentity = `<h1>${escapeHtml(inputs.content.identity.name)}</h1><p>${escapeHtml(inputs.content.identity.role)}</p>`;
|
||||
if (!html.includes(adjacentIdentity)) throw new Error('The canonical role must be immediately adjacent to the H1.');
|
||||
const excludedSemanticContainers = new Set(['SCRIPT', 'STYLE', 'TEMPLATE', 'NOSCRIPT']);
|
||||
|
||||
for (const id of canonicalSectionIds) {
|
||||
const sectionMatches = html.match(new RegExp(`<section id="${escapeRegularExpression(id)}"(?:\\s|>)`, 'gu')) ?? [];
|
||||
if (sectionMatches.length !== 1) throw new Error(`Expected exactly one section: ${id}`);
|
||||
const navigationMatches = html.match(new RegExp(`href="#${escapeRegularExpression(id)}"`, 'gu')) ?? [];
|
||||
if (navigationMatches.length !== 1) throw new Error(`Expected one native navigation link for section: ${id}`);
|
||||
const label = inputs.content.navigation.find((item) => item.id === id)?.label;
|
||||
if (label === undefined || !html.includes(`<h2 id="${id}-heading">${escapeHtml(label.toUpperCase())}</h2>`)) {
|
||||
throw new Error(`Missing canonical section heading: ${id}`);
|
||||
const isVisibleSemanticNode = (node, appRoot) => {
|
||||
if (!node.isConnected || !appRoot.contains(node)) return false;
|
||||
let element = node.nodeType === 1 ? node : node.parentElement;
|
||||
while (element !== null) {
|
||||
if (excludedSemanticContainers.has(element.tagName) || element.hasAttribute('hidden') ||
|
||||
element.hasAttribute('inert') || element.getAttribute('aria-hidden') === 'true' ||
|
||||
element.style?.display === 'none' || element.style?.visibility === 'hidden') {
|
||||
return false;
|
||||
}
|
||||
if (element === appRoot) return true;
|
||||
element = element.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const requiredVisibleCopy = [
|
||||
inputs.content.systemsOperations.title,
|
||||
inputs.content.infrastructure.title,
|
||||
...inputs.content.infrastructure.bands.flatMap((band) => [band.title, band.summary]),
|
||||
...inputs.content.projects.selected.flatMap((project) => [project.title, project.summary]),
|
||||
...inputs.content.toolkit.flatMap((group) => [group.title, ...group.technologies.map((technology) => technology.name)]),
|
||||
inputs.content.contact.email,
|
||||
];
|
||||
for (const copy of requiredVisibleCopy) {
|
||||
if (!html.includes(escapeHtml(copy))) throw new Error(`Missing required semantic evidence: ${copy}`);
|
||||
}
|
||||
if (!html.includes(`href="mailto:${escapeHtml(inputs.content.contact.email)}"`)) {
|
||||
throw new Error('Missing visible mailto contact link.');
|
||||
}
|
||||
const visibleText = (node, appRoot) => {
|
||||
if (node.nodeType === 3) return isVisibleSemanticNode(node, appRoot) ? node.textContent ?? '' : '';
|
||||
if (node.nodeType === 1 && !isVisibleSemanticNode(node, appRoot)) return '';
|
||||
return [...node.childNodes].map((child) => visibleText(child, appRoot)).join('');
|
||||
};
|
||||
|
||||
const moduleScript = exactlyOneMatch(html, /<script type="module"[^>]*src="\/assets\/[a-zA-Z0-9._-]+\.js"[^>]*><\/script>/gu, 'client module script');
|
||||
const snapshot = exactlyOneMatch(
|
||||
html,
|
||||
/<script id="portfolio-inputs" type="application\/json">([\s\S]*?)<\/script>/gu,
|
||||
'inert input snapshot',
|
||||
);
|
||||
let parsedSnapshot;
|
||||
const assertSemanticHtml = (html, inputs, contract) => {
|
||||
const window = new Window();
|
||||
try {
|
||||
parsedSnapshot = JSON.parse(snapshot[1]);
|
||||
} catch {
|
||||
throw new Error('The inert input snapshot is malformed.');
|
||||
}
|
||||
if (JSON.stringify(parsedSnapshot) !== JSON.stringify(inputs)) {
|
||||
throw new Error('Hydration snapshot does not match the server input bytes and meaning.');
|
||||
}
|
||||
if (snapshot.index >= moduleScript.index || h1.index >= moduleScript.index) {
|
||||
throw new Error('Semantic HTML and inert snapshot must precede the client module script.');
|
||||
window.document.write(html);
|
||||
const document = window.document;
|
||||
if (document.documentElement.lang !== 'en') throw new Error('Final HTML must declare lang=en.');
|
||||
const appRoots = [...document.querySelectorAll('#app')];
|
||||
if (appRoots.length !== 1 || !isVisibleSemanticNode(appRoots[0], appRoots[0])) {
|
||||
throw new Error('Expected one connected visible semantic #app root.');
|
||||
}
|
||||
const appRoot = appRoots[0];
|
||||
const h1s = [...appRoot.querySelectorAll('h1')].filter((node) => isVisibleSemanticNode(node, appRoot));
|
||||
if (h1s.length !== 1 || h1s[0].textContent !== inputs.content.identity.name) {
|
||||
throw new Error('Expected exactly one visible H1 with the canonical identity.');
|
||||
}
|
||||
const role = h1s[0].nextElementSibling;
|
||||
if (role === null || !isVisibleSemanticNode(role, appRoot) || role.textContent !== inputs.content.identity.role) {
|
||||
throw new Error('Expected the visible canonical role immediately after the H1.');
|
||||
}
|
||||
|
||||
const navigations = [...appRoot.querySelectorAll('nav[aria-label="Portfolio sections"]')]
|
||||
.filter((node) => isVisibleSemanticNode(node, appRoot));
|
||||
if (navigations.length !== 1) throw new Error('Expected exactly one visible navigation.');
|
||||
const navigationLinks = [...navigations[0].querySelectorAll('a')]
|
||||
.filter((node) => isVisibleSemanticNode(node, appRoot));
|
||||
if (JSON.stringify(navigationLinks.map((link) => link.getAttribute('href'))) !==
|
||||
JSON.stringify(canonicalSectionIds.map((id) => `#${id}`))) {
|
||||
throw new Error('Visible navigation does not contain the exact canonical native links.');
|
||||
}
|
||||
|
||||
const sections = [...appRoot.querySelectorAll('main > section')]
|
||||
.filter((node) => isVisibleSemanticNode(node, appRoot));
|
||||
if (JSON.stringify(sections.map((section) => section.id)) !== JSON.stringify(canonicalSectionIds)) {
|
||||
throw new Error('Visible sections do not match the eight canonical sections.');
|
||||
}
|
||||
for (const section of sections) {
|
||||
const label = inputs.content.navigation.find((item) => item.id === section.id)?.label;
|
||||
const headings = [...section.querySelectorAll(':scope > h2')]
|
||||
.filter((node) => isVisibleSemanticNode(node, appRoot));
|
||||
if (label === undefined || headings.length !== 1 || headings[0].id !== `${section.id}-heading` ||
|
||||
headings[0].textContent !== label.toUpperCase()) {
|
||||
throw new Error(`Missing visible section heading: ${section.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const semanticText = visibleText(appRoot, appRoot);
|
||||
const requiredVisibleCopy = [
|
||||
inputs.content.systemsOperations.title,
|
||||
inputs.content.infrastructure.title,
|
||||
...inputs.content.infrastructure.bands.flatMap((band) => [band.title, band.summary]),
|
||||
...inputs.content.projects.selected.flatMap((project) => [project.title, project.summary]),
|
||||
...inputs.content.toolkit.flatMap((group) => [group.title, ...group.technologies.map((technology) => technology.name)]),
|
||||
inputs.content.contact.email,
|
||||
];
|
||||
for (const copy of requiredVisibleCopy) {
|
||||
if (!semanticText.includes(copy)) throw new Error(`Missing visible semantic evidence: ${copy}`);
|
||||
}
|
||||
const mailLinks = [...appRoot.querySelectorAll(`a[href="mailto:${inputs.content.contact.email}"]`)]
|
||||
.filter((node) => isVisibleSemanticNode(node, appRoot));
|
||||
if (mailLinks.length !== 1 || mailLinks[0].textContent !== inputs.content.contact.email) {
|
||||
throw new Error('Missing visible semantic mailto contact link.');
|
||||
}
|
||||
|
||||
const moduleScript = document.querySelector(`script[type="module"][src="/${contract.entryModule}"]`);
|
||||
const snapshot = document.querySelector('script#portfolio-inputs[type="application/json"]');
|
||||
if (moduleScript === null || snapshot === null ||
|
||||
(appRoot.compareDocumentPosition(moduleScript) & window.Node.DOCUMENT_POSITION_FOLLOWING) === 0 ||
|
||||
(snapshot.compareDocumentPosition(moduleScript) & window.Node.DOCUMENT_POSITION_FOLLOWING) === 0) {
|
||||
throw new Error('Connected visible semantic content and snapshot must precede the trusted module script.');
|
||||
}
|
||||
let parsedSnapshot;
|
||||
try {
|
||||
parsedSnapshot = JSON.parse(snapshot.textContent ?? '');
|
||||
} catch {
|
||||
throw new Error('The inert input snapshot is malformed.');
|
||||
}
|
||||
if (JSON.stringify(parsedSnapshot) !== JSON.stringify(inputs)) {
|
||||
throw new Error('Hydration snapshot does not match the server input bytes and meaning.');
|
||||
}
|
||||
} finally {
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -256,13 +352,34 @@ const assertCrawlArtifacts = async (root, inputs, headers) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyArtifactDirectory = async (root, inputs) => {
|
||||
const assertExactHostArtifacts = async (root, inputs) => {
|
||||
const expected = createStaticHostArtifacts(
|
||||
createPageMetadata(inputs.content, inputs.manifest, inputs.site),
|
||||
inputs.site,
|
||||
);
|
||||
const actualHeaders = await readRequiredText(root, '_headers.example');
|
||||
if (actualHeaders !== expected.headers) throw new Error('Headers must exactly equal the generated expected headers.');
|
||||
const actualRobots = await readRequiredText(root, 'robots.txt');
|
||||
if (actualRobots !== expected.robots) throw new Error('Robots must exactly equal the generated expected robots policy.');
|
||||
const actualRedirects = await readRequiredText(root, '_redirects.example');
|
||||
if (actualRedirects !== expected.redirects) throw new Error('Redirects must exactly equal the generated expected redirect example.');
|
||||
if (expected.sitemap !== undefined) {
|
||||
const actualSitemap = await readRequiredText(root, 'sitemap.xml');
|
||||
if (actualSitemap !== expected.sitemap) throw new Error('Sitemap must exactly equal the generated expected sitemap.');
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyArtifactDirectory = async (root, inputs, contractInput) => {
|
||||
const contract = parseArtifactContract(contractInput);
|
||||
const html = await readRequiredText(root, 'index.html');
|
||||
const files = await listArtifacts(root);
|
||||
assertArtifactInventory(files, html, inputs);
|
||||
assertArtifactInventory(files, inputs, contract);
|
||||
await assertArtifactDigests(root, contract);
|
||||
await assertNoProhibitedSurfaces(root, files);
|
||||
assertSemanticHtml(html, inputs);
|
||||
assertTrustedHtmlAssets(html, contract);
|
||||
assertSemanticHtml(html, inputs, contract);
|
||||
const serializedJsonLd = assertMetadata(html, inputs);
|
||||
await assertExactHostArtifacts(root, inputs);
|
||||
const headers = await readRequiredText(root, '_headers.example');
|
||||
assertSecurityHeaders(headers, inputs, serializedJsonLd);
|
||||
await assertCrawlArtifacts(root, inputs, headers);
|
||||
@@ -284,7 +401,14 @@ export const verifyArtifacts = async (configPath) => {
|
||||
preparedAssetRoot: pathToFileURL(`${resolve(repositoryRoot, 'inputs/prepared-assets')}${sep}`),
|
||||
siteConfigJson: pathToFileURL(configAbsolutePath),
|
||||
});
|
||||
await verifyArtifactDirectory(resolve(repositoryRoot, 'dist'), inputs);
|
||||
const authoritySource = await readRequiredText(resolve(repositoryRoot, 'dist-ssr'), 'artifact-authority.json');
|
||||
let authority;
|
||||
try {
|
||||
authority = parseBuildAuthority(JSON.parse(authoritySource));
|
||||
} catch (error) {
|
||||
throw new Error('Persisted build authority is invalid.', { cause: error });
|
||||
}
|
||||
await verifyArtifactDirectory(resolve(repositoryRoot, 'dist'), inputs, authority.artifactContract);
|
||||
};
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) {
|
||||
|
||||
Reference in New Issue
Block a user