fix: harden prerender publication boundary
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
@@ -31,6 +32,9 @@ const template = `<!doctype html><html lang="en"><head><meta charset="UTF-8" />
|
||||
<script type="module" crossorigin src="/assets/index-test.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-test.css"></head><body><div id="app"></div>
|
||||
<!--portfolio-body--></body></html>`;
|
||||
const clientJavaScript = 'export {};\n';
|
||||
const clientStylesheet = 'body { margin: 0; }\n';
|
||||
const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
afterEach(async () => {
|
||||
@@ -45,25 +49,39 @@ const writeValidArtifacts = async (site?: SiteConfig) => {
|
||||
await mkdir(join(directory, 'assets'));
|
||||
const html = assemblePrerenderedHtml(template, manifest, await render('/', inputs), inputs);
|
||||
const hostArtifacts = createStaticHostArtifacts(createPageMetadata(inputs.content, inputs.manifest, inputs.site), inputs.site);
|
||||
const contract = {
|
||||
schema: 'portfolio-artifact-contract/v1',
|
||||
buildId: inputs.site.buildId,
|
||||
entryModule: 'assets/index-test.js',
|
||||
executableModules: ['assets/index-test.js'],
|
||||
modulePreloads: ['assets/index-test.js'],
|
||||
styles: ['assets/index-test.css'],
|
||||
viteAssets: [],
|
||||
publicAssets: [],
|
||||
files: [
|
||||
{ path: 'assets/index-test.css', sha256: sha256(clientStylesheet) },
|
||||
{ path: 'assets/index-test.js', sha256: sha256(clientJavaScript) },
|
||||
],
|
||||
} as const;
|
||||
await Promise.all([
|
||||
writeFile(join(directory, 'index.html'), html),
|
||||
writeFile(join(directory, 'robots.txt'), hostArtifacts.robots),
|
||||
writeFile(join(directory, '_headers.example'), hostArtifacts.headers),
|
||||
writeFile(join(directory, '_redirects.example'), hostArtifacts.redirects),
|
||||
writeFile(join(directory, 'assets/index-test.js'), 'export {};\n'),
|
||||
writeFile(join(directory, 'assets/index-test.css'), 'body { margin: 0; }\n'),
|
||||
writeFile(join(directory, 'assets/index-test.js'), clientJavaScript),
|
||||
writeFile(join(directory, 'assets/index-test.css'), clientStylesheet),
|
||||
...(hostArtifacts.sitemap === undefined
|
||||
? []
|
||||
: [writeFile(join(directory, 'sitemap.xml'), hostArtifacts.sitemap)]),
|
||||
]);
|
||||
return { directory, html, inputs };
|
||||
return { directory, html, inputs, contract };
|
||||
};
|
||||
|
||||
describe('artifact verification', () => {
|
||||
it('accepts the complete private preview artifact set', async () => {
|
||||
const { directory, inputs } = await writeValidArtifacts();
|
||||
const { directory, inputs, contract } = await writeValidArtifacts();
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs)).resolves.toBeUndefined();
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts the pure production artifact path with canonical sitemap and allow policy', async () => {
|
||||
@@ -72,9 +90,9 @@ describe('artifact verification', () => {
|
||||
canonicalUrl: 'https://portfolio.example.test/',
|
||||
buildId: 'production-test',
|
||||
};
|
||||
const { directory, inputs } = await writeValidArtifacts(productionSite);
|
||||
const { directory, inputs, contract } = await writeValidArtifacts(productionSite);
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs)).resolves.toBeUndefined();
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -96,13 +114,13 @@ describe('artifact verification', () => {
|
||||
{
|
||||
name: 'a prohibited private surface',
|
||||
mutate: async (directory: string) => writeFile(join(directory, 'assets/index-test.js'), 'const evidenceId = "fixture";'),
|
||||
error: /prohibited surface/i,
|
||||
error: /prohibited surface|digest does not match/i,
|
||||
},
|
||||
])('rejects $name', async ({ mutate, error }) => {
|
||||
const { directory, inputs } = await writeValidArtifacts();
|
||||
const { directory, inputs, contract } = await writeValidArtifacts();
|
||||
await mutate(directory);
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(error);
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -114,7 +132,7 @@ describe('artifact verification', () => {
|
||||
{
|
||||
name: 'duplicate H1 identity',
|
||||
mutate: (html: string) => html.replace('</main>', '<h1>Luca Pîslaru</h1></main>'),
|
||||
error: /exactly one h1/i,
|
||||
error: /exactly one (?:visible )?h1/i,
|
||||
},
|
||||
{
|
||||
name: 'malformed JSON-LD',
|
||||
@@ -124,7 +142,7 @@ describe('artifact verification', () => {
|
||||
{
|
||||
name: 'missing infrastructure evidence',
|
||||
mutate: (html: string) => html.replace('id="infrastructure"', 'id="removed-infrastructure"'),
|
||||
error: /section.*infrastructure/i,
|
||||
error: /section.*infrastructure|visible sections/i,
|
||||
},
|
||||
{
|
||||
name: 'executable snapshot assignment',
|
||||
@@ -132,25 +150,145 @@ describe('artifact verification', () => {
|
||||
error: /inert input snapshot|prohibited surface/i,
|
||||
},
|
||||
])('rejects raw HTML with $name', async ({ mutate, error }) => {
|
||||
const { directory, html, inputs } = await writeValidArtifacts();
|
||||
const { directory, html, inputs, contract } = await writeValidArtifacts();
|
||||
await writeFile(join(directory, 'index.html'), mutate(html));
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(error);
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
it('rejects a missing crawl artifact', async () => {
|
||||
const { directory, inputs } = await writeValidArtifacts();
|
||||
const { directory, inputs, contract } = await writeValidArtifacts();
|
||||
await rm(join(directory, 'robots.txt'));
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(/robots/i);
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/robots/i);
|
||||
});
|
||||
|
||||
it('checks the CSP hash against the actual serialized JSON-LD bytes', async () => {
|
||||
const { directory, inputs } = await writeValidArtifacts();
|
||||
const { directory, inputs, contract } = await writeValidArtifacts();
|
||||
const headersPath = join(directory, '_headers.example');
|
||||
const headers = await readFile(headersPath, 'utf8');
|
||||
await writeFile(headersPath, headers.replace(/sha256-[A-Za-z0-9+/=]+/u, 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='));
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(/csp.*json-ld|json-ld.*csp/i);
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/csp.*json-ld|json-ld.*csp|exact.*headers/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'a final-HTML-authorized substitute module',
|
||||
mutate: async (directory: string, html: string) => {
|
||||
await rm(join(directory, 'assets/index-test.js'));
|
||||
await writeFile(join(directory, 'assets/substitute.js'), clientJavaScript);
|
||||
return html.replaceAll('assets/index-test.js', 'assets/substitute.js');
|
||||
},
|
||||
error: /trusted.*module|artifact contract|unexpected artifact/i,
|
||||
},
|
||||
{
|
||||
name: 'an extra inline executable script',
|
||||
mutate: async (_directory: string, html: string) => html.replace('</body>', '<script>void 0</script></body>'),
|
||||
error: /unexpected executable script/i,
|
||||
},
|
||||
{
|
||||
name: 'an extra external executable script',
|
||||
mutate: async (_directory: string, html: string) => html.replace('</body>', '<script src="https://example.test/evil.js"></script></body>'),
|
||||
error: /unexpected executable script|external executable/i,
|
||||
},
|
||||
{
|
||||
name: 'a final-HTML-authorized extra stylesheet',
|
||||
mutate: async (directory: string, html: string) => {
|
||||
await writeFile(join(directory, 'assets/extra.css'), 'body {}');
|
||||
return html.replace('</head>', '<link rel="stylesheet" href="/assets/extra.css" /></head>');
|
||||
},
|
||||
error: /trusted.*style|artifact contract|unexpected artifact/i,
|
||||
},
|
||||
{
|
||||
name: 'a missing trusted module preload',
|
||||
mutate: async (_directory: string, html: string) => html.replace(/<link rel="modulepreload"[^>]+>\n?/u, ''),
|
||||
error: /trusted.*preload|module preload/i,
|
||||
},
|
||||
])('rejects $name instead of trusting final HTML', async ({ mutate, error }) => {
|
||||
const { directory, html, inputs, contract } = await writeValidArtifacts();
|
||||
await writeFile(join(directory, 'index.html'), await mutate(directory, html));
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'an appended CSP directive',
|
||||
mutate: (headers: string) => `${headers} script-src https://example.test\n`,
|
||||
},
|
||||
{
|
||||
name: 'an external script-src relaxation',
|
||||
mutate: (headers: string) => headers.replace("script-src 'self'", "script-src 'self' https://example.test"),
|
||||
},
|
||||
{
|
||||
name: 'a duplicate partial CSP policy',
|
||||
mutate: (headers: string) => `${headers} Content-Security-Policy: default-src 'self'\n`,
|
||||
},
|
||||
{
|
||||
name: 'commented header impostors',
|
||||
mutate: (headers: string) => headers.split('\n').map((line) => `# ${line}`).join('\n'),
|
||||
},
|
||||
])('rejects $name by exact header bytes', async ({ mutate }) => {
|
||||
const { directory, inputs, contract } = await writeValidArtifacts();
|
||||
const path = join(directory, '_headers.example');
|
||||
await writeFile(path, mutate(await readFile(path, 'utf8')));
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/exact.*headers|headers.*exact/i);
|
||||
});
|
||||
|
||||
it('rejects modified redirect example bytes', async () => {
|
||||
const { directory, inputs, contract } = await writeValidArtifacts();
|
||||
const path = join(directory, '_redirects.example');
|
||||
await writeFile(path, `${await readFile(path, 'utf8')}# appended\n`);
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/exact.*redirect|redirect.*exact/i);
|
||||
});
|
||||
|
||||
it('rejects modified production sitemap bytes', async () => {
|
||||
const productionSite: SiteConfig = {
|
||||
environment: 'production',
|
||||
canonicalUrl: 'https://portfolio.example.test/',
|
||||
buildId: 'production-test',
|
||||
};
|
||||
const { directory, inputs, contract } = await writeValidArtifacts(productionSite);
|
||||
const path = join(directory, 'sitemap.xml');
|
||||
await writeFile(path, `${await readFile(path, 'utf8')}<!-- appended -->\n`);
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/exact.*sitemap|sitemap.*exact/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'a hidden application root',
|
||||
mutate: (html: string) => html.replace('<div id="app">', '<div id="app" hidden>'),
|
||||
},
|
||||
{
|
||||
name: 'identity rendered only inside a template',
|
||||
mutate: (html: string) => html.replace(
|
||||
'<h1>Luca Pîslaru</h1><p>System Administrator & Full-Stack Developer</p>',
|
||||
'<template><h1>Luca Pîslaru</h1><p>System Administrator & Full-Stack Developer</p></template>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'an aria-hidden native navigation',
|
||||
mutate: (html: string) => html.replace('<nav class="site-navigation"', '<nav aria-hidden="true" class="site-navigation"'),
|
||||
},
|
||||
{
|
||||
name: 'an inert canonical section',
|
||||
mutate: (html: string) => html.replace('<section id="infrastructure"', '<section id="infrastructure" inert'),
|
||||
},
|
||||
{
|
||||
name: 'infrastructure evidence present only in hydration JSON',
|
||||
mutate: (html: string) => {
|
||||
const snapshotAt = html.indexOf('<script id="portfolio-inputs"');
|
||||
return `${html.slice(0, snapshotAt).replaceAll('Secure self-hosted platform and lifecycle', 'Removed evidence')}${html.slice(snapshotAt)}`;
|
||||
},
|
||||
},
|
||||
])('rejects $name as non-visible semantic evidence', async ({ mutate }) => {
|
||||
const { directory, html, inputs, contract } = await writeValidArtifacts();
|
||||
await writeFile(join(directory, 'index.html'), mutate(html));
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/visible semantic|visible h1|visible navigation|visible section|connected/i);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user