feat: define secure static hosting contract

This commit is contained in:
2026-09-02 09:44:15 +03:00
parent 672ba80d9b
commit 847e6e4da9
8 changed files with 638 additions and 27 deletions

View 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);
});
});

View File

@@ -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=()',

View 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);
});
});