sdxc

Type to search, or start from one of these:

@sdxc/security-headers

Typed Content-Security-Policy, Permissions-Policy and response security headers, with middleware

npm add @sdxc/security-headers
pnpm add @sdxc/security-headers
yarn add @sdxc/security-headers
bun add @sdxc/security-headers
Depends on
remix
Used by
uptime, auth-saas, reader, blog

Typed Content-Security-Policy, Permissions-Policy and response security headers, with middleware.

Installation

npm add @sdxc/security-headers

The middleware runs on the remix router, and parsers return @sdxc/result values. Both install alongside this package.

A browser enforces a set of response headers describing what a document may do: where its scripts, styles, images and frames come from (Content-Security-Policy), whether it is reached only over HTTPS (Strict-Transport-Security), how much of its URL leaks in a Referer (Referrer-Policy), which device APIs it may ask for (Permissions-Policy), whether it shares a browsing-context group or resources with other origins (COOP, COEP, CORP), and whether a response's type may be sniffed (X-Content-Type-Options). This package models each of them as a typed object an app writes once and reviews in one place.

The CSP model writes keywords unquoted and quotes them on output, so a missing quote is impossible, and a "nonce" source is replaced by a per-response nonce the middleware generates on first read. The Permissions-Policy and Reporting-Endpoints go through the RFC 9651 serializer in @sdxc/structured-fields. X-Frame-Options is derived from frameAncestors, so the two can never disagree.

Serialization fails toward the stricter policy: a CSP source that would break out of its directive is dropped, a source list left empty is written as 'none', a Permissions-Policy origin RFC 9651 cannot carry denies its feature, and HSTS preload is written only when the preload lists would accept it.

Usage

Install The Policy

import type { SecurityHeaders } from "@sdxc/security-headers";

import { securityHeaders } from "@sdxc/security-headers/middleware";
import { createRouter } from "remix/router";

const SECURITY_POLICY: SecurityHeaders.Policy = {
	contentSecurityPolicy: {
		defaultSrc: ["self"],
		scriptSrc: ["self", "nonce"],
		styleSrc: ["self", "unsafe-inline"],
		frameAncestors: ["none"],
		reportTo: "csp",
	},
	reportingEndpoints: { csp: "/reports/csp" },
	strictTransportSecurity: { maxAge: 63072000, includeSubDomains: true, preload: true },
	referrerPolicy: "strict-origin-when-cross-origin",
	crossOriginOpenerPolicy: "same-origin",
	permissionsPolicy: { camera: [], microphone: [], geolocation: [] },
};

let router = createRouter({
	middleware: [securityHeaders(SECURITY_POLICY)], // place it right before the renderer
});

A response to https://example.com/ that read ctx.securityHeaders.nonce is sent with:

content-security-policy: default-src 'self'; script-src 'self' 'nonce-3q2+7w…=='; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; report-to csp
x-frame-options: DENY
strict-transport-security: max-age=63072000; includeSubDomains; preload
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=()
cross-origin-opener-policy: same-origin
reporting-endpoints: csp="/reports/csp"
x-content-type-options: nosniff

A response that never read the nonce is sent with script-src 'self'.

Write Headers Without The Middleware

import { apply } from "@sdxc/security-headers";
import { generateNonce } from "@sdxc/security-headers/csp";

let headers = new Headers({ "content-type": "text/html" });
apply(headers, SECURITY_POLICY, { url: new URL(request.url), nonce: generateNonce() });

Read A Policy Back

import { unwrap } from "@sdxc/result";
import { parse } from "@sdxc/security-headers/csp";

let [policy] = unwrap(parse("default-src 'self'; frame-src https://challenges.example.com"));
policy?.directives.frameSrc; // ["https://challenges.example.com"]

API

@sdxc/security-headers

entries(policy, options): Array<[name, value]>

Every header the policy produces, lowercase names in a stable order. options (SecurityHeaders.ApplyOptions) takes the request url, which gates HSTS (written only for https:), and options.nonce is substituted for every "nonce" source in both CSP headers. x-content-type-options: nosniff is written unless noSniff is false. X-Frame-Options is DENY for frameAncestors: ["none"] (or []), SAMEORIGIN for ["self"], and left out otherwise; the Report-Only policy derives nothing.

apply(headers, policy, options): void

Writes each entry the Headers does not already carry, so a route's own value wins. When the response set its own Content-Security-Policy, no X-Frame-Options is derived.

override(policy, patch): SecurityHeaders.Policy

