feat: define secure static hosting contract
This commit is contained in:
@@ -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