feat: define secure static hosting contract
This commit is contained in:
78
src/security/security-policy.ts
Normal file
78
src/security/security-policy.ts
Normal 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');
|
||||
};
|
||||
Reference in New Issue
Block a user