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:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@api.global/typedserver',
|
||||
version: '7.8.18',
|
||||
version: '7.9.0',
|
||||
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { type IServerOptions, TypedServer } from '../classes.typedserver.js';
|
||||
import { type IServerOptions, type ISecurityHeaders, TypedServer } from '../classes.typedserver.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export interface IUtilityWebsiteServerConstructorOptions {
|
||||
/** Custom route handler to add additional routes */
|
||||
addCustomRoutes?: (typedserver: TypedServer) => Promise<any>;
|
||||
/** Application semantic version */
|
||||
appSemVer?: string;
|
||||
/** Domain name for the website */
|
||||
domain: string;
|
||||
/** Directory to serve static files from */
|
||||
serveDir: string;
|
||||
feedMetadata: IServerOptions['feedMetadata'];
|
||||
/** RSS feed metadata */
|
||||
feedMetadata?: IServerOptions['feedMetadata'];
|
||||
/** Enable/disable CORS (default: true) */
|
||||
cors?: boolean;
|
||||
/** Enable/disable SPA fallback (default: true) */
|
||||
spaFallback?: boolean;
|
||||
/** Security headers configuration */
|
||||
securityHeaders?: ISecurityHeaders;
|
||||
/** Force SSL redirect (default: false) */
|
||||
forceSsl?: boolean;
|
||||
/** Port to listen on (default: 3000) */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,14 +44,28 @@ export class UtilityWebsiteServer {
|
||||
/**
|
||||
* Start the website server
|
||||
*/
|
||||
public async start(portArg = 3000) {
|
||||
public async start(portArg?: number) {
|
||||
const port = portArg ?? this.options.port ?? 3000;
|
||||
|
||||
this.typedserver = new TypedServer({
|
||||
cors: true,
|
||||
injectReload: true,
|
||||
watch: true,
|
||||
// Core settings
|
||||
cors: this.options.cors ?? true,
|
||||
serveDir: this.options.serveDir,
|
||||
domain: this.options.domain,
|
||||
forceSsl: false,
|
||||
port,
|
||||
|
||||
// Development features
|
||||
injectReload: true,
|
||||
watch: true,
|
||||
|
||||
// SPA support (enabled by default for modern web apps)
|
||||
spaFallback: this.options.spaFallback ?? true,
|
||||
|
||||
// Security
|
||||
forceSsl: this.options.forceSsl ?? false,
|
||||
securityHeaders: this.options.securityHeaders,
|
||||
|
||||
// PWA manifest
|
||||
manifest: {
|
||||
name: this.options.domain,
|
||||
short_name: this.options.domain,
|
||||
@@ -46,11 +75,11 @@ export class UtilityWebsiteServer {
|
||||
background_color: '#000000',
|
||||
scope: '/',
|
||||
},
|
||||
port: portArg,
|
||||
|
||||
// features
|
||||
// SEO features
|
||||
robots: true,
|
||||
sitemap: true,
|
||||
feedMetadata: this.options.feedMetadata,
|
||||
});
|
||||
|
||||
let lswData: interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo['response'] = {
|
||||
|
||||
Reference in New Issue
Block a user