fix: align nginx with production smoke gate
This commit is contained in:
@@ -25,6 +25,11 @@ server {
|
|||||||
root __DOCUMENT_ROOT__;
|
root __DOCUMENT_ROOT__;
|
||||||
index index.html;
|
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 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 X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "no-referrer" always;
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
@@ -32,6 +37,7 @@ server {
|
|||||||
add_header X-Frame-Options "DENY" always;
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header Cache-Control $portfolio_cache_control always;
|
add_header Cache-Control $portfolio_cache_control always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000" 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 = / { try_files /index.html =404; }
|
||||||
location = /index.html { try_files /index.html =404; }
|
location = /index.html { try_files /index.html =404; }
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const renderCacheMap = (site, files) => {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAddHeaders = (site, jsonLdCspHash, indent = ' ') => {
|
const renderAddHeaders = (site, jsonLdCspHash, manifestDigest, indent = ' ') => {
|
||||||
const headers = createSecurityHeaders(site, jsonLdCspHash);
|
const headers = createSecurityHeaders(site, jsonLdCspHash);
|
||||||
return [
|
return [
|
||||||
`${indent}add_header Content-Security-Policy "${headers.csp}" always;`,
|
`${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 Permissions-Policy "${headers.permissionsPolicy}" always;`,
|
||||||
`${indent}add_header X-Frame-Options "${headers.frameOptions}" always;`,
|
`${indent}add_header X-Frame-Options "${headers.frameOptions}" always;`,
|
||||||
`${indent}add_header Cache-Control $portfolio_cache_control 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.hsts === undefined ? [] : [`${indent}add_header Strict-Transport-Security "${headers.hsts}" always;`]),
|
||||||
...(headers.xRobotsTag === undefined ? [] : [`${indent}add_header X-Robots-Tag "${headers.xRobotsTag}" always;`]),
|
...(headers.xRobotsTag === undefined ? [] : [`${indent}add_header X-Robots-Tag "${headers.xRobotsTag}" always;`]),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
@@ -142,6 +145,9 @@ export const renderNginxConfig = (options) => {
|
|||||||
|
|
||||||
const production = site.environment === 'production';
|
const production = site.environment === 'production';
|
||||||
if (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.tlsCertificatePath, 'TLS certificate path');
|
||||||
assertSafeAbsolutePath(options.tlsKeyPath, 'TLS key path');
|
assertSafeAbsolutePath(options.tlsKeyPath, 'TLS key path');
|
||||||
} else if (options.tlsCertificatePath !== undefined || options.tlsKeyPath !== undefined) {
|
} else if (options.tlsCertificatePath !== undefined || options.tlsKeyPath !== undefined) {
|
||||||
@@ -189,7 +195,15 @@ export const renderNginxConfig = (options) => {
|
|||||||
...(production ? [renderTls(options.tlsCertificatePath, options.tlsKeyPath)] : []),
|
...(production ? [renderTls(options.tlsCertificatePath, options.tlsKeyPath)] : []),
|
||||||
` root ${documentRoot};`,
|
` root ${documentRoot};`,
|
||||||
' index index.html;',
|
' 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 = / {',
|
' location = / {',
|
||||||
' try_files /index.html =404;',
|
' try_files /index.html =404;',
|
||||||
@@ -229,7 +243,7 @@ const parseArguments = (arguments_) => {
|
|||||||
}
|
}
|
||||||
const required = ['--config', '--json-ld-hash', '--artifact-manifest', '--document-root'];
|
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}`);
|
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}`);
|
for (const key of values.keys()) if (!allowed.has(key)) throw new Error(`Unknown host config argument: ${key}`);
|
||||||
return { values, alternateHosts };
|
return { values, alternateHosts };
|
||||||
};
|
};
|
||||||
@@ -247,6 +261,7 @@ export const generateHostConfig = async (arguments_) => {
|
|||||||
alternateHosts,
|
alternateHosts,
|
||||||
tlsCertificatePath: values.get('--tls-certificate'),
|
tlsCertificatePath: values.get('--tls-certificate'),
|
||||||
tlsKeyPath: values.get('--tls-key'),
|
tlsKeyPath: values.get('--tls-key'),
|
||||||
|
manifestDigest: values.get('--manifest-digest'),
|
||||||
});
|
});
|
||||||
const output = values.get('--output');
|
const output = values.get('--output');
|
||||||
if (output === undefined) process.stdout.write(config);
|
if (output === undefined) process.stdout.write(config);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../../scripts/generate-host-config.mjs';
|
} from '../../scripts/generate-host-config.mjs';
|
||||||
|
|
||||||
const jsonLdHash = 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
const jsonLdHash = 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
||||||
|
const releaseManifestDigest = 'd'.repeat(64);
|
||||||
const productionSite: SiteConfig = {
|
const productionSite: SiteConfig = {
|
||||||
environment: 'production',
|
environment: 'production',
|
||||||
canonicalUrl: 'https://portfolio.example.test/',
|
canonicalUrl: 'https://portfolio.example.test/',
|
||||||
@@ -38,6 +39,7 @@ describe('generated Nginx static-host contract', () => {
|
|||||||
alternateHosts: ['www.portfolio.example.test'],
|
alternateHosts: ['www.portfolio.example.test'],
|
||||||
tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem',
|
tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem',
|
||||||
tlsKeyPath: '/etc/portfolio/tls/privkey.pem',
|
tlsKeyPath: '/etc/portfolio/tls/privkey.pem',
|
||||||
|
manifestDigest: releaseManifestDigest,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const file of manifest.files) expect(config).toContain(`~^/${file.path.replaceAll('.', '\\.')}$`);
|
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'],
|
alternateHosts: ['www.portfolio.example.test'],
|
||||||
tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem',
|
tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem',
|
||||||
tlsKeyPath: '/etc/portfolio/tls/privkey.pem',
|
tlsKeyPath: '/etc/portfolio/tls/privkey.pem',
|
||||||
|
manifestDigest: releaseManifestDigest,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(config).toContain('listen 80;');
|
expect(config).toContain('listen 80;');
|
||||||
@@ -80,12 +83,18 @@ describe('generated Nginx static-host contract', () => {
|
|||||||
alternateHosts: [],
|
alternateHosts: [],
|
||||||
tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem',
|
tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem',
|
||||||
tlsKeyPath: '/etc/portfolio/tls/privkey.pem',
|
tlsKeyPath: '/etc/portfolio/tls/privkey.pem',
|
||||||
|
manifestDigest: releaseManifestDigest,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(config).toContain("default-src 'self'; script-src 'self' 'sha256-");
|
expect(config).toContain("default-src 'self'; script-src 'self' 'sha256-");
|
||||||
expect(config).toContain("img-src 'self' data:");
|
expect(config).toContain("img-src 'self' data:");
|
||||||
expect(config).toContain("connect-src 'none'");
|
expect(config).toContain("connect-src 'none'");
|
||||||
expect(config).toContain('add_header Strict-Transport-Security "max-age=31536000" always;');
|
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('includeSubDomains');
|
||||||
expect(config).not.toContain('X-Robots-Tag');
|
expect(config).not.toContain('X-Robots-Tag');
|
||||||
expect(config).not.toMatch(/unsafe-inline|unsafe-eval|sourceMappingURL|\.map(?:\s|$)/u);
|
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).toContain('private, no-store');
|
||||||
expect(config).not.toContain('Strict-Transport-Security');
|
expect(config).not.toContain('Strict-Transport-Security');
|
||||||
expect(config).not.toContain('sitemap.xml');
|
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([
|
it.each([
|
||||||
@@ -149,6 +171,7 @@ describe('generated Nginx static-host contract', () => {
|
|||||||
|
|
||||||
expect(example).toContain('__CANONICAL_HOST__');
|
expect(example).toContain('__CANONICAL_HOST__');
|
||||||
expect(example).toContain('__JSON_LD_CSP_HASH__');
|
expect(example).toContain('__JSON_LD_CSP_HASH__');
|
||||||
|
expect(example).toContain('__MANIFEST_DIGEST__');
|
||||||
expect(example).not.toContain('portfolio.example.test');
|
expect(example).not.toContain('portfolio.example.test');
|
||||||
expect(example).not.toMatch(/server_name\s+[a-z0-9.-]+\.[a-z]{2,}/iu);
|
expect(example).not.toMatch(/server_name\s+[a-z0-9.-]+\.[a-z]{2,}/iu);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user