BUG-017 — No Content-Security-Policy, and HTML responses ship without HSTS / X-Frame-Options / X-Content-Type-Options / Referrer-Policy / Permissions-Policy
| Field | Value |
|---|---|
| Severity | High (security) |
| Surface | Public site / every HTML response |
| Status | Open |
| Discovered | 2026-04-20 |
| Discovered by | Playwright recon — inspected response headers across 283 requests |
Across every captured response (documents + 11 API endpoints, 283 requests total) in recon/artifacts/2026-04-20T09-40-50-508Z/network/requests.json:
# No CSP header on a single response, anywhere
jq '[.[] | select(.responseHeaders["content-security-policy"] != null)] | length'
→ 0Security-related headers observed on any response, anywhere on the site:
strict-transport-security # (present only on API responses)
x-content-type-options # (present only on API responses)
x-frame-options # (present only on API responses)
x-xss-protection # (present only on API responses; value "0")
The HTML document response for https://chamaconnect.io/ (the response the browser hydrates) ships with none of those. Its full keys[]:
alt-svc, cache-control, cf-cache-status, cf-ray, content-encoding, content-type,
date, nel, report-to, server, server-timing, vary,
x-nextjs-cache, x-nextjs-prerender, x-nextjs-stale-time, x-powered-by
No content-security-policy. No strict-transport-security. No x-frame-options. No x-content-type-options. No referrer-policy. No permissions-policy.
This is a defense-in-depth bug, but it multiplies every other security bug on the site:
- Clickjacking —
/admin/dashboard,/admin/chamas/create,/admin/chamas/[id]/settingscan be<iframe>-embedded by any attacker site, which can then overlay invisible UI and trick a logged-in chama treasurer into clicking "Approve Payout" or "Delete Chama". - No CSP means XSS is uncontained. Combined with BUG-013 (JWT in response body) and BUG-016 (never-expiring JWT), any single reflected or stored XSS on a contact form, profile bio, or chama description hands the attacker a permanent credential for the victim's account.
- No HSTS on the document response means a user visiting
http://chamaconnect.iofrom a hotel wifi is downgrade-attackable on first visit. (Cloudflare will often add HSTS at the edge; this evidence shows it is not consistently present on the document response in the current configuration.) - No Referrer-Policy means when a logged-in user clicks an external link, the full path (e.g.
/admin/chamas/<mongoid>/loans/<loanid>) leaks into the third-party's access log via theRefererheader. x-xss-protection: 0is modern best practice, but it only appears on API responses — HTML documents don't even set it.- Missing
Permissions-Policy— the site could lock down camera/microphone/geolocation but opts for the browser default ("allowed"), which exposes a bigger surface than needed. x-powered-byheader is present — minor info disclosure revealing the tech stack (Next.js) and handing attackers pre-targeted CVE lists.
A Kenyan money platform failing these is hard to defend to an ODPC auditor.
Headers are not set in next.config.mjs (or middleware), and the reverse proxy (Cloudflare) is not configured to add them to HTML responses — only API responses happen to have partial ones, likely because the backend framework sets them itself.
Add a Next.js middleware (or headers() export in next.config.mjs) that applies strict headers to every HTML document response:
// next.config.mjs
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https://chamaconnect.io https://cdn.cloudflare.com",
"connect-src 'self' wss://chamaconnect.io https://api.chamaconnect.io",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"upgrade-insecure-requests",
].join("; ");
export default {
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "Content-Security-Policy", value: csp },
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(self)" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Resource-Policy", value: "same-origin" },
],
},
];
},
poweredByHeader: false,
};Then run a CSP report-only rollout for 1–2 weeks using Content-Security-Policy-Report-Only + report-uri so you can find legitimate third-party inline scripts before enforcing.
curl -sI https://chamaconnect.io/ | grep -iE 'content-security-policy|strict-transport-security|x-frame-options|x-content-type-options|referrer-policy|permissions-policy'
# must show all sixcurl -sI https://chamaconnect.io/ | grep -i x-powered-by→ no match.<iframe src="https://chamaconnect.io/admin/dashboard">on an attacker page → refuses to render.- Mozilla Observatory score goes from F → A.
- Security regression test:
test("every public route sets core security headers", async ({ request }) => {
for (const path of ["/", "/features", "/pricing", "/about", "/faqs", "/contact", "/get-started"]) {
const r = await request.get(path);
const h = r.headers();
expect(h["content-security-policy"]).toBeTruthy();
expect(h["strict-transport-security"]).toContain("max-age=");
expect(h["x-frame-options"] ?? h["content-security-policy"]).toMatch(/frame-ancestors|DENY|SAMEORIGIN/i);
expect(h["x-content-type-options"]).toBe("nosniff");
}
});