feat: define secure static hosting contract
This commit is contained in:
@@ -5,6 +5,7 @@ import type {
|
||||
SiteConfig,
|
||||
} from './types.ts';
|
||||
import { serializeJsonLd } from '../security/serialize-json-ld.ts';
|
||||
import { createSecurityHeaders } from '../security/security-policy.ts';
|
||||
|
||||
interface PersonJsonLd {
|
||||
'@type': 'Person';
|
||||
@@ -108,28 +109,19 @@ export const createPageMetadata = (
|
||||
};
|
||||
};
|
||||
|
||||
const contentSecurityPolicy = (jsonLdCspHash: string): string => [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' '${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',
|
||||
].join('; ');
|
||||
|
||||
const commonHeaders = (metadata: Readonly<PageMetadata>): string[] => [
|
||||
` Content-Security-Policy: ${contentSecurityPolicy(metadata.jsonLdCspHash)}`,
|
||||
' X-Content-Type-Options: nosniff',
|
||||
' Referrer-Policy: no-referrer',
|
||||
' Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
||||
' X-Frame-Options: DENY',
|
||||
];
|
||||
const commonHeaders = (
|
||||
metadata: Readonly<PageMetadata>,
|
||||
site: Readonly<SiteConfig>,
|
||||
): string[] => {
|
||||
const headers = createSecurityHeaders(site, metadata.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}`,
|
||||
];
|
||||
};
|
||||
|
||||
export const createStaticHostArtifacts = (
|
||||
metadata: Readonly<PageMetadata>,
|
||||
@@ -140,7 +132,7 @@ export const createStaticHostArtifacts = (
|
||||
robots: 'User-agent: *\nDisallow: /\n',
|
||||
headers: [
|
||||
'/*',
|
||||
...commonHeaders(metadata),
|
||||
...commonHeaders(metadata, site),
|
||||
' Cache-Control: private, no-store',
|
||||
' X-Robots-Tag: noindex, nofollow, noarchive',
|
||||
'',
|
||||
@@ -164,7 +156,7 @@ export const createStaticHostArtifacts = (
|
||||
' Cache-Control: public, max-age=31536000, immutable',
|
||||
'',
|
||||
'/*',
|
||||
...commonHeaders(metadata),
|
||||
...commonHeaders(metadata, site),
|
||||
' Cache-Control: public, max-age=0, must-revalidate',
|
||||
' Strict-Transport-Security: max-age=31536000',
|
||||
'',
|
||||
|
||||
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