feat: define secure static hosting contract
This commit is contained in:
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',
|
||||
'',
|
||||
'/*',
|
||||
` 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=()',
|
||||
|
||||
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