Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@arraypress/security-headers

Security response headers for static hosts — CSP, HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy. Generates a Cloudflare/Netlify _headers file, with an Astro integration. Zero dependencies.

Install

npm install @arraypress/security-headers

Astro

// astro.config.mjs
import { defineConfig } from 'astro/config';
import headers from '@arraypress/security-headers/astro';

export default defineConfig({
  security: { csp: true },    // Astro owns CSP — see below
  integrations: [headers()],   // everything else, written to dist/_headers
});

The integration writes _headers on astro:build:done, so there's no separate build script to remember. It logs what it wrote:

[@arraypress/security-headers] wrote _headers — 7 headers on /*

Why a file and not middleware

On Cloudflare, a static-assets deploy with no server script serves requests for free. Adding middleware to set headers adds a script and makes every request billable. _headers is applied at the edge for nothing.

Why CSP defaults to off here

Astro has its own security.csp, and it can hash the inline <script> and <style> blocks Astro itself emits — the theme flash-guard, scoped component styles. A static _headers file can't hash them, so expressing the same policy there means 'unsafe-inline' on both directives, which is most of what CSP was protecting you from.

So Astro owns CSP, and this owns what Astro doesn't do: HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy, X-Content-Type-Options, Cross-Origin-Opener-Policy and X-Permitted-Cross-Domain-Policies.

Opt back in — for a host where Astro's CSP isn't in play:

integrations: [headers({ csp: { defaultSrc: ["'self'"] } })]

Options

headers(config?, options?)

  • config — a SecurityHeadersConfig (below). csp defaults to false here.
  • options.path — path pattern the headers apply to. Default '/*'.
  • options.filename — output name. Default '_headers'.

Cloudflare caps a _headers file at 100 rules; one path costs one rule however many headers it carries.

Anywhere else

The generators are plain functions with no framework attached — use them from a build script, a Worker, or a test.

import { headersFile, buildHeaders, buildCSP, buildHSTS } from '@arraypress/security-headers';

// A _headers file, as a string.
writeFileSync('dist/_headers', headersFile({ csp: { scriptSrc: ["'self'"] } }));

// The same values as a plain object — for a Response you build yourself.
return new Response(body, { headers: buildHeaders() });

// Or one header at a time.
buildCSP({ defaultSrc: ["'self'"] });
buildHSTS({ maxAge: 31536000, includeSubDomains: true });

headersFile() renders the Cloudflare/Netlify format — a path line, then each header indented two spaces:

/*
  X-Content-Type-Options: nosniff
  X-Frame-Options: SAMEORIGIN
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=()
  Cross-Origin-Opener-Policy: same-origin
  X-Permitted-Cross-Domain-Policies: none
  Content-Security-Policy: default-src 'self'; script-src 'self'; …
  Strict-Transport-Security: max-age=31536000; includeSubDomains

Under the Astro integration the CSP line is absent, since Astro owns it.

Configuration

Option Default Notes
csp strict defaults CSPConfig or false. Defaults to false in the Astro integration.
cspReportOnly false A stricter policy sent as …-Report-Only, to trial before enforcing.
hsts true HSTSConfig, true for defaults, or false to skip.
xContentTypeOptions true Emits nosniff.
xFrameOptions 'SAMEORIGIN' 'DENY', 'SAMEORIGIN' or false.
referrerPolicy 'strict-origin-when-cross-origin' Any policy string, or false.
permissionsPolicy camera=(), microphone=(), geolocation=() Any policy string, or false.
crossOriginOpenerPolicy 'same-origin' Use 'same-origin-allow-popups' for OAuth popups.
crossOriginEmbedderPolicy false 'require-corp' / 'credentialless'. See isolation below.
crossOriginResourcePolicy false 'same-origin' / 'same-site' / 'cross-origin'.
permittedCrossDomainPolicies 'none' Legacy Flash/Acrobat crossdomain.xml opt-out.
originAgentCluster false Emits Origin-Agent-Cluster: ?1.
reportingEndpoints null { csp: 'https://…' } → Reporting-Endpoints.

Every header is independently togglable — pass false to skip it.

Cross-origin isolation

Cross-Origin-Opener-Policy is on by default: it severs window.opener across origins and needs nothing from the resources you load, so it costs you nothing. The one gotcha is OAuth popups that talk back via window.opener — those want 'same-origin-allow-popups'.

Cross-Origin-Embedder-Policy and Cross-Origin-Resource-Policy are off by default, deliberately. COEP blocks every cross-origin resource that hasn't opted in, and CORP stops other sites embedding your images and fonts. Turn them on together, with COOP 'same-origin', when you actually need crossOriginIsolated — that is, SharedArrayBuffer or wasm threads:

headers({
  crossOriginOpenerPolicy: 'same-origin',
  crossOriginEmbedderPolicy: 'require-corp',
  crossOriginResourcePolicy: 'same-origin',
})

An app using AudioWorklets or Workers does not need this on its own — only shared memory does.

Rolling out a CSP

Send a strict policy as report-only alongside a permissive enforced one, watch the reports, then promote it:

headers({
  csp: { scriptSrc: ["'self'", "'unsafe-inline'"] },   // enforced today
  cspReportOnly: { scriptSrc: ["'self'"] },             // the goal
  reportingEndpoints: { csp: 'https://example.com/csp-report' },
})

CSP directives are camelCase and become kebab-case on the wire: defaultSrc, scriptSrc, styleSrc, imgSrc, fontSrc, connectSrc, frameSrc, and the rest.

buildHeaders({
  csp: { scriptSrc: ["'self'", 'https://challenges.cloudflare.com'] },
  xFrameOptions: 'DENY',
  hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
  permissionsPolicy: false,
});

Gotchas

Two defaults are strict on purpose and will bite if your site is the exception. Both fail silently in the browser, so they're worth knowing before you deploy.

The microphone and camera are off

Permissions-Policy defaults to camera=(), microphone=(), geolocation=(), which disables getUserMedia() outright — the call rejects, and nothing in your own code looks wrong. Right for a marketing or directory site; wrong for an app that records audio.

// An app with a record-from-mic button:
headers({ permissionsPolicy: 'camera=(), microphone=(self), geolocation=()' })

The default CSP blocks Google Fonts

font-src defaults to 'self' and style-src to 'self' 'unsafe-inline', so a <link> to fonts.googleapis.com and the files it pulls from fonts.gstatic.com are both blocked:

headers({
  csp: {
    styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
    fontSrc: ["'self'", 'https://fonts.gstatic.com'],
  },
})

The same applies to any third-party origin — analytics, embeds, a CDN. The default assumes a site that serves everything itself; add origins as you add dependencies rather than loosening default-src.

This one doesn't arise under the Astro integration, where CSP is off and Astro owns the policy.

Security notes

X-Frame-Options is superseded by CSP's frame-ancestors but is still emitted for older browsers — they don't cost each other anything.

The Permissions-Policy default is a tight baseline suited to admin surfaces. If your site legitimately uses the camera, microphone or geolocation, extend it rather than dropping the header.

HSTS only takes effect over HTTPS, and preload is a one-way door — browsers cache it for a long time, so don't enable it until you're certain every subdomain can serve HTTPS.

License

MIT

About

Security headers for static hosts — CSP, HSTS, cross-origin isolation. Writes a _headers file; Astro integration included.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages