From 259f52a02b455be5e51a7b9a1de9856a5eae6e62 Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 11:05:34 +0300 Subject: [PATCH] fix: align nginx with production smoke gate --- nginx.portfolio.conf.example | 6 ++++++ scripts/generate-host-config.mjs | 21 ++++++++++++++++++--- tests/integration/nginx-config.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/nginx.portfolio.conf.example b/nginx.portfolio.conf.example index ae8bdf6..8c57b78 100644 --- a/nginx.portfolio.conf.example +++ b/nginx.portfolio.conf.example @@ -25,6 +25,11 @@ server { root __DOCUMENT_ROOT__; index index.html; + gzip on; + gzip_vary on; + gzip_min_length 1; + gzip_types text/css application/javascript application/json image/svg+xml; + 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; @@ -32,6 +37,7 @@ server { add_header X-Frame-Options "DENY" always; add_header Cache-Control $portfolio_cache_control always; add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Portfolio-Manifest-Digest "__MANIFEST_DIGEST__" always; location = / { try_files /index.html =404; } location = /index.html { try_files /index.html =404; } diff --git a/scripts/generate-host-config.mjs b/scripts/generate-host-config.mjs index 0b68bce..9212b7f 100644 --- a/scripts/generate-host-config.mjs +++ b/scripts/generate-host-config.mjs @@ -85,7 +85,7 @@ const renderCacheMap = (site, files) => { ].join('\n'); }; -const renderAddHeaders = (site, jsonLdCspHash, indent = ' ') => { +const renderAddHeaders = (site, jsonLdCspHash, manifestDigest, indent = ' ') => { const headers = createSecurityHeaders(site, jsonLdCspHash); return [ `${indent}add_header Content-Security-Policy "${headers.csp}" always;`, @@ -94,6 +94,9 @@ const renderAddHeaders = (site, jsonLdCspHash, indent = ' ') => { `${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;`, + ...(site.environment === 'production' + ? [`${indent}add_header X-Portfolio-Manifest-Digest "${manifestDigest}" 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'); @@ -142,6 +145,9 @@ export const renderNginxConfig = (options) => { const production = site.environment === 'production'; if (production) { + if (typeof options.manifestDigest !== 'string' || !sha256Pattern.test(options.manifestDigest)) { + throw new Error('Production manifest digest must be a lowercase SHA-256 digest.'); + } assertSafeAbsolutePath(options.tlsCertificatePath, 'TLS certificate path'); assertSafeAbsolutePath(options.tlsKeyPath, 'TLS key path'); } else if (options.tlsCertificatePath !== undefined || options.tlsKeyPath !== undefined) { @@ -189,7 +195,15 @@ export const renderNginxConfig = (options) => { ...(production ? [renderTls(options.tlsCertificatePath, options.tlsKeyPath)] : []), ` root ${documentRoot};`, ' index index.html;', - renderAddHeaders(site, jsonLdCspHash), + ...(production + ? [ + ' gzip on;', + ' gzip_vary on;', + ' gzip_min_length 1;', + ' gzip_types text/css application/javascript application/json image/svg+xml;', + ] + : []), + renderAddHeaders(site, jsonLdCspHash, options.manifestDigest), '', ' location = / {', ' try_files /index.html =404;', @@ -229,7 +243,7 @@ const parseArguments = (arguments_) => { } 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']); + const allowed = new Set([...required, '--output', '--tls-certificate', '--tls-key', '--manifest-digest']); for (const key of values.keys()) if (!allowed.has(key)) throw new Error(`Unknown host config argument: ${key}`); return { values, alternateHosts }; }; @@ -247,6 +261,7 @@ export const generateHostConfig = async (arguments_) => { alternateHosts, tlsCertificatePath: values.get('--tls-certificate'), tlsKeyPath: values.get('--tls-key'), + manifestDigest: values.get('--manifest-digest'), }); const output = values.get('--output'); if (output === undefined) process.stdout.write(config); diff --git a/tests/integration/nginx-config.test.ts b/tests/integration/nginx-config.test.ts index 6421ead..bd6a92a 100644 --- a/tests/integration/nginx-config.test.ts +++ b/tests/integration/nginx-config.test.ts @@ -9,6 +9,7 @@ import { } from '../../scripts/generate-host-config.mjs'; const jsonLdHash = 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; +const releaseManifestDigest = 'd'.repeat(64); const productionSite: SiteConfig = { environment: 'production', canonicalUrl: 'https://portfolio.example.test/', @@ -38,6 +39,7 @@ describe('generated Nginx static-host contract', () => { alternateHosts: ['www.portfolio.example.test'], tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem', tlsKeyPath: '/etc/portfolio/tls/privkey.pem', + manifestDigest: releaseManifestDigest, }); for (const file of manifest.files) expect(config).toContain(`~^/${file.path.replaceAll('.', '\\.')}$`); @@ -62,6 +64,7 @@ describe('generated Nginx static-host contract', () => { alternateHosts: ['www.portfolio.example.test'], tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem', tlsKeyPath: '/etc/portfolio/tls/privkey.pem', + manifestDigest: releaseManifestDigest, }); expect(config).toContain('listen 80;'); @@ -80,12 +83,18 @@ describe('generated Nginx static-host contract', () => { alternateHosts: [], tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem', tlsKeyPath: '/etc/portfolio/tls/privkey.pem', + manifestDigest: releaseManifestDigest, }); 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).toContain(`add_header X-Portfolio-Manifest-Digest "${releaseManifestDigest}" always;`); + expect(config).toContain('gzip on;'); + expect(config).toContain('gzip_min_length 1;'); + expect(config).toContain('gzip_vary on;'); + expect(config).toContain('gzip_types text/css application/javascript application/json image/svg+xml;'); expect(config).not.toContain('includeSubDomains'); expect(config).not.toContain('X-Robots-Tag'); expect(config).not.toMatch(/unsafe-inline|unsafe-eval|sourceMappingURL|\.map(?:\s|$)/u); @@ -104,6 +113,19 @@ describe('generated Nginx static-host contract', () => { expect(config).toContain('private, no-store'); expect(config).not.toContain('Strict-Transport-Security'); expect(config).not.toContain('sitemap.xml'); + expect(config).not.toContain('X-Portfolio-Manifest-Digest'); + }); + + it('rejects production configuration without the signed manifest digest', () => { + expect(() => renderNginxConfig({ + site: productionSite, + jsonLdCspHash: jsonLdHash, + artifactManifest: manifest, + documentRoot: '/srv/portfolio/current', + alternateHosts: [], + tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem', + tlsKeyPath: '/etc/portfolio/tls/privkey.pem', + })).toThrow(/manifest digest/i); }); it.each([ @@ -149,6 +171,7 @@ describe('generated Nginx static-host contract', () => { expect(example).toContain('__CANONICAL_HOST__'); expect(example).toContain('__JSON_LD_CSP_HASH__'); + expect(example).toContain('__MANIFEST_DIGEST__'); expect(example).not.toContain('portfolio.example.test'); expect(example).not.toMatch(/server_name\s+[a-z0-9.-]+\.[a-z]{2,}/iu); });