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,78 @@
import type { SiteConfig } from '../content/types.ts';
export const PERMISSIONS_POLICY =
'camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()' as const;
const jsonLdHashPattern = /^sha256-[A-Za-z0-9+/]{43}=$/u;
export interface SecurityHeaderMap {
csp: string;
contentTypeOptions: 'nosniff';
referrerPolicy: 'no-referrer';
permissionsPolicy: typeof PERMISSIONS_POLICY;
frameOptions: 'DENY';
cacheControl: 'private, no-store' | 'public, max-age=0, must-revalidate';
hsts?: 'max-age=31536000';
xRobotsTag?: 'noindex, nofollow, noarchive';
}
const assertJsonLdCspHash = (value: string): void => {
if (!jsonLdHashPattern.test(value)) {
throw new Error('JSON-LD CSP hash must be one canonical SHA-256 base64 expression.');
}
};
export const buildContentSecurityPolicy = (jsonLdCspHash: string): string => {
assertJsonLdCspHash(jsonLdCspHash);
return [
"default-src 'self'",
`script-src 'self' '${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',
].join('; ');
};
export const createSecurityHeaders = (site: Readonly<SiteConfig>, jsonLdCspHash: string): SecurityHeaderMap => {
const common = {
csp: buildContentSecurityPolicy(jsonLdCspHash),
contentTypeOptions: 'nosniff' as const,
referrerPolicy: 'no-referrer' as const,
permissionsPolicy: PERMISSIONS_POLICY,
frameOptions: 'DENY' as const,
};
return site.environment === 'preview'
? {
...common,
cacheControl: 'private, no-store',
xRobotsTag: 'noindex, nofollow, noarchive',
}
: {
...common,
cacheControl: 'public, max-age=0, must-revalidate',
hsts: 'max-age=31536000',
};
};
export const serializeStaticHostHeaders = (site: Readonly<SiteConfig>, jsonLdCspHash: string): string => {
const headers = createSecurityHeaders(site, jsonLdCspHash);
return [
'/*',
` Content-Security-Policy: ${headers.csp}`,
` X-Content-Type-Options: ${headers.contentTypeOptions}`,
` Referrer-Policy: ${headers.referrerPolicy}`,
` Permissions-Policy: ${headers.permissionsPolicy}`,
` X-Frame-Options: ${headers.frameOptions}`,
` Cache-Control: ${headers.cacheControl}`,
...(headers.hsts === undefined ? [] : [` Strict-Transport-Security: ${headers.hsts}`]),
...(headers.xRobotsTag === undefined ? [] : [` X-Robots-Tag: ${headers.xRobotsTag}`]),
'',
].join('\n');
};