Returns a patched copy: a header set to null is removed, a CSP patch is merged directive by directive through merge, and any other value replaces the base's.

stringifyStrictTransportSecurity(value): string

Writes max-age as whole, non-negative seconds, then includeSubDomains, then preload only when maxAge is at least a year and includeSubDomains is set.

SecurityHeaders.Policy

FieldHeader
contentSecurityPolicyContent-Security-Policy
contentSecurityPolicyReportOnlyContent-Security-Policy-Report-Only
strictTransportSecurityStrict-Transport-Security
referrerPolicy (value or fallbacks)Referrer-Policy
permissionsPolicyPermissions-Policy
crossOriginOpenerPolicyCross-Origin-Opener-Policy
crossOriginEmbedderPolicyCross-Origin-Embedder-Policy
crossOriginEmbedderPolicyReportOnlyCross-Origin-Embedder-Policy-Report-Only
crossOriginResourcePolicyCross-Origin-Resource-Policy
noSniff (default true)X-Content-Type-Options
reportingEndpointsReporting-Endpoints

SecurityHeaders.Override is the same shape with every field nullable and the CSP fields taking a CSP.Override. SecurityHeaders.StrictTransportSecurity is { maxAge, includeSubDomains?, preload? } and SecurityHeaders.ReferrerPolicy the policy tokens. The CSP and PermissionsPolicy namespaces are re-exported here too.

@sdxc/security-headers/csp

stringify(directives, options?): string

Writes one policy in the order the object lists its directives. Keywords (self, none, unsafe-inline, strict-dynamic, wasm-unsafe-eval …), nonce-… and sha256-… sources are quoted; schemes and hosts are written as-is. "nonce" becomes 'nonce-<options.nonce>', or is dropped without a nonce (CSP.StringifyOptions). An empty list is written as 'none'.

stringify({ scriptSrc: ["self", "nonce"], objectSrc: [] }, { nonce: "cmFuZG9t" });
// "script-src 'self' 'nonce-cmFuZG9t'; object-src 'none'"

parse(value): Result<CSP.Parsed[], CSPParseError>

Reads a header value into one CSP.Parsed ({ directives, unknown }) per comma-joined policy. Names and keywords are lowercased and unquoted; a repeated directive keeps its first value and the repeat lands in unknown, as does any directive the model does not carry, verbatim. A character outside printable ASCII or a directive name outside [A-Za-z0-9-] fails with a CSPParseError whose position says where.

let result = parse("script-src 'self' https://cdn.example.com, img-src *");
// success: [{ directives: { scriptSrc: ["self", "https://cdn.example.com"] }, unknown: {} },
//           { directives: { imgSrc: ["*"] }, unknown: {} }]

merge(base, override): CSP.Directives

Replaces each directive the override (CSP.Override) names and removes those it sets to null, keeping the base's order.

generateNonce(): string

16 random bytes as padded base64, for a caller that writes CSP without the middleware.

CSP.Directives

defaultSrc, scriptSrc, scriptSrcElem, scriptSrcAttr, styleSrc, styleSrcElem, styleSrcAttr, imgSrc, fontSrc, connectSrc, mediaSrc, objectSrc, frameSrc, childSrc, workerSrc, manifestSrc, baseUri, formAction and frameAncestors take source lists; sandbox takes true or tokens; reportTo an endpoint name; reportUri URLs; upgradeInsecureRequests true; requireTrustedTypesFor ["script"]; trustedTypes policy names plus the none and allow-duplicates keywords. CSP.Keyword, CSP.Source, CSP.SourceList and CSP.SandboxToken type the values.

@sdxc/security-headers/permissions-policy

stringify(policy): Result<string, StructuredFieldStringifyError>

Writes a PermissionsPolicy.Policy, a map from feature name to a PermissionsPolicy.Allowlist: [] as (), "*" as the bare token *, self and src as tokens and origins as strings. Feature names keep their wire spelling (browsing-topics), since they are an open registry.

stringify({ camera: [], fullscreen: "*", geolocation: ["self", "https://maps.example.com"] });
// success('camera=(), fullscreen=*, geolocation=(self "https://maps.example.com")')

parse(value): Result<PermissionsPolicy.Policy, StructuredFieldParseError>

Reads a header value back, dropping parameters (report-to) and members that are neither tokens nor strings. A bare self reads as ["self"].

@sdxc/security-headers/reports

parseReports(request): Promise<Result<CSPReport.Violation[], CSPReportParseError>>

