feat: define secure static hosting contract
This commit is contained in:
42
nginx.portfolio.conf.example
Normal file
42
nginx.portfolio.conf.example
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Replace every double-underscore placeholder before validation or deployment.
|
||||||
|
# Generate the deployable file with scripts/generate-host-config.mjs; this file
|
||||||
|
# intentionally contains no real production hostname, certificate path, or root.
|
||||||
|
|
||||||
|
# Requires standard MIME mappings and static-file defaults in the parent Nginx http context.
|
||||||
|
max_ranges 1;
|
||||||
|
|
||||||
|
map $uri $portfolio_cache_control {
|
||||||
|
default "public, max-age=0, must-revalidate";
|
||||||
|
__EXACT_HASHED_ASSET_RULES__
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name __CANONICAL_HOST__ __ALTERNATE_HOSTS__;
|
||||||
|
return 308 https://__CANONICAL_HOST__$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name __CANONICAL_HOST__;
|
||||||
|
ssl_certificate __TLS_CERTIFICATE_PATH__;
|
||||||
|
ssl_certificate_key __TLS_KEY_PATH__;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
root __DOCUMENT_ROOT__;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' '__JSON_LD_CSP_HASH__'; 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 'self'; upgrade-insecure-requests" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Cache-Control $portfolio_cache_control always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||||
|
|
||||||
|
location = / { try_files /index.html =404; }
|
||||||
|
location = /index.html { try_files /index.html =404; }
|
||||||
|
location = /robots.txt { try_files /robots.txt =404; }
|
||||||
|
location = /sitemap.xml { try_files /sitemap.xml =404; }
|
||||||
|
location /assets/ { try_files $uri =404; }
|
||||||
|
location / { return 404; }
|
||||||
|
}
|
||||||
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 assertSecurityHeaders = (headers, inputs, serializedJsonLd) => {
|
||||||
const required = [
|
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'",
|
"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',
|
'Referrer-Policy: no-referrer', 'X-Frame-Options: DENY',
|
||||||
'Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
'Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
||||||
];
|
];
|
||||||
for (const value of required) {
|
for (const value of required) {
|
||||||
if (!headers.includes(value)) throw new Error(`Missing security header policy: ${value}`);
|
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 expectedHash = `sha256-${createHash('sha256').update(serializedJsonLd).digest('base64')}`;
|
||||||
const hashes = headers.match(/sha256-[A-Za-z0-9+/=]+/gu) ?? [];
|
const hashes = headers.match(/sha256-[A-Za-z0-9+/=]+/gu) ?? [];
|
||||||
if (hashes.length !== 1 || hashes[0] !== expectedHash) {
|
if (hashes.length !== 1 || hashes[0] !== expectedHash) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
SiteConfig,
|
SiteConfig,
|
||||||
} from './types.ts';
|
} from './types.ts';
|
||||||
import { serializeJsonLd } from '../security/serialize-json-ld.ts';
|
import { serializeJsonLd } from '../security/serialize-json-ld.ts';
|
||||||
|
import { createSecurityHeaders } from '../security/security-policy.ts';
|
||||||
|
|
||||||
interface PersonJsonLd {
|
interface PersonJsonLd {
|
||||||
'@type': 'Person';
|
'@type': 'Person';
|
||||||
@@ -108,28 +109,19 @@ export const createPageMetadata = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const contentSecurityPolicy = (jsonLdCspHash: string): string => [
|
const commonHeaders = (
|
||||||
"default-src 'self'",
|
metadata: Readonly<PageMetadata>,
|
||||||
`script-src 'self' '${jsonLdCspHash}'`,
|
site: Readonly<SiteConfig>,
|
||||||
"style-src 'self'",
|
): string[] => {
|
||||||
"img-src 'self'",
|
const headers = createSecurityHeaders(site, metadata.jsonLdCspHash);
|
||||||
"media-src 'self'",
|
return [
|
||||||
"font-src 'self'",
|
` Content-Security-Policy: ${headers.csp}`,
|
||||||
"connect-src 'none'",
|
` X-Content-Type-Options: ${headers.contentTypeOptions}`,
|
||||||
"object-src 'none'",
|
` Referrer-Policy: ${headers.referrerPolicy}`,
|
||||||
"base-uri 'none'",
|
` Permissions-Policy: ${headers.permissionsPolicy}`,
|
||||||
"frame-ancestors 'none'",
|
` X-Frame-Options: ${headers.frameOptions}`,
|
||||||
"form-action 'none'",
|
];
|
||||||
'upgrade-insecure-requests',
|
};
|
||||||
].join('; ');
|
|
||||||
|
|
||||||
const commonHeaders = (metadata: Readonly<PageMetadata>): string[] => [
|
|
||||||
` Content-Security-Policy: ${contentSecurityPolicy(metadata.jsonLdCspHash)}`,
|
|
||||||
' X-Content-Type-Options: nosniff',
|
|
||||||
' Referrer-Policy: no-referrer',
|
|
||||||
' Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
|
||||||
' X-Frame-Options: DENY',
|
|
||||||
];
|
|
||||||
|
|
||||||
export const createStaticHostArtifacts = (
|
export const createStaticHostArtifacts = (
|
||||||
metadata: Readonly<PageMetadata>,
|
metadata: Readonly<PageMetadata>,
|
||||||
@@ -140,7 +132,7 @@ export const createStaticHostArtifacts = (
|
|||||||
robots: 'User-agent: *\nDisallow: /\n',
|
robots: 'User-agent: *\nDisallow: /\n',
|
||||||
headers: [
|
headers: [
|
||||||
'/*',
|
'/*',
|
||||||
...commonHeaders(metadata),
|
...commonHeaders(metadata, site),
|
||||||
' Cache-Control: private, no-store',
|
' Cache-Control: private, no-store',
|
||||||
' X-Robots-Tag: noindex, nofollow, noarchive',
|
' X-Robots-Tag: noindex, nofollow, noarchive',
|
||||||
'',
|
'',
|
||||||
@@ -164,7 +156,7 @@ export const createStaticHostArtifacts = (
|
|||||||
' Cache-Control: public, max-age=31536000, immutable',
|
' Cache-Control: public, max-age=31536000, immutable',
|
||||||
'',
|
'',
|
||||||
'/*',
|
'/*',
|
||||||
...commonHeaders(metadata),
|
...commonHeaders(metadata, site),
|
||||||
' Cache-Control: public, max-age=0, must-revalidate',
|
' Cache-Control: public, max-age=0, must-revalidate',
|
||||||
' Strict-Transport-Security: max-age=31536000',
|
' Strict-Transport-Security: max-age=31536000',
|
||||||
'',
|
'',
|
||||||
|
|||||||
78
src/security/security-policy.ts
Normal file
78
src/security/security-policy.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { SiteConfig } from '../content/types.ts';
|
||||||
|
|
||||||
|
export const PERMISSIONS_POLICY =
|
||||||
|
'camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()' as const;
|
||||||
|
|
||||||
|
const jsonLdHashPattern = /^sha256-[A-Za-z0-9+/]{43}=$/u;
|
||||||
|
|
||||||
|
export interface SecurityHeaderMap {
|
||||||
|
csp: string;
|
||||||
|
contentTypeOptions: 'nosniff';
|
||||||
|
referrerPolicy: 'no-referrer';
|
||||||
|
permissionsPolicy: typeof PERMISSIONS_POLICY;
|
||||||
|
frameOptions: 'DENY';
|
||||||
|
cacheControl: 'private, no-store' | 'public, max-age=0, must-revalidate';
|
||||||
|
hsts?: 'max-age=31536000';
|
||||||
|
xRobotsTag?: 'noindex, nofollow, noarchive';
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertJsonLdCspHash = (value: string): void => {
|
||||||
|
if (!jsonLdHashPattern.test(value)) {
|
||||||
|
throw new Error('JSON-LD CSP hash must be one canonical SHA-256 base64 expression.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildContentSecurityPolicy = (jsonLdCspHash: string): string => {
|
||||||
|
assertJsonLdCspHash(jsonLdCspHash);
|
||||||
|
return [
|
||||||
|
"default-src 'self'",
|
||||||
|
`script-src 'self' '${jsonLdCspHash}'`,
|
||||||
|
"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 'self'",
|
||||||
|
'upgrade-insecure-requests',
|
||||||
|
].join('; ');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSecurityHeaders = (site: Readonly<SiteConfig>, jsonLdCspHash: string): SecurityHeaderMap => {
|
||||||
|
const common = {
|
||||||
|
csp: buildContentSecurityPolicy(jsonLdCspHash),
|
||||||
|
contentTypeOptions: 'nosniff' as const,
|
||||||
|
referrerPolicy: 'no-referrer' as const,
|
||||||
|
permissionsPolicy: PERMISSIONS_POLICY,
|
||||||
|
frameOptions: 'DENY' as const,
|
||||||
|
};
|
||||||
|
return site.environment === 'preview'
|
||||||
|
? {
|
||||||
|
...common,
|
||||||
|
cacheControl: 'private, no-store',
|
||||||
|
xRobotsTag: 'noindex, nofollow, noarchive',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...common,
|
||||||
|
cacheControl: 'public, max-age=0, must-revalidate',
|
||||||
|
hsts: 'max-age=31536000',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeStaticHostHeaders = (site: Readonly<SiteConfig>, jsonLdCspHash: string): string => {
|
||||||
|
const headers = createSecurityHeaders(site, jsonLdCspHash);
|
||||||
|
return [
|
||||||
|
'/*',
|
||||||
|
` Content-Security-Policy: ${headers.csp}`,
|
||||||
|
` X-Content-Type-Options: ${headers.contentTypeOptions}`,
|
||||||
|
` Referrer-Policy: ${headers.referrerPolicy}`,
|
||||||
|
` Permissions-Policy: ${headers.permissionsPolicy}`,
|
||||||
|
` X-Frame-Options: ${headers.frameOptions}`,
|
||||||
|
` Cache-Control: ${headers.cacheControl}`,
|
||||||
|
...(headers.hsts === undefined ? [] : [` Strict-Transport-Security: ${headers.hsts}`]),
|
||||||
|
...(headers.xRobotsTag === undefined ? [] : [` X-Robots-Tag: ${headers.xRobotsTag}`]),
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
};
|
||||||
155
tests/integration/nginx-config.test.ts
Normal file
155
tests/integration/nginx-config.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { SiteConfig } from '@/content/types';
|
||||||
|
import {
|
||||||
|
parseHashedArtifactManifest,
|
||||||
|
renderNginxConfig,
|
||||||
|
// @ts-expect-error The executable ESM build script intentionally has no publishable declaration file.
|
||||||
|
} from '../../scripts/generate-host-config.mjs';
|
||||||
|
|
||||||
|
const jsonLdHash = 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
||||||
|
const productionSite: SiteConfig = {
|
||||||
|
environment: 'production',
|
||||||
|
canonicalUrl: 'https://portfolio.example.test/',
|
||||||
|
buildId: 'release-1',
|
||||||
|
};
|
||||||
|
const previewSite: SiteConfig = {
|
||||||
|
environment: 'preview',
|
||||||
|
canonicalUrl: 'https://portfolio.localhost/',
|
||||||
|
buildId: 'preview-1',
|
||||||
|
};
|
||||||
|
const manifest = {
|
||||||
|
schema: 'portfolio-host-artifact-manifest/v1',
|
||||||
|
files: [
|
||||||
|
{ path: 'assets/app-a1b2c3d4.css', sha256: 'b'.repeat(64) },
|
||||||
|
{ path: 'assets/app-a1b2c3d4.js', sha256: 'a'.repeat(64) },
|
||||||
|
{ path: 'assets/demo-c3d4e5f6.webm', sha256: 'c'.repeat(64) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('generated Nginx static-host contract', () => {
|
||||||
|
it('binds immutable caching to the exact hashed artifact manifest', () => {
|
||||||
|
const config = renderNginxConfig({
|
||||||
|
site: productionSite,
|
||||||
|
jsonLdCspHash: jsonLdHash,
|
||||||
|
artifactManifest: manifest,
|
||||||
|
documentRoot: '/srv/portfolio/current',
|
||||||
|
alternateHosts: ['www.portfolio.example.test'],
|
||||||
|
tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem',
|
||||||
|
tlsKeyPath: '/etc/portfolio/tls/privkey.pem',
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const file of manifest.files) expect(config).toContain(`~^/${file.path.replaceAll('.', '\\.')}$`);
|
||||||
|
expect(config).toContain('public, max-age=31536000, immutable');
|
||||||
|
expect(config).toContain('public, max-age=0, must-revalidate');
|
||||||
|
expect(config).toContain(
|
||||||
|
'Requires standard MIME mappings and static-file defaults in the parent Nginx http context.',
|
||||||
|
);
|
||||||
|
expect(config).not.toContain('include /etc/nginx/mime.types;');
|
||||||
|
expect(config).not.toContain('default_type application/octet-stream;');
|
||||||
|
expect(config).not.toContain('sendfile on;');
|
||||||
|
expect(config).toContain('max_ranges 1;');
|
||||||
|
expect(config).not.toContain('max_ranges 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects HTTP and an alternate HTTPS host directly to the canonical HTTPS host', () => {
|
||||||
|
const config = renderNginxConfig({
|
||||||
|
site: productionSite,
|
||||||
|
jsonLdCspHash: jsonLdHash,
|
||||||
|
artifactManifest: manifest,
|
||||||
|
documentRoot: '/srv/portfolio/current',
|
||||||
|
alternateHosts: ['www.portfolio.example.test'],
|
||||||
|
tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem',
|
||||||
|
tlsKeyPath: '/etc/portfolio/tls/privkey.pem',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config).toContain('listen 80;');
|
||||||
|
expect(config).toContain('server_name portfolio.example.test www.portfolio.example.test;');
|
||||||
|
expect(config.match(/return 308 https:\/\/portfolio\.example\.test\$request_uri;/gu)).toHaveLength(2);
|
||||||
|
expect(config).not.toMatch(/return 30[127] /u);
|
||||||
|
expect(config).not.toContain('http://portfolio.example.test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies exact browser headers and production indexability without public source maps', () => {
|
||||||
|
const config = renderNginxConfig({
|
||||||
|
site: productionSite,
|
||||||
|
jsonLdCspHash: jsonLdHash,
|
||||||
|
artifactManifest: manifest,
|
||||||
|
documentRoot: '/srv/portfolio/current',
|
||||||
|
alternateHosts: [],
|
||||||
|
tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem',
|
||||||
|
tlsKeyPath: '/etc/portfolio/tls/privkey.pem',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config).toContain("default-src 'self'; script-src 'self' 'sha256-");
|
||||||
|
expect(config).toContain("img-src 'self' data:");
|
||||||
|
expect(config).toContain("connect-src 'none'");
|
||||||
|
expect(config).toContain('add_header Strict-Transport-Security "max-age=31536000" always;');
|
||||||
|
expect(config).not.toContain('includeSubDomains');
|
||||||
|
expect(config).not.toContain('X-Robots-Tag');
|
||||||
|
expect(config).not.toMatch(/unsafe-inline|unsafe-eval|sourceMappingURL|\.map(?:\s|$)/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes preview responses private and noindex without a sitemap or HSTS', () => {
|
||||||
|
const config = renderNginxConfig({
|
||||||
|
site: previewSite,
|
||||||
|
jsonLdCspHash: jsonLdHash,
|
||||||
|
artifactManifest: manifest,
|
||||||
|
documentRoot: '/srv/portfolio/preview',
|
||||||
|
alternateHosts: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config).toContain('add_header X-Robots-Tag "noindex, nofollow, noarchive" always;');
|
||||||
|
expect(config).toContain('private, no-store');
|
||||||
|
expect(config).not.toContain('Strict-Transport-Security');
|
||||||
|
expect(config).not.toContain('sitemap.xml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'a source map',
|
||||||
|
value: {
|
||||||
|
...manifest,
|
||||||
|
files: [{ path: 'assets/app-a1b2c3d4.js.map', sha256: 'a'.repeat(64) }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'an external or unsafe path',
|
||||||
|
value: {
|
||||||
|
...manifest,
|
||||||
|
files: [{ path: '../app-a1b2c3d4.js', sha256: 'a'.repeat(64) }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'a non-canonical digest',
|
||||||
|
value: {
|
||||||
|
...manifest,
|
||||||
|
files: [{ path: 'assets/app-a1b2c3d4.js', sha256: 'A'.repeat(64) }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])('rejects $name in the hashed artifact manifest', ({ value }) => {
|
||||||
|
expect(() => parseHashedArtifactManifest(value)).toThrow(/artifact manifest/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects document-root values that cannot be emitted as one Nginx token', () => {
|
||||||
|
expect(() =>
|
||||||
|
renderNginxConfig({
|
||||||
|
site: previewSite,
|
||||||
|
jsonLdCspHash: jsonLdHash,
|
||||||
|
artifactManifest: manifest,
|
||||||
|
documentRoot: '/srv/portfolio/injected root',
|
||||||
|
alternateHosts: [],
|
||||||
|
}),
|
||||||
|
).toThrow(/document root/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ships a domain-neutral example instead of inventing Luca’s production hostname', () => {
|
||||||
|
const example = readFileSync(resolve(process.cwd(), 'nginx.portfolio.conf.example'), 'utf8');
|
||||||
|
|
||||||
|
expect(example).toContain('__CANONICAL_HOST__');
|
||||||
|
expect(example).toContain('__JSON_LD_CSP_HASH__');
|
||||||
|
expect(example).not.toContain('portfolio.example.test');
|
||||||
|
expect(example).not.toMatch(/server_name\s+[a-z0-9.-]+\.[a-z]{2,}/iu);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -110,7 +110,7 @@ describe('environment-aware crawl and response artifacts', () => {
|
|||||||
' Cache-Control: public, max-age=31536000, immutable',
|
' Cache-Control: public, max-age=31536000, immutable',
|
||||||
'',
|
'',
|
||||||
'/*',
|
'/*',
|
||||||
` Content-Security-Policy: default-src 'self'; script-src 'self' '${metadata.jsonLdCspHash}'; style-src 'self'; img-src 'self'; media-src 'self'; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'; upgrade-insecure-requests`,
|
` Content-Security-Policy: default-src 'self'; script-src 'self' '${metadata.jsonLdCspHash}'; 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 'self'; upgrade-insecure-requests`,
|
||||||
' X-Content-Type-Options: nosniff',
|
' X-Content-Type-Options: nosniff',
|
||||||
' Referrer-Policy: no-referrer',
|
' Referrer-Policy: no-referrer',
|
||||||
' Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
' Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
||||||
|
|||||||
82
tests/unit/security-policy.test.ts
Normal file
82
tests/unit/security-policy.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildContentSecurityPolicy,
|
||||||
|
createSecurityHeaders,
|
||||||
|
serializeStaticHostHeaders,
|
||||||
|
} from '@/security/security-policy';
|
||||||
|
import type { SiteConfig } from '@/content/types';
|
||||||
|
|
||||||
|
const jsonLdHash = 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
||||||
|
const productionSite: SiteConfig = {
|
||||||
|
environment: 'production',
|
||||||
|
canonicalUrl: 'https://portfolio.example.test/',
|
||||||
|
buildId: 'release-1',
|
||||||
|
};
|
||||||
|
const previewSite: SiteConfig = {
|
||||||
|
environment: 'preview',
|
||||||
|
canonicalUrl: 'https://portfolio.localhost/',
|
||||||
|
buildId: 'preview-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('static-host security policy', () => {
|
||||||
|
it('builds the exact restrictive CSP around the serialized JSON-LD hash', () => {
|
||||||
|
expect(buildContentSecurityPolicy(jsonLdHash)).toBe(
|
||||||
|
[
|
||||||
|
"default-src 'self'",
|
||||||
|
`script-src 'self' '${jsonLdHash}'`,
|
||||||
|
"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 'self'",
|
||||||
|
'upgrade-insecure-requests',
|
||||||
|
].join('; '),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits production security and revalidation headers without widening HSTS', () => {
|
||||||
|
const headers = createSecurityHeaders(productionSite, jsonLdHash);
|
||||||
|
|
||||||
|
expect(headers).toEqual({
|
||||||
|
csp: buildContentSecurityPolicy(jsonLdHash),
|
||||||
|
contentTypeOptions: 'nosniff',
|
||||||
|
referrerPolicy: 'no-referrer',
|
||||||
|
permissionsPolicy:
|
||||||
|
'camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
||||||
|
frameOptions: 'DENY',
|
||||||
|
cacheControl: 'public, max-age=0, must-revalidate',
|
||||||
|
hsts: 'max-age=31536000',
|
||||||
|
});
|
||||||
|
expect(headers.hsts).not.toContain('includeSubDomains');
|
||||||
|
expect(headers).not.toHaveProperty('xRobotsTag');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps preview responses private, noindex, and outside HSTS policy', () => {
|
||||||
|
const headers = createSecurityHeaders(previewSite, jsonLdHash);
|
||||||
|
|
||||||
|
expect(headers.cacheControl).toBe('private, no-store');
|
||||||
|
expect(headers.xRobotsTag).toBe('noindex, nofollow, noarchive');
|
||||||
|
expect(headers).not.toHaveProperty('hsts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' 'unsafe-inline",
|
||||||
|
"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' *",
|
||||||
|
'sha256-not-base64!',
|
||||||
|
"'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='",
|
||||||
|
])('rejects an invalid or policy-injecting JSON-LD hash: %s', (value) => {
|
||||||
|
expect(() => buildContentSecurityPolicy(value)).toThrow(/JSON-LD CSP hash/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes one exact response-header template with no wildcard or external source expressions', () => {
|
||||||
|
const serialized = serializeStaticHostHeaders(productionSite, jsonLdHash);
|
||||||
|
|
||||||
|
expect(serialized).toContain(`Content-Security-Policy: ${buildContentSecurityPolicy(jsonLdHash)}`);
|
||||||
|
expect(serialized).toContain('Strict-Transport-Security: max-age=31536000');
|
||||||
|
expect(serialized).not.toMatch(/unsafe-inline|unsafe-eval|https?:\/\/|\*\.(?:js|css)/u);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user