Skip to content

Latest commit

 

History

History
160 lines (118 loc) · 12.4 KB

File metadata and controls

160 lines (118 loc) · 12.4 KB

Security headers and Content-Security-Policy

AgentPay applies its baseline browser security posture from two layers:

  • Static headers (all except CSP) are delivered via next.config.ts headers() at build time.
  • Content-Security-Policy is delivered at request time by src/proxy.ts so a per-request cryptographic nonce can be injected into script-src 'nonce-…'.

This document is a reference for contributors changing headers, CSP directives, external integrations, or the theme pre-paint script.

Where the headers are defined

  • src/lib/securityHeaders.ts owns the CSP directive list (buildCsp()) and the static header map (defaultSecurityHeaders()).
  • src/proxy.ts generates a per-request nonce, calls buildCsp() with it, and sets the CSP response header. It also forwards the nonce via x-nonce request header so src/app/layout.tsx can apply it to the inline <script>.
  • next.config.ts resolves the API base once with resolveApiBase() and calls defaultSecurityHeaders() for every static header except Content-Security-Policy (which would otherwise overwrite the proxy value).
  • src/app/layout.tsx reads the x-nonce request header and stamps it on the inline theme pre-paint <script>.

Response header map

defaultSecurityHeaders() returns these headers except Content-Security-Policy, which is set by the proxy at request time:

Header Source Current value / behavior Purpose
Content-Security-Policy src/proxy.ts at request time Generated by buildCsp() with a per-request nonce Restricts where scripts, styles, images, fonts, fetches, forms, frames, objects, and base URLs can load or execute. The nonce allows the inline theme script to run without 'unsafe-inline'.
X-Content-Type-Options next.config.ts (build-time) nosniff Prevents browsers from MIME-sniffing a response away from its declared Content-Type.
Referrer-Policy next.config.ts (build-time) strict-origin-when-cross-origin Sends full referrers on same-origin navigation, but only the origin for cross-origin HTTPS requests and no referrer on HTTPS-to-HTTP downgrades.
X-Frame-Options next.config.ts (build-time) DENY Legacy clickjacking defense that prevents the dashboard from being framed. This complements CSP frame-ancestors 'none'.
Permissions-Policy next.config.ts (build-time) camera=(), microphone=(), geolocation=(), payment=(), browsing-topics=(), interest-cohort=() Denies sensitive browser capabilities and tracking-related APIs by default.
Strict-Transport-Security next.config.ts (build-time) max-age=63072000; includeSubDomains; preload in production only Tells browsers to use HTTPS for this host for two years, including subdomains, and opts into preload eligibility. It is omitted in development so local browsers do not cache an HTTPS upgrade for dev servers.

CSP directive reference

buildCsp() currently emits the following directives. Keep this table in sync with the directives object in src/lib/securityHeaders.ts.

Directive Current sources Why it exists
default-src 'self' Baseline fallback: resources are same-origin unless a more specific directive below allows something else.
script-src Nonce path: 'self' 'nonce-<value>' (+ 'unsafe-eval' in dev); fallback: 'self' 'unsafe-inline' (+ 'unsafe-eval' in dev) Allows dashboard scripts from this origin. When proxy provides a nonce (the standard path), the inline theme pre-paint script executes via the nonce and 'unsafe-inline' is omitted. When no nonce is provided (e.g. tests, or if proxy is not deployed), 'unsafe-inline' serves as a safety net. 'unsafe-eval' is development-only for Next.js Fast Refresh and related dev tooling. Do not add 'unsafe-eval' to production.
style-src 'self' 'unsafe-inline' Allows same-origin styles plus inline style tags generated by Next.js and next/font.
font-src 'self' data: Allows bundled same-origin fonts and data: font URLs.
img-src 'self' data: Allows same-origin images and small inline data: images.
connect-src 'self' plus the resolved API origin Allows browser fetch/WebSocket-style connections to the dashboard origin and the AgentPay API origin derived from NEXT_PUBLIC_AGENTPAY_API_BASE.
frame-ancestors 'none' Modern clickjacking defense that prevents any parent page from embedding the dashboard.
form-action 'self' Restricts form submissions to the dashboard origin.
base-uri 'self' Prevents injected <base> tags from rewriting relative URLs to an attacker-controlled origin.
object-src 'none' Blocks legacy plugin/embed/object execution surfaces.

The policy intentionally does not include navigate-to, so normal top-level navigation from links such as external documentation or Stellar links remains possible.

How connect-src tracks the API origin

The API base URL is resolved in next.config.ts with resolveApiBase(). That value is passed to defaultSecurityHeaders(), and buildCsp() calls originOf(apiBase) before adding it to connect-src.

Only the origin is used. For example, https://api.example.com/v1 becomes https://api.example.com in CSP. This keeps the CSP aligned with browser origin checks and with the actual backend origin used by the frontend API client.

If apiBase cannot be parsed as a URL, originOf() falls back to the default local API origin from DEFAULT_API_BASE. This keeps the generated CSP valid instead of emitting an invalid source expression.

Inline script and the nonce flow

The inline theme pre-paint script in src/app/layout.tsx was the primary reason 'unsafe-inline' existed in script-src. The nonce proxy replaces that with a request-time delegation:

  1. src/proxy.ts generates a fresh crypto.randomUUID() nonce on every request.
  2. It builds the CSP via buildCsp({nonce}), which emits script-src 'self' 'nonce-<value>' — no 'unsafe-inline'.
  3. The nonce is also placed on the request headers as x-nonce, so server components can read it.
  4. src/app/layout.tsx reads the nonce with headers().get('x-nonce') and passes it as the nonce attribute on the inline <script> element. The browser matches this against the CSP nonce and executes the script.
  5. The buildCsp() function still falls back to 'unsafe-inline' when no nonce argument is passed, which keeps tests working and provides a safety net if proxy is temporarily absent.

