feat(typedserver): Add configurable security headers and default SPA behavior

Introduce structured security headers support (CSP, HSTS, X-Frame-Options, COOP/COEP/CORP, Permissions-Policy, Referrer-Policy, X-XSS-Protection, etc.) and apply them to responses and OPTIONS preflight. Expose configuration via the server API and document usage. Also update UtilityWebsiteServer defaults (SPA fallback enabled by default) and related docs.
This commit is contained in:
2025-12-05 13:13:59 +00:00
parent 2f064c7ea8
commit ffb00cdb71
5 changed files with 367 additions and 28 deletions

View File

@@ -1,10 +1,80 @@
import * as plugins from './plugins.js';
import * as paths from './paths.js';
import * as interfaces from '../dist_ts_interfaces/index.js';
import { DevToolsController } from './controllers/controller.devtools.js';
import { TypedRequestController } from './controllers/controller.typedrequest.js';
import { BuiltInRoutesController } from './controllers/controller.builtin.js';
/**
* Content Security Policy configuration
* Each directive can be a string or array of sources
*/
export interface IContentSecurityPolicy {
/** Fallback for other directives */
defaultSrc?: string | string[];
/** Valid sources for scripts */
scriptSrc?: string | string[];
/** Valid sources for stylesheets */
styleSrc?: string | string[];
/** Valid sources for images */
imgSrc?: string | string[];
/** Valid sources for fonts */
fontSrc?: string | string[];
/** Valid sources for AJAX, WebSockets, etc. */
connectSrc?: string | string[];
/** Valid sources for media (audio/video) */
mediaSrc?: string | string[];
/** Valid sources for frames */
frameSrc?: string | string[];
/** Valid sources for <object>, <embed>, <applet> */
objectSrc?: string | string[];
/** Valid sources for web workers */
workerSrc?: string | string[];
/** Valid sources for form actions */
formAction?: string | string[];
/** Controls which URLs can embed the page */
frameAncestors?: string | string[];
/** Restricts URLs for <base> element */
baseUri?: string | string[];
/** Report violations to this URL */
reportUri?: string;
/** Report violations to this endpoint */
reportTo?: string;
/** Upgrade insecure requests to HTTPS */
upgradeInsecureRequests?: boolean;
/** Block all mixed content */
blockAllMixedContent?: boolean;
}
/**
* Security headers configuration
*/
export interface ISecurityHeaders {
/** Content Security Policy */
csp?: IContentSecurityPolicy;
/** X-Frame-Options: DENY, SAMEORIGIN, or ALLOW-FROM uri */
xFrameOptions?: 'DENY' | 'SAMEORIGIN' | string;
/** X-Content-Type-Options: nosniff */
xContentTypeOptions?: boolean;
/** X-XSS-Protection header (legacy, but still useful) */
xXssProtection?: boolean | string;
/** Referrer-Policy header */
referrerPolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url';
/** Strict-Transport-Security (HSTS) max-age in seconds */
hstsMaxAge?: number;
/** Include subdomains in HSTS */
hstsIncludeSubDomains?: boolean;
/** HSTS preload flag */
hstsPreload?: boolean;
/** Permissions-Policy (formerly Feature-Policy) */
permissionsPolicy?: Record<string, string[]>;
/** Cross-Origin-Opener-Policy */
crossOriginOpenerPolicy?: 'unsafe-none' | 'same-origin-allow-popups' | 'same-origin';
/** Cross-Origin-Embedder-Policy */
crossOriginEmbedderPolicy?: 'unsafe-none' | 'require-corp' | 'credentialless';
/** Cross-Origin-Resource-Policy */
crossOriginResourcePolicy?: 'same-site' | 'same-origin' | 'cross-origin';
}
export interface IServerOptions {
/**
* serve a particular directory
@@ -62,6 +132,11 @@ export interface IServerOptions {
* Useful for single-page applications with client-side routing
*/
spaFallback?: boolean;
/**
* Security headers configuration (CSP, HSTS, X-Frame-Options, etc.)
*/
securityHeaders?: ISecurityHeaders;
}
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'ALL';
@@ -388,16 +463,133 @@ export class TypedServer {
}
/**
* Add CORS headers to a response
* Build CSP header string from configuration
*/
private addCorsHeaders(response: Response): Response {
if (!this.options.cors) return response;
private buildCspHeader(csp: IContentSecurityPolicy): string {
const directives: string[] = [];
const addDirective = (name: string, value: string | string[] | undefined) => {
if (value) {
const sources = Array.isArray(value) ? value.join(' ') : value;
directives.push(`${name} ${sources}`);
}
};
addDirective('default-src', csp.defaultSrc);
addDirective('script-src', csp.scriptSrc);
addDirective('style-src', csp.styleSrc);
addDirective('img-src', csp.imgSrc);
addDirective('font-src', csp.fontSrc);
addDirective('connect-src', csp.connectSrc);
addDirective('media-src', csp.mediaSrc);
addDirective('frame-src', csp.frameSrc);
addDirective('object-src', csp.objectSrc);
addDirective('worker-src', csp.workerSrc);
addDirective('form-action', csp.formAction);
addDirective('frame-ancestors', csp.frameAncestors);
addDirective('base-uri', csp.baseUri);
if (csp.reportUri) {
directives.push(`report-uri ${csp.reportUri}`);
}
if (csp.reportTo) {
directives.push(`report-to ${csp.reportTo}`);
}
if (csp.upgradeInsecureRequests) {
directives.push('upgrade-insecure-requests');
}
if (csp.blockAllMixedContent) {
directives.push('block-all-mixed-content');
}
return directives.join('; ');
}
/**
* Apply all configured headers (CORS, security) to a response
*/
private applyResponseHeaders(response: Response): Response {
const headers = new Headers(response.headers);
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
headers.set('Access-Control-Max-Age', '86400');
// CORS headers
if (this.options.cors) {
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
headers.set('Access-Control-Max-Age', '86400');
}
// Security headers
const security = this.options.securityHeaders;
if (security) {
// Content Security Policy
if (security.csp) {
const cspHeader = this.buildCspHeader(security.csp);
if (cspHeader) {
headers.set('Content-Security-Policy', cspHeader);
}
}
// X-Frame-Options
if (security.xFrameOptions) {
headers.set('X-Frame-Options', security.xFrameOptions);
}
// X-Content-Type-Options
if (security.xContentTypeOptions) {
headers.set('X-Content-Type-Options', 'nosniff');
}
// X-XSS-Protection
if (security.xXssProtection) {
const value = typeof security.xXssProtection === 'string'
? security.xXssProtection
: '1; mode=block';
headers.set('X-XSS-Protection', value);
}
// Referrer-Policy
if (security.referrerPolicy) {
headers.set('Referrer-Policy', security.referrerPolicy);
}
// Strict-Transport-Security (HSTS)
if (security.hstsMaxAge !== undefined) {
let hsts = `max-age=${security.hstsMaxAge}`;
if (security.hstsIncludeSubDomains) {
hsts += '; includeSubDomains';
}
if (security.hstsPreload) {
hsts += '; preload';
}
headers.set('Strict-Transport-Security', hsts);
}
// Permissions-Policy
if (security.permissionsPolicy) {
const policies = Object.entries(security.permissionsPolicy)
.map(([feature, allowlist]) => `${feature}=(${allowlist.join(' ')})`)
.join(', ');
if (policies) {
headers.set('Permissions-Policy', policies);
}
}
// Cross-Origin-Opener-Policy
if (security.crossOriginOpenerPolicy) {
headers.set('Cross-Origin-Opener-Policy', security.crossOriginOpenerPolicy);
}
// Cross-Origin-Embedder-Policy
if (security.crossOriginEmbedderPolicy) {
headers.set('Cross-Origin-Embedder-Policy', security.crossOriginEmbedderPolicy);
}
// Cross-Origin-Resource-Policy
if (security.crossOriginResourcePolicy) {
headers.set('Cross-Origin-Resource-Policy', security.crossOriginResourcePolicy);
}
}
return new Response(response.body, {
status: response.status,
@@ -416,12 +608,12 @@ export class TypedServer {
// Handle OPTIONS preflight for CORS
if (method === 'OPTIONS' && this.options.cors) {
return this.addCorsHeaders(new Response(null, { status: 204 }));
return this.applyResponseHeaders(new Response(null, { status: 204 }));
}
// Process the request and wrap response with CORS headers
// Process the request and wrap response with all configured headers
const response = await this.handleRequestInternal(request, url, path, method);
return this.addCorsHeaders(response);
return this.applyResponseHeaders(response);
}
/**