StellarEduPay enforces a Content Security Policy on all HTTP responses to mitigate XSS attacks. The policy is applied at two layers: the Next.js frontend and the Express backend.
Without CSP, a successful XSS injection (e.g. a malicious student name rendered in the dashboard) can execute arbitrary JavaScript in the admin's browser, steal the JWT from localStorage, and exfiltrate school data. CSP prevents this by restricting which scripts, styles, and network destinations the browser will allow.
Applied to every HTML response via the Next.js headers() API:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self' https://horizon-testnet.stellar.org https://horizon.stellar.org;
object-src 'none';
frame-ancestors 'none';
base-uri 'self';
form-action 'self'
| Directive | Value | Rationale |
|---|---|---|
default-src |
'self' |
Deny all unlisted resource types by default |
script-src |
'self' |
No inline scripts, no eval, no third-party JS |
style-src |
'self' |
No inline styles, no third-party CSS |
img-src |
'self' data: |
Allows inline SVG/base64 images used by the UI |
font-src |
'self' |
Self-hosted fonts only |
connect-src |
'self' https://horizon-testnet.stellar.org https://horizon.stellar.org |
Allows fetch/XHR to the backend API and both Stellar Horizon endpoints |
object-src |
'none' |
Blocks Flash and other plugins |
frame-ancestors |
'none' |
Prevents clickjacking (equivalent to X-Frame-Options: DENY) |
base-uri |
'self' |
Prevents base-tag hijacking |
form-action |
'self' |
Restricts form submissions to the same origin |
Additional security headers set alongside CSP:
| Header | Value |
|---|---|
X-Frame-Options |
DENY |
X-Content-Type-Options |
nosniff |
Referrer-Policy |
strict-origin-when-cross-origin |
The Express backend serves only JSON API responses — directives for scripts, styles, and images are irrelevant. Helmet is configured with a minimal policy appropriate for an API:
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
})default-src 'none' means the browser should load nothing from this origin as a document resource. frame-ancestors 'none' prevents the API responses from being embedded in frames.
The CSP configuration is covered by tests/csp.test.js, which verifies:
- The frontend
next.config.jsexports aheaders()function returning a catch-all entry with aContent-Security-Policyheader. - The frontend CSP includes
default-src 'self',script-src 'self',frame-ancestors 'none',object-src 'none', and the Stellar Horizonconnect-srcallowlist. - The frontend CSP does not contain
'unsafe-inline'or'unsafe-eval'. - The backend
app.jssetsdefaultSrc: ["'none'"]andframeAncestors: ["'none'"]and does not includescriptSrc,styleSrc,imgSrc,'unsafe-inline', or'unsafe-eval'.
Run the tests with:
npm test -- tests/csp.test.jsIf a new external service needs to be reachable from the frontend (e.g. a currency conversion API), add its origin to the connect-src directive in frontend/next.config.js and update the test in tests/csp.test.js accordingly.
Do not add 'unsafe-inline' or 'unsafe-eval' to script-src. If a third-party library requires inline scripts, use a nonce-based approach instead.
All outbound webhook URLs pass through a multi-layer SSRF defence on every delivery attempt.
validateWebhookUrl(url) is called when an endpoint is created or updated:
- Only
https://scheme is accepted. - Well-known internal hostnames (
localhost,*.local,*.internal,*.localhost,*.test,*.invalid) are rejected without a DNS lookup. - Bare IP literals are checked directly against the deny list.
- DNS is resolved and all returned addresses (A + AAAA) must be public.
Immediately before every HTTP delivery the hostname is re-resolved and every IP is re-checked. If the hostname now resolves to a private address (DNS rebinding attack), the delivery is aborted with error SSRF_BLOCKED.
Both IPv4 and IPv6 are covered:
| Range | Reason |
|---|---|
| 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 | RFC 1918 private |
| 127.0.0.0/8 | Loopback |
| 169.254.0.0/16 | Link-local / AWS metadata |
| 100.64.0.0/10 | CGNAT (RFC 6598) |
| ::1 | IPv6 loopback |
| fe80::/10 | IPv6 link-local |
| fc00::/7 | IPv6 ULA |
| ::ffff:0:0/96 | IPv4-mapped (delegates to IPv4 check) |
| 64:ff9b::/96 | NAT64 prefix |
The Axios instance used for delivery is configured with maxRedirects: 0. Any 3xx response is treated as a delivery failure with error code SSRF_REDIRECT_BLOCKED and is not followed.
Response bodies are capped at 64 KB (maxContentLength: 65536). Requests that exceed this are aborted.