feat: define secure static hosting contract
This commit is contained in:
259
scripts/generate-host-config.mjs
Normal file
259
scripts/generate-host-config.mjs
Normal file
@@ -0,0 +1,259 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { isAbsolute, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildContentSecurityPolicy, createSecurityHeaders } from '../src/security/security-policy.ts';
|
||||
import { parseSiteConfig } from '../src/content/schemas.ts';
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const safeAssetPath = /^assets\/[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
||||
const safeHost = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
|
||||
|
||||
const exactKeys = (value, keys) => JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...keys].sort());
|
||||
|
||||
export const parseHashedArtifactManifest = (input) => {
|
||||
if (
|
||||
typeof input !== 'object' ||
|
||||
input === null ||
|
||||
Array.isArray(input) ||
|
||||
!exactKeys(input, ['schema', 'files']) ||
|
||||
input.schema !== 'portfolio-host-artifact-manifest/v1' ||
|
||||
!Array.isArray(input.files)
|
||||
) {
|
||||
throw new Error('Hashed artifact manifest has an invalid envelope.');
|
||||
}
|
||||
const files = input.files.map((record) => {
|
||||
if (
|
||||
typeof record !== 'object' ||
|
||||
record === null ||
|
||||
Array.isArray(record) ||
|
||||
!exactKeys(record, ['path', 'sha256']) ||
|
||||
typeof record.path !== 'string' ||
|
||||
!safeAssetPath.test(record.path) ||
|
||||
record.path.endsWith('.map') ||
|
||||
typeof record.sha256 !== 'string' ||
|
||||
!sha256Pattern.test(record.sha256)
|
||||
) {
|
||||
throw new Error('Hashed artifact manifest contains an invalid file record.');
|
||||
}
|
||||
return { path: record.path, sha256: record.sha256 };
|
||||
});
|
||||
const paths = files.map(({ path }) => path);
|
||||
if (JSON.stringify(paths) !== JSON.stringify([...new Set(paths)].sort())) {
|
||||
throw new Error('Hashed artifact manifest files must be sorted and unique.');
|
||||
}
|
||||
return { schema: input.schema, files };
|
||||
};
|
||||
|
||||
const hostManifestFromInput = (input) => {
|
||||
if (input?.schema === 'portfolio-build-authority/v1') {
|
||||
return parseHashedArtifactManifest({
|
||||
schema: 'portfolio-host-artifact-manifest/v1',
|
||||
files: input.artifactContract?.files,
|
||||
});
|
||||
}
|
||||
if (input?.schema === 'portfolio-artifact-contract/v1') {
|
||||
return parseHashedArtifactManifest({
|
||||
schema: 'portfolio-host-artifact-manifest/v1',
|
||||
files: input.files,
|
||||
});
|
||||
}
|
||||
return parseHashedArtifactManifest(input);
|
||||
};
|
||||
|
||||
const assertSafeAbsolutePath = (value, label) => {
|
||||
if (typeof value !== 'string' || !isAbsolute(value) || /[\s;$`{}]/u.test(value)) {
|
||||
throw new Error(`${label} must be an explicit safe absolute path.`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertHost = (value, label) => {
|
||||
if (typeof value !== 'string' || !safeHost.test(value)) throw new Error(`${label} is invalid.`);
|
||||
};
|
||||
|
||||
const escapeNginxRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
||||
|
||||
const renderCacheMap = (site, files) => {
|
||||
if (site.environment === 'preview') {
|
||||
return ['map $uri $portfolio_cache_control {', ' default "private, no-store";', '}'].join('\n');
|
||||
}
|
||||
return [
|
||||
'map $uri $portfolio_cache_control {',
|
||||
' default "public, max-age=0, must-revalidate";',
|
||||
...files.map(({ path }) => ` ~^/${escapeNginxRegex(path)}$ "public, max-age=31536000, immutable";`),
|
||||
'}',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const renderAddHeaders = (site, jsonLdCspHash, indent = ' ') => {
|
||||
const headers = createSecurityHeaders(site, jsonLdCspHash);
|
||||
return [
|
||||
`${indent}add_header Content-Security-Policy "${headers.csp}" always;`,
|
||||
`${indent}add_header X-Content-Type-Options "${headers.contentTypeOptions}" always;`,
|
||||
`${indent}add_header Referrer-Policy "${headers.referrerPolicy}" always;`,
|
||||
`${indent}add_header Permissions-Policy "${headers.permissionsPolicy}" always;`,
|
||||
`${indent}add_header X-Frame-Options "${headers.frameOptions}" always;`,
|
||||
`${indent}add_header Cache-Control $portfolio_cache_control always;`,
|
||||
...(headers.hsts === undefined ? [] : [`${indent}add_header Strict-Transport-Security "${headers.hsts}" always;`]),
|
||||
...(headers.xRobotsTag === undefined ? [] : [`${indent}add_header X-Robots-Tag "${headers.xRobotsTag}" always;`]),
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const renderTls = (certificatePath, keyPath, indent = ' ') =>
|
||||
[
|
||||
`${indent}ssl_certificate ${certificatePath};`,
|
||||
`${indent}ssl_certificate_key ${keyPath};`,
|
||||
`${indent}ssl_protocols TLSv1.2 TLSv1.3;`,
|
||||
].join('\n');
|
||||
|
||||
export const renderNginxConfig = (options) => {
|
||||
const { site, jsonLdCspHash, documentRoot } = options;
|
||||
if (
|
||||
typeof site !== 'object' ||
|
||||
site === null ||
|
||||
!['preview', 'production'].includes(site.environment) ||
|
||||
typeof site.canonicalUrl !== 'string'
|
||||
) {
|
||||
throw new Error('Site config is invalid.');
|
||||
}
|
||||
const canonical = new URL(site.canonicalUrl);
|
||||
if (
|
||||
canonical.protocol !== 'https:' ||
|
||||
canonical.username !== '' ||
|
||||
canonical.password !== '' ||
|
||||
canonical.pathname !== '/' ||
|
||||
canonical.search !== '' ||
|
||||
canonical.hash !== '' ||
|
||||
canonical.port !== ''
|
||||
) {
|
||||
throw new Error('Canonical URL must be one root HTTPS origin.');
|
||||
}
|
||||
assertHost(canonical.hostname, 'Canonical host');
|
||||
assertSafeAbsolutePath(documentRoot, 'Document root');
|
||||
const artifactManifest = parseHashedArtifactManifest(options.artifactManifest);
|
||||
const alternateHosts = options.alternateHosts ?? [];
|
||||
if (!Array.isArray(alternateHosts)) throw new Error('Alternate hosts must be an array.');
|
||||
for (const host of alternateHosts) assertHost(host, 'Alternate host');
|
||||
const sortedAlternateHosts = [...new Set(alternateHosts)].sort();
|
||||
if (sortedAlternateHosts.length !== alternateHosts.length || sortedAlternateHosts.includes(canonical.hostname)) {
|
||||
throw new Error('Alternate hosts must be unique and distinct from the canonical host.');
|
||||
}
|
||||
buildContentSecurityPolicy(jsonLdCspHash);
|
||||
|
||||
const production = site.environment === 'production';
|
||||
if (production) {
|
||||
assertSafeAbsolutePath(options.tlsCertificatePath, 'TLS certificate path');
|
||||
assertSafeAbsolutePath(options.tlsKeyPath, 'TLS key path');
|
||||
} else if (options.tlsCertificatePath !== undefined || options.tlsKeyPath !== undefined) {
|
||||
throw new Error('Preview host generation does not accept production TLS material.');
|
||||
}
|
||||
|
||||
const allHosts = [canonical.hostname, ...sortedAlternateHosts].join(' ');
|
||||
const redirectTarget = `https://${canonical.hostname}$request_uri`;
|
||||
const redirectServers = production
|
||||
? [
|
||||
'server {',
|
||||
' listen 80;',
|
||||
` server_name ${allHosts};`,
|
||||
` return 308 ${redirectTarget};`,
|
||||
'}',
|
||||
...(sortedAlternateHosts.length === 0
|
||||
? []
|
||||
: [
|
||||
'',
|
||||
'server {',
|
||||
' listen 443 ssl;',
|
||||
` server_name ${sortedAlternateHosts.join(' ')};`,
|
||||
renderTls(options.tlsCertificatePath, options.tlsKeyPath),
|
||||
` return 308 ${redirectTarget};`,
|
||||
'}',
|
||||
]),
|
||||
'',
|
||||
].join('\n')
|
||||
: '';
|
||||
|
||||
const sitemapLocation = production
|
||||
? [' location = /sitemap.xml {', ' try_files /sitemap.xml =404;', ' }'].join('\n')
|
||||
: '';
|
||||
|
||||
return [
|
||||
'# Generated by scripts/generate-host-config.mjs. Do not edit.',
|
||||
'# Requires standard MIME mappings and static-file defaults in the parent Nginx http context.',
|
||||
'max_ranges 1;',
|
||||
renderCacheMap(site, artifactManifest.files),
|
||||
'',
|
||||
redirectServers,
|
||||
'server {',
|
||||
production ? ' listen 443 ssl;' : ' listen 8080;',
|
||||
` server_name ${canonical.hostname};`,
|
||||
...(production ? [renderTls(options.tlsCertificatePath, options.tlsKeyPath)] : []),
|
||||
` root ${documentRoot};`,
|
||||
' index index.html;',
|
||||
renderAddHeaders(site, jsonLdCspHash),
|
||||
'',
|
||||
' location = / {',
|
||||
' try_files /index.html =404;',
|
||||
' }',
|
||||
' location = /index.html {',
|
||||
' try_files /index.html =404;',
|
||||
' }',
|
||||
' location = /robots.txt {',
|
||||
' try_files /robots.txt =404;',
|
||||
' }',
|
||||
...(sitemapLocation === '' ? [] : [sitemapLocation]),
|
||||
' location /assets/ {',
|
||||
' try_files $uri =404;',
|
||||
' }',
|
||||
' location / {',
|
||||
' return 404;',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
]
|
||||
.filter((line, index, all) => !(line === '' && all[index - 1] === ''))
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const parseArguments = (arguments_) => {
|
||||
const values = new Map();
|
||||
const alternateHosts = [];
|
||||
for (let index = 0; index < arguments_.length; index += 2) {
|
||||
const key = arguments_[index];
|
||||
const value = arguments_[index + 1];
|
||||
if (typeof key !== 'string' || !key.startsWith('--') || value === undefined) {
|
||||
throw new Error('Host config arguments must be exact --name value pairs.');
|
||||
}
|
||||
if (key === '--alternate-host') alternateHosts.push(value);
|
||||
else if (values.has(key)) throw new Error(`Duplicate host config argument: ${key}`);
|
||||
else values.set(key, value);
|
||||
}
|
||||
const required = ['--config', '--json-ld-hash', '--artifact-manifest', '--document-root'];
|
||||
for (const key of required) if (!values.has(key)) throw new Error(`Missing host config argument: ${key}`);
|
||||
const allowed = new Set([...required, '--output', '--tls-certificate', '--tls-key']);
|
||||
for (const key of values.keys()) if (!allowed.has(key)) throw new Error(`Unknown host config argument: ${key}`);
|
||||
return { values, alternateHosts };
|
||||
};
|
||||
|
||||
export const generateHostConfig = async (arguments_) => {
|
||||
const { values, alternateHosts } = parseArguments(arguments_);
|
||||
const readJson = async (path) => JSON.parse(await readFile(resolve(path), 'utf8'));
|
||||
const site = parseSiteConfig(await readJson(values.get('--config')));
|
||||
const artifactManifest = hostManifestFromInput(await readJson(values.get('--artifact-manifest')));
|
||||
const config = renderNginxConfig({
|
||||
site,
|
||||
jsonLdCspHash: values.get('--json-ld-hash'),
|
||||
artifactManifest,
|
||||
documentRoot: values.get('--document-root'),
|
||||
alternateHosts,
|
||||
tlsCertificatePath: values.get('--tls-certificate'),
|
||||
tlsKeyPath: values.get('--tls-key'),
|
||||
});
|
||||
const output = values.get('--output');
|
||||
if (output === undefined) process.stdout.write(config);
|
||||
else await writeFile(resolve(output), config, { flag: 'wx' });
|
||||
return config;
|
||||
};
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) {
|
||||
await generateHostConfig(process.argv.slice(2));
|
||||
}
|
||||
@@ -311,15 +311,18 @@ const assertMetadata = (html, inputs) => {
|
||||
|
||||
const assertSecurityHeaders = (headers, inputs, serializedJsonLd) => {
|
||||
const required = [
|
||||
"default-src 'self'", "script-src 'self'", "style-src 'self'", "img-src 'self'", "media-src 'self'",
|
||||
"default-src 'self'", "script-src 'self'", "style-src 'self'", "img-src 'self' data:", "media-src 'self'",
|
||||
"font-src 'self'", "connect-src 'none'", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'",
|
||||
"form-action 'none'", 'upgrade-insecure-requests', 'X-Content-Type-Options: nosniff',
|
||||
"form-action 'self'", 'upgrade-insecure-requests', 'X-Content-Type-Options: nosniff',
|
||||
'Referrer-Policy: no-referrer', 'X-Frame-Options: DENY',
|
||||
'Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
||||
];
|
||||
for (const value of required) {
|
||||
if (!headers.includes(value)) throw new Error(`Missing security header policy: ${value}`);
|
||||
}
|
||||
if (/unsafe-inline|unsafe-eval|(?:^|[;\s])\*(?:[;\s]|$)|https?:\/\//iu.test(headers)) {
|
||||
throw new Error('Security header policy contains an unsafe or external source expression.');
|
||||
}
|
||||
const expectedHash = `sha256-${createHash('sha256').update(serializedJsonLd).digest('base64')}`;
|
||||
const hashes = headers.match(/sha256-[A-Za-z0-9+/=]+/gu) ?? [];
|
||||
if (hashes.length !== 1 || hashes[0] !== expectedHash) {
|
||||
|
||||
Reference in New Issue
Block a user