Reads a report POST in either format: application/reports+json batches (reports of other types and malformed CSP reports skipped), application/csp-report legacy documents, and either one labelled application/json. Each violation carries documentURL, blockedURL, effectiveDirective, disposition, sample, sourceFile and lineNumber; empty strings become null, and an older legacy report's violated-directive supplies the directive. CSPReport.Violation is the violation type. An unsupported media type or an unreadable body fails with a CSPReportParseError.

@sdxc/security-headers/middleware

securityHeaders(policy): Middleware

Publishes ctx.securityHeaders and applies the resulting policy to the response the chain returns, keeping any header the response set itself. HSTS is written only on https: requests, and a 101 Switching Protocols response passes through untouched.

  • ctx.securityHeaders.nonce - this response's nonce, generated on first read; a response that never reads it gets every "nonce" source removed. Read it in the handler: the headers are written when the chain resolves, before a streamed body renders.

  • ctx.securityHeaders.policy - the policy the response will be sent with.

  • ctx.securityHeaders.override(patch) - patches this response's policy.

SecurityHeadersContext types that state, and SecurityHeadersKey is the context key, for code that reads it with ctx.get.

securityHeadersOverride(patch): Middleware

Controller or action middleware applying patch for the routes it guards; a pass-through when securityHeaders is not installed.

Pattern: An Inline Script With A Nonce

import { createAction } from "remix/router";

export default createAction(routes.formPost, {
	handler(ctx) {
		let nonce = ctx.securityHeaders.nonce;
		return ctx.render(
			<html lang="en">
				<body>
					<form method="post" action="/callback" />
					<script nonce={nonce}>{"document.forms[0].submit()"}</script>
				</body>
			</html>,
		);
	},
});

The policy lists "nonce" in scriptSrc; the response carries 'nonce-…' because the handler read it.

Pattern: The Managed Import Map

remix/ui keeps the attributes of <ImportMap> on the <script type="importmap"> it writes, and its client runtime copies that script's nonce onto every import map it appends later. A document that uses client entries renders it with the nonce:

import { ImportMap } from "remix/ui/server";

<head>
	<ImportMap value={importMap} nonce={ctx.securityHeaders.nonce} />
</head>;

Without <ImportMap>, the client-entry import map is written with no attributes and a nonce-based script-src blocks it.

Pattern: A Route That Must Be Framed

import { securityHeadersOverride } from "@sdxc/security-headers/middleware";
import { createAction } from "remix/router";

export default createAction(routes.embed, {
	middleware: [securityHeadersOverride({ contentSecurityPolicy: { frameAncestors: ["*"] } })],
	handler() {
		return new Response(embedHtml, { headers: { "content-type": "text/html" } });
	},
});

The derived X-Frame-Options disappears with the 'none' it came from.

Pattern: Report-Only Rollout And A Report Route

import type { SecurityHeaders } from "@sdxc/security-headers";

import { isFailure } from "@sdxc/result";
import { parseReports } from "@sdxc/security-headers/reports";

const SECURITY_POLICY: SecurityHeaders.Policy = {
	contentSecurityPolicyReportOnly: {
		defaultSrc: ["self"],
		reportTo: "csp",
		reportUri: ["/reports/csp"],
	},
	reportingEndpoints: { csp: "/reports/csp" },
};

router.post(routes.reports.csp, async (ctx) => {
	let reports = await parseReports(ctx.request);
	if (isFailure(reports)) return new Response(null, { status: 400 });
	for (let violation of reports.data) await recordViolation(violation);
	return new Response(null, { status: 204 });
});

Pattern: Asserting A Policy In A Test

import { unwrap } from "@sdxc/result";
import { parse } from "@sdxc/security-headers/csp";
import { expect } from "vitest";

let [policy] = unwrap(parse(response.headers.get("content-security-policy") ?? ""));
expect(policy?.directives.frameSrc).toEqual(["https://challenges.example.com"]);

Pattern: Choosing Sources That Hold Up

  • Keep styleSrc: ["self", "unsafe-inline"] and never put "nonce" in styleSrc: remix/ui emits one <style> per css() mixin without a nonce, and a nonce in style-src makes browsers ignore 'unsafe-inline' and block those styles.

  • A cached HTML response replays its nonce to every visitor. Behind a shared cache, run a policy without "nonce" and use sha256-… sources for fixed inline scripts.

  • Start with contentSecurityPolicyReportOnly and a report route; move the directives to contentSecurityPolicy once the reports are quiet.

  • 'strict-dynamic' makes browsers ignore 'self' and host sources, so every <script src> would need the nonce too; adopt it deliberately.