Dev vs. production: Both environments use the nonce path with one difference — development also includes 'unsafe-eval' to support Fast Refresh. The isDev flag controls this, just as before.

Style inline tradeoff

style-src includes 'unsafe-inline' because Next.js and next/font can inject inline style tags needed for rendering. This is unchanged by the nonce change — style-src is not affected.

Safely adding an allowed origin

Before adding a source, decide whether it is actually needed by browser-enforced CSP:

  • API calls, analytics beacons, EventSource, or WebSocket connections usually require connect-src.
  • Images require img-src.
  • Fonts require font-src.
  • Scripts require script-src and should be avoided unless the origin is trusted and stable.
  • Top-level link navigation does not require a CSP source unless the app adds navigate-to later.

Recommended workflow:

  1. Prefer configuration-derived origins. For the primary backend, set NEXT_PUBLIC_AGENTPAY_API_BASE; do not hard-code a duplicate API origin in CSP.
  2. Add the narrowest source expression to the specific directive in src/lib/securityHeaders.ts. Prefer a full origin such as https://api.example.com over a scheme or wildcard.
  3. Keep development-only relaxations behind isDev so production remains stricter.
  4. When adding a source that affects script-src, verify the nonce path still works: the proxy forwards the nonce to layout.tsx, and layout applies it to any new inline scripts.
  5. Add or update tests in src/lib/__tests__/securityHeaders.test.ts when behavior changes.
  6. Update this document in the same PR and verify every documented directive/header still exists in source.
  7. Run npm run lint, npm run build, and a source cross-check such as rg -n "default-src|script-src|style-src|font-src|img-src|connect-src|frame-ancestors|form-action|base-uri|object-src|Content-Security-Policy|X-Content-Type-Options|Referrer-Policy|X-Frame-Options|Permissions-Policy|Strict-Transport-Security" src/lib/securityHeaders.ts src/proxy.ts next.config.ts docs/security-headers.md.

Safely relaxing a directive

Relaxing CSP or hardening headers should be treated as a security-sensitive change:

  • Document the product requirement and why a narrower alternative is not enough.
  • Avoid broad wildcards such as *, https:, or data: unless the resource type truly requires them.
  • Never add 'unsafe-eval' to production script-src without a clear migration plan.
  • Never remove both X-Frame-Options: DENY and frame-ancestors 'none' unless the dashboard is intentionally becoming embeddable; if embedding is required, allow only the exact parent origins.
  • Re-run the source cross-check and update this reference doc so future contributors can audit the change.

Hardened curl example generation on the docs page

The docs page (src/app/docs/page.tsx) renders copyable curl commands built in src/app/docs/endpoints.ts by interpolating the API base URL. That value originates from NEXT_PUBLIC_AGENTPAY_API_BASE, so a misconfigured or hostile environment value could otherwise end up inside a command a user pastes into a terminal — e.g. https://api.example.com/$(rm -rf ~) would inject a command substitution into the copied string.

getSections() defends against this with sanitizeBaseUrl(), which applies the same rules as resolveApiBase() before any interpolation:

  • trims whitespace and falls back to DEFAULT_API_BASE when empty;
  • requires the value to parse as an absolute URL;
  • accepts only http:/https: protocols;
  • in production refuses http: on non-localhost hosts (localhost, 127.0.0.1 stay allowed);
  • normalises to origin + pathname without trailing slashes, which drops embedded credentials, query strings, and fragments.

On top of the resolveApiBase parity rules, the normalised URL is matched against a strict allowlist ([A-Za-z0-9\-._~%:/]). Any character a shell could interpret — whitespace, quotes, $, backticks, backslashes, ;, &, |, <, >, parentheses, braces, glob characters — causes the value to be rejected, and the examples are generated against DEFAULT_API_BASE instead. Characters the WHATWG URL parser percent-encodes during parsing (backtick, ", space) reach the command only as harmless %xx literals.

Two differences from resolveApiBase are intentional:

  1. resolveApiBase throws on invalid input; sanitizeBaseUrl degrades to the safe default because the docs page only renders examples and should not crash on a bad env var that other layers may still surface.
  2. The extra shell-metacharacter allowlist exists because a URL can be perfectly valid (RFC 3986 allows $, (, ), ;, ' in paths) and still be dangerous inside a shell command.

Every generated command also wraps the base URL in double quotes. Because the allowlist excludes $, backtick, \, and ", the quoted string cannot be expanded or broken out of by any POSIX-compatible shell.

Malicious-input coverage lives in src/app/docs/page.test.tsx: it asserts the page never renders a command containing $(/metacharacters, and unit-tests the reject, percent-encode, normalisation, and production-http paths of sanitizeBaseUrl directly. Keep those tests and this section in sync when changing endpoints.ts.

Maintenance checklist

When changing src/lib/securityHeaders.ts or src/proxy.ts, confirm:

  • Every emitted response header is documented above.
  • Every emitted CSP directive is documented above.
  • Development-vs-production differences are described.
  • connect-src still reflects the configured API origin.
  • The nonce flow is intact: proxy generates a nonce, buildCsp() receives it, and layout.tsx reads it from the request headers.
  • If 'unsafe-inline' is ever removed from the no-nonce fallback, confirm the nonce path still works for every inline script.
  • buildCsp() tests cover both the nonce path (no 'unsafe-inline') and the fallback path (with 'unsafe-inline').