diff --git a/README.md b/README.md index ef50cef..1f90a64 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ you can also use other package managers like [pnpm](https://pnpm.io/) or [yarn]( ### default headers +this will add the default headers to the response, this will be enough to get your website A grade in [security headers](https://securityheaders.com/) + ```ts // src/hooks.server.ts import { securityHeaders } from '@islamzaoui/securekit'; @@ -28,6 +30,8 @@ export const handle = securityHeaders().handle; ### with custom headers +you can customize the headers you want to set, and override any of the headers available in response headers. + ```ts // src/hooks.server.ts import { securityHeaders } from '@islamzaoui/securekit'; @@ -46,6 +50,8 @@ export const handle = securityHeaders({ ### with multiple handles +you can use [sequence](https://kit.svelte.dev/docs/modules#sveltejs-kit-hooks) to sequencing other handles with `securityHeaders`. + ```ts // src/hooks.server.ts import { securityHeaders } from '@islamzaoui/securekit'; @@ -61,10 +67,32 @@ export const handle = sequence( ); ``` +### delete headers + +set the value to `null` to delete the header. + +```ts +// src/hooks.server.ts +import { securityHeaders } from '@islamzaoui/securekit'; + +export const handle = securityHeaders({ + headers: { + 'Access-Control-Allow-Origin': 'https://yoursite.com', + 'x-sveltekit-page': null, // this will be deleted + }, +}); +``` + ## Content Security Policy header your can use `csp` option in `securityHeaders` to set the `Content-Security-Policy` header directives. +**Note**: + +- `config.csp` directives will override any existing directives in the `Content-Security-Policy` header. + +- `config.csp` directives will extend the existing directives in [svelte.config.js](https://kit.svelte.dev/docs/configuration#csp) + ```ts // src/hooks.server.ts import { securityHeaders } from '@islamzaoui/securekit'; diff --git a/apps/demo/src/hooks.server.ts b/apps/demo/src/hooks.server.ts index 8ac784a..0e32ebd 100644 --- a/apps/demo/src/hooks.server.ts +++ b/apps/demo/src/hooks.server.ts @@ -1,12 +1,13 @@ import { dev } from '$app/environment'; -import { rules, securityHeaders } from '@islamzaoui/securekit'; +import { securityHeaders, rules } from '@islamzaoui/securekit'; const origin = dev ? 'http://localhost:5173' : 'https://securekit-demo.vercel.app'; export const handle = securityHeaders({ headers: { ...rules.defaultHeaders, - 'Access-Control-Allow-Origin': origin + 'Access-Control-Allow-Origin': origin, + 'x-sveltekit-page': null }, csp: { directives: { @@ -21,9 +22,9 @@ export const handle = securityHeaders({ 'manifest-src': ["'self'"], 'media-src': ["'self'", 'data:'], 'object-src': ["'none'"], - 'style-src': ["'self'", "'unsafe-inline'"], + 'style-src': ["'self'"], 'default-src': ["'self'", origin], - 'script-src': ["'self'", "'unsafe-inline'"], + 'script-src': ["'self'"], 'worker-src': ["'self'"] } }, diff --git a/apps/demo/svelte.config.js b/apps/demo/svelte.config.js index e987418..97a5eac 100644 --- a/apps/demo/svelte.config.js +++ b/apps/demo/svelte.config.js @@ -11,7 +11,13 @@ const config = { // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // If your environment is not supported, or you settled on a specific environment, switch out the adapter. // See https://kit.svelte.dev/docs/adapters for more information about adapters. - adapter: adapter() + adapter: adapter(), + csp: { + mode: 'auto', + directives: { + 'default-src': ["'self'"] + } + } } }; diff --git a/packages/securekit/src/headers.ts b/packages/securekit/src/constants/rules.ts similarity index 88% rename from packages/securekit/src/headers.ts rename to packages/securekit/src/constants/rules.ts index 369e75d..67428c9 100644 --- a/packages/securekit/src/headers.ts +++ b/packages/securekit/src/constants/rules.ts @@ -1,4 +1,4 @@ -import { HTTPResponseHeaders } from './types/http'; +import { HTTPResponseHeaders } from '../types/http'; const defaultHeaders: HTTPResponseHeaders = { 'X-Frame-Options': 'DENY', @@ -7,7 +7,7 @@ const defaultHeaders: HTTPResponseHeaders = { 'Permissions-Policy': 'geolocation=(), camera=(), microphone=()', }; -export const rules = { +export default { /** * Default headers for secure headers. * @example diff --git a/packages/securekit/src/index.ts b/packages/securekit/src/index.ts index 497132e..d4f6d16 100644 --- a/packages/securekit/src/index.ts +++ b/packages/securekit/src/index.ts @@ -1,59 +1,3 @@ -import { Handle } from '@sveltejs/kit'; -import { Config } from './types'; -import { normalizeCspValue, normalizeHeaderValue } from './utils'; -import { rules } from './headers'; - -/** - * sets HTTP response security headers based on the provided configuration. - * - * @param {Config} [config] - Configuration object for secure headers. - * @param {Object} [config.headers] - Custom headers to set. - * @param {boolean} [config.debug] - Enables debug logging if true. - * @param {Object} [config.csp] - Content Security Policy directives. - * @returns {Object} An object containing the handle function to process requests. - */ -export const securityHeaders = ( - config: Config = { - headers: { - ...rules.defaultHeaders, - }, - debug: false, - csp: undefined, - }, -) => { - const headers = new Map(); - - if (config?.headers) { - Object.entries(config.headers).forEach(([key, value]) => { - value && headers.set(key, normalizeHeaderValue(value)); - }); - } - - if (config?.csp) { - const cspDirectives = Object.entries(config.csp.directives) - .map(([key, value]) => `${key} ${normalizeCspValue(value)}`) - .join('; '); - headers.set('Content-Security-Policy', cspDirectives); - } - - const handle: Handle = async ({ event, resolve }) => { - if (config?.debug) { - console.log( - '[DEBUG] securekit Headers:', - JSON.stringify(Object.fromEntries(headers), null, 2), - ); - } - - const response = await resolve(event); - - headers.forEach((value, key) => { - response.headers.set(key, value); - }); - - return response; - }; - - return { handle }; -}; - -export { rules } from './headers'; +export { securityHeaders } from './securityHeaders'; +export { default as rules } from './constants/rules'; +export * from './types'; diff --git a/packages/securekit/src/securityHeaders.ts b/packages/securekit/src/securityHeaders.ts new file mode 100644 index 0000000..dd13dc8 --- /dev/null +++ b/packages/securekit/src/securityHeaders.ts @@ -0,0 +1,98 @@ +import { Handle } from '@sveltejs/kit'; +import rules from './constants/rules'; +import { Config } from './types'; +import { + normalizeCspValue, + normalizeHeaderValue, +} from './utils/headerNormalizers'; +import { combineCspHeaders } from './utils/cspHelpers'; + +/** + * Sets HTTP response security headers based on the provided configuration. + * + * @param {Config} [config] - Configuration object for secure headers. + * @param {HTTPResponseHeaders} [config.headers] - Custom headers to set. Defaults to predefined security headers. + * @param {boolean} [config.debug=false] - Enables debug logging if true. + * @param {Csp} [config.csp] - Content Security Policy directives. + * + * @returns {Object} An object containing the handle function to process requests. + * @property {Handle} handle - A SvelteKit handle function that applies the configured security headers to each response. + * + * @example + * // Basic usage with default settings + * export const handle = securityHeaders().handle; + * + * @example + * // Custom configuration + * export const handle = securityHeaders({ + * headers: { + * 'Access-Control-Allow-Origin': 'https://example.com', + * 'X-Frame-Options': 'SAMEORIGIN' + * }, + * debug: true, + * csp: { + * directives: { + * 'default-src': ["'self'"], + * 'script-src': ["'self'", 'https://trusted-cdn.com'] + * } + * } + * }).handle; + */ +export const securityHeaders = ( + config: Config = { + headers: { + ...rules.defaultHeaders, + }, + debug: false, + csp: undefined, + }, +) => { + const headers = new Map(); + + if (config?.headers) { + Object.entries(config.headers).forEach(([key, value]) => { + if (value !== undefined) + headers.set(key, normalizeHeaderValue(value)); + }); + } + + if (config?.csp) { + const cspDirectives = Object.entries(config.csp.directives) + .map(([key, value]) => `${key} ${normalizeCspValue(value)}`) + .join('; '); + headers.set('Content-Security-Policy', cspDirectives); + } + + const handle: Handle = async ({ event, resolve }) => { + const response = await resolve(event); + + headers.forEach((value, key) => { + if (key.toLocaleLowerCase() === 'content-security-policy') { + const existingCsp = response.headers.get( + 'Content-Security-Policy', + ); + const combinedCsp = combineCspHeaders(existingCsp, value); + if (combinedCsp) { + response.headers.set( + 'Content-Security-Policy', + combinedCsp, + ); + } + } else { + if (value) response.headers.set(key, value); + else response.headers.delete(key); + } + }); + + if (config?.debug) { + console.log( + '[DEBUG] securekit Headers:\n', + JSON.stringify(Object.fromEntries(response.headers), null, 2), + ); + } + + return response; + }; + + return { handle }; +}; diff --git a/packages/securekit/src/types/csp.ts b/packages/securekit/src/types/csp.d.ts similarity index 94% rename from packages/securekit/src/types/csp.ts rename to packages/securekit/src/types/csp.d.ts index 4640272..8d6d042 100644 --- a/packages/securekit/src/types/csp.ts +++ b/packages/securekit/src/types/csp.d.ts @@ -116,5 +116,14 @@ export interface CspDirectives { } export interface Csp { + /** + * Content Security Policy header directives. + * @example + * directives: { + * 'default-src': ['self'], + * 'script-src': ['self', 'https://example.com'], + * 'style-src': ['self', 'https://example.com'], + * } + */ directives: CspDirectives; } diff --git a/packages/securekit/src/types/http.ts b/packages/securekit/src/types/http.d.ts similarity index 80% rename from packages/securekit/src/types/http.ts rename to packages/securekit/src/types/http.d.ts index cd67e55..4d2d230 100644 --- a/packages/securekit/src/types/http.ts +++ b/packages/securekit/src/types/http.d.ts @@ -36,58 +36,58 @@ export type HTTPResponseHeaders = { /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security | Strict-Transport-Security MDN} */ - 'Strict-Transport-Security'?: string; + 'Strict-Transport-Security'?: string | null; /** * Better to use csp in securekit function instead * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy | Content-Security-Policy MDN} */ - 'Content-Security-Policy'?: string; + 'Content-Security-Policy'?: string | null; /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy | Referrer-Policy MDN} */ - 'Referrer-Policy'?: ReferrerPolicy; + 'Referrer-Policy'?: ReferrerPolicy | null; /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy | Permissions-Policy MDN} */ - 'Permissions-Policy'?: string; + 'Permissions-Policy'?: string | null; /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection | X-XSS-Protection MDN} */ - 'X-XSS-Protection'?: string; + 'X-XSS-Protection'?: string | null; /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options | X-Frame-Options MDN} */ - 'X-Frame-Options'?: 'DENY' | 'SAMEORIGIN'; + 'X-Frame-Options'?: 'DENY' | 'SAMEORIGIN' | null; /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options | X-Content-Type-Options MDN} */ - 'X-Content-Type-Options'?: 'nosniff'; + 'X-Content-Type-Options'?: 'nosniff' | null; /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin | Access-Control-Allow-Origin MDN} */ - 'Access-Control-Allow-Origin'?: '*' | string | string[]; + 'Access-Control-Allow-Origin'?: '*' | string | string[] | null; /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods | Access-Control-Allow-Methods MDN} */ - 'Access-Control-Allow-Methods'?: '*' | HTTPMethods | HTTPMethods[]; + 'Access-Control-Allow-Methods'?: '*' | HTTPMethods | HTTPMethods[] | null; /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers | Access-Control-Allow-Headers MDN} */ - 'Access-Control-Allow-Headers'?: '*' | string | string[]; + 'Access-Control-Allow-Headers'?: '*' | string | string[] | null; /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age | Access-Control-Max-Age MDN} */ - 'Access-Control-Max-Age'?: number; + 'Access-Control-Max-Age'?: number | null; /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Embedder-Policy | Cross-Origin-Embedder-Policy MDN} */ - 'Cross-Origin-Embedder-Policy'?: CrossOriginEmbedderPolicy; + 'Cross-Origin-Embedder-Policy'?: CrossOriginEmbedderPolicy | null; /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy | Cross-Origin-Opener-Policy MDN} */ - 'Cross-Origin-Opener-Policy'?: CrossOriginOpenerPolicy; + 'Cross-Origin-Opener-Policy'?: CrossOriginOpenerPolicy | null; /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Resource-Policy | Cross-Origin-Resource-Policy MDN} */ - 'Cross-Origin-Resource-Policy'?: CrossOriginResourcePolicy; + 'Cross-Origin-Resource-Policy'?: CrossOriginResourcePolicy | null; }; diff --git a/packages/securekit/src/types/index.ts b/packages/securekit/src/types/index.ts index 7969830..c9a715c 100644 --- a/packages/securekit/src/types/index.ts +++ b/packages/securekit/src/types/index.ts @@ -23,7 +23,7 @@ export type Config = { /** * Content Security Policy Directives to be applied. * - * This will override any CSP headers set in `headers`. + * This will override any CSP headers set in `config.headers`, and extend csp directives in your `svelte.config.js`. * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP * @default undefined */ diff --git a/packages/securekit/src/utils/cspHelpers.ts b/packages/securekit/src/utils/cspHelpers.ts new file mode 100644 index 0000000..6238a42 --- /dev/null +++ b/packages/securekit/src/utils/cspHelpers.ts @@ -0,0 +1,35 @@ +export const combineCspHeaders = ( + existingCsp: string | null, + newCsp: string | null, +): string | null => { + if (!existingCsp && !newCsp) return null; + if (!existingCsp) return newCsp; + if (!newCsp) return existingCsp; + + const parseDirectives = (csp: string) => { + return new Map( + csp.split(';').map((d) => { + const [key, ...values] = d.trim().split(/\s+/); + return [key, new Set(values.filter((v) => v !== ''))]; + }), + ); + }; + + const existingDirectives = parseDirectives(existingCsp); + const newDirectives = parseDirectives(newCsp); + + newDirectives.forEach((values, key) => { + if (existingDirectives.has(key)) { + values.forEach((value) => existingDirectives.get(key)!.add(value)); + } else { + existingDirectives.set(key, values); + } + }); + + const combinedDirectives = Array.from(existingDirectives.entries()) + .filter(([_, values]) => values.size > 0) + .map(([key, values]) => `${key} ${Array.from(values).join(' ')}`) + .join('; '); + + return combinedDirectives || null; +}; diff --git a/packages/securekit/src/utils.ts b/packages/securekit/src/utils/headerNormalizers.ts similarity index 78% rename from packages/securekit/src/utils.ts rename to packages/securekit/src/utils/headerNormalizers.ts index a63f629..14427d0 100644 --- a/packages/securekit/src/utils.ts +++ b/packages/securekit/src/utils/headerNormalizers.ts @@ -1,6 +1,7 @@ export const normalizeHeaderValue = ( - value: string | number | string[], -): string => { + value: string | number | string[] | null, +): string | null => { + if (value === null) return null; if (typeof value === 'string') return value.trim(); if (Array.isArray(value)) return value.join(', '); return value.toString(); diff --git a/turbo.json b/turbo.json index cbd5548..3a75ced 100644 --- a/turbo.json +++ b/turbo.json @@ -1,7 +1,5 @@ { "$schema": "https://turbo.build/schema.json", - "globalDependencies": ["**/.env.*local"], - "globalEnv": ["NODE_ENV"], "tasks": { "format": {}, "lint": {},