diff --git a/nginx.portfolio.conf.example b/nginx.portfolio.conf.example new file mode 100644 index 0000000..ae8bdf6 --- /dev/null +++ b/nginx.portfolio.conf.example @@ -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; } +} diff --git a/scripts/generate-host-config.mjs b/scripts/generate-host-config.mjs new file mode 100644 index 0000000..0b68bce --- /dev/null +++ b/scripts/generate-host-config.mjs @@ -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)); +} diff --git a/scripts/verify-artifacts.mjs b/scripts/verify-artifacts.mjs index 2a360e0..ab6562d 100644 --- a/scripts/verify-artifacts.mjs +++ b/scripts/verify-artifacts.mjs @@ -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) { diff --git a/src/content/metadata.ts b/src/content/metadata.ts index 66c96e3..f614397 100644 --- a/src/content/metadata.ts +++ b/src/content/metadata.ts @@ -5,6 +5,7 @@ import type { SiteConfig, } from './types.ts'; import { serializeJsonLd } from '../security/serialize-json-ld.ts'; +import { createSecurityHeaders } from '../security/security-policy.ts'; interface PersonJsonLd { '@type': 'Person'; @@ -108,28 +109,19 @@ export const createPageMetadata = ( }; }; -const contentSecurityPolicy = (jsonLdCspHash: string): string => [ - "default-src 'self'", - `script-src 'self' '${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', -].join('; '); - -const commonHeaders = (metadata: Readonly): 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', -]; +const commonHeaders = ( + metadata: Readonly, + site: Readonly, +): string[] => { + const headers = createSecurityHeaders(site, metadata.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}`, + ]; +}; export const createStaticHostArtifacts = ( metadata: Readonly, @@ -140,7 +132,7 @@ export const createStaticHostArtifacts = ( robots: 'User-agent: *\nDisallow: /\n', headers: [ '/*', - ...commonHeaders(metadata), + ...commonHeaders(metadata, site), ' Cache-Control: private, no-store', ' X-Robots-Tag: noindex, nofollow, noarchive', '', @@ -164,7 +156,7 @@ export const createStaticHostArtifacts = ( ' Cache-Control: public, max-age=31536000, immutable', '', '/*', - ...commonHeaders(metadata), + ...commonHeaders(metadata, site), ' Cache-Control: public, max-age=0, must-revalidate', ' Strict-Transport-Security: max-age=31536000', '', diff --git a/src/security/security-policy.ts b/src/security/security-policy.ts new file mode 100644 index 0000000..1887f65 --- /dev/null +++ b/src/security/security-policy.ts @@ -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, 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, 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'); +}; diff --git a/tests/integration/nginx-config.test.ts b/tests/integration/nginx-config.test.ts new file mode 100644 index 0000000..6421ead --- /dev/null +++ b/tests/integration/nginx-config.test.ts @@ -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); + }); +}); diff --git a/tests/unit/metadata.test.ts b/tests/unit/metadata.test.ts index d3babc9..97b5c97 100644 --- a/tests/unit/metadata.test.ts +++ b/tests/unit/metadata.test.ts @@ -110,7 +110,7 @@ describe('environment-aware crawl and response artifacts', () => { ' 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', ' Referrer-Policy: no-referrer', ' Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()', diff --git a/tests/unit/security-policy.test.ts b/tests/unit/security-policy.test.ts new file mode 100644 index 0000000..2b7c35a --- /dev/null +++ b/tests/unit/security-policy.test.ts @@ -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); + }); +});