The CSP Testing Anti-Pattern: Why You Must Parse the Document
A deep dive into why string-matching your Content-Security-Policy header in tests is dangerous, and how we parse the actual SPA entry document to prevent self-inflicted outages.
Implementing a strict Content-Security-Policy (CSP) on a modern Single Page Application (SPA) is an exercise in frustration. You block inline scripts, you restrict object sources, and you lock down fetch destinations. Then, you deploy, and your UI breaks because a third-party stylesheet attempts to load a webfont from an undocumented origin.
To prevent these breakages, developers write tests. Unfortunately, the standard way developers test CSP headers is fundamentally flawed.
At Mezite, we embed a React SPA directly into our Go backend control plane. We require a strict CSP. When we tighten our policy, we review it thoroughly and write tests to ensure the header is present. However, the initial iterations of our CSP tightening omitted the webfont origins the SPA entry document actually loads.
If we ship a policy like that, we break the administrative UI for every self-hosted deployment.
Here is the technical breakdown of the testing anti-pattern that hides these issues, and the document-parsing approach we use to guarantee our CSP is correct.
The String Comparison Illusion
The standard approach to testing a CSP header looks like this: you make a request to the handler, extract the Content-Security-Policy header, and compare it against an expected string.
func TestCSPHeader(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
expected := "default-src 'self'; script-src 'self'; style-src 'self';"
actual := rec.Header().Get("Content-Security-Policy")
assert.Equal(t, expected, actual)
}This test is worse than useless; it is an illusion.
A policy assertion that merely restates the policy cannot fail for the reason that matters. It simply verifies that the server output matches the template in your code. It does not verify that the policy permits the resources your application actually requires.
If you add a Google Font to your index.html without updating the Go handler, the test still passes. The header still matches the expected string. But in production, the browser blocks the font.
The Hidden Connection Hint
In our case, the gap involves a missing font-src directive for fonts.gstatic.com.
The SPA entry document requests a Google stylesheet. That stylesheet, in turn, requests the webfonts. Because the font files are requested by the external stylesheet and not the entry document itself, scanning the document for <link rel="stylesheet"> or <style> blocks is insufficient.
However, modern performance practices provide a signal. The index.html contains a connection hint: <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>.
A naive test ignores this hint. We treat it as evidence of a dependency instead: the hint names an origin the page intends to use, and the only place that origin can be exercised is the font request made later by the stylesheet, which is governed by font-src. Strictly speaking, browsers govern the preconnect itself by connect-src (falling back to default-src), so a policy with connect-src 'self' will block the hint even when font-src allows the fonts. Our check is deliberately looser than that: it accepts the origin appearing in any fetch directive, which is enough to catch a missing font-src entry. When you tighten a CSP, account for every external origin, inline block, and connection hint present in the entry document.
Deriving Truth from the Artefact
To fix this, we invert our testing methodology. Instead of comparing the header to a hardcoded string, we derive the expected policy requirements directly from the artefact the policy governs: the generated index.html.
In our Go backend, our TestSPAEntryDocumentSatisfiesServedCSP test actually parses the HTML document.
It scans for every element that triggers a fetch or requires a directive:
<script>and<style>tags (both inline and external)<link>tags (stylesheets, manifests, preloads, and preconnect hints)<img>,<object>,<embed>, and<iframe>elements<base>elements
For every resource the document requests, the test calculates the necessary CSP directive. It takes the live header returned by the handler and ensures that the policy explicitly allows the derived requirements.
// We extract the actual header the handler serves
header := servedCSPHeader(t)
// We parse the actual HTML document the user receives
doc := readSPAEntryDocument(t)
// We extract every resource reference from the DOM
refs := collectCSPReferences(doc)
for _, ref := range refs {
// Assert that the served header permits the specific reference
assertPolicyPermits(t, header, ref)
}Why This Matters
This approach guarantees that the policy and the document cannot drift apart.
If a frontend engineer adds a new tracking script or a remote image to the SPA, the test fails immediately, forcing them to update the backend CSP header. If a backend engineer tightens the CSP and inadvertently blocks an existing asset, the test fails, preventing a broken UI from reaching production.
When you secure an application, your tests must reflect reality. Do not test your security policies against themselves. Test them against the systems they actually constrain.
Mezite Team
Engineering