AgentPay applies its baseline browser security posture from two layers:
- Static headers (all except CSP) are delivered via
next.config.tsheaders()at build time. - Content-Security-Policy is delivered at request time by
src/proxy.tsso a per-request cryptographic nonce can be injected intoscript-src 'nonce-…'.
This document is a reference for contributors changing headers, CSP directives, external integrations, or the theme pre-paint script.
src/lib/securityHeaders.tsowns the CSP directive list (buildCsp()) and the static header map (defaultSecurityHeaders()).src/proxy.tsgenerates a per-request nonce, callsbuildCsp()with it, and sets the CSP response header. It also forwards the nonce viax-noncerequest header sosrc/app/layout.tsxcan apply it to the inline<script>.next.config.tsresolves the API base once withresolveApiBase()and callsdefaultSecurityHeaders()for every static header exceptContent-Security-Policy(which would otherwise overwrite the proxy value).src/app/layout.tsxreads thex-noncerequest header and stamps it on the inline theme pre-paint<script>.
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. |
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.
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.
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:
src/proxy.tsgenerates a freshcrypto.randomUUID()nonce on every request.- It builds the CSP via
buildCsp({nonce}), which emitsscript-src 'self' 'nonce-<value>'— no'unsafe-inline'. - The nonce is also placed on the request headers as
x-nonce, so server components can read it. src/app/layout.tsxreads the nonce withheaders().get('x-nonce')and passes it as thenonceattribute on the inline<script>element. The browser matches this against the CSP nonce and executes the script.- 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-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.
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-srcand 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-tolater.
Recommended workflow:
- Prefer configuration-derived origins. For the primary backend, set
NEXT_PUBLIC_AGENTPAY_API_BASE; do not hard-code a duplicate API origin in CSP. - Add the narrowest source expression to the specific directive in
src/lib/securityHeaders.ts. Prefer a full origin such ashttps://api.example.comover a scheme or wildcard. - Keep development-only relaxations behind
isDevso production remains stricter. - 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. - Add or update tests in
src/lib/__tests__/securityHeaders.test.tswhen behavior changes. - Update this document in the same PR and verify every documented directive/header still exists in source.
- Run
npm run lint,npm run build, and a source cross-check such asrg -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.
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:, ordata:unless the resource type truly requires them. - Never add
'unsafe-eval'to productionscript-srcwithout a clear migration plan. - Never remove both
X-Frame-Options: DENYandframe-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.
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_BASEwhen 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.1stay 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:
resolveApiBasethrows on invalid input;sanitizeBaseUrldegrades 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.- 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.
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-srcstill reflects the configured API origin.- The nonce flow is intact: proxy generates a nonce,
buildCsp()receives it, andlayout.tsxreads 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').