Implementing Content Security Policy (CSP) Generation with Go
Content Security Policy headers are one of those things every web team knows they should have but often skips — the syntax is verbose, the directives are easy to misconfigure, and a wrong policy breaks the app silently in production. Writing a small generator in Go removes the human error and makes CSP a first-class part of your build. Why CSP matters and where it fails CSP is an HTTP response header that tells the browser which resources — scripts, styles, images, fonts — are allowed to load and from where. A well-crafted policy defeats cross-site scripting (XSS) attacks even when an attacker has already injected content into your HTML, because the browser simply refuses to execute disallowed scripts. The problem is maintenance. Teams start with a permissive policy (default-src 'self'), then add exceptions for every CDN and vendor widget until the header looks like an allow-list for the entire internet. At that point the policy is longer than it is useful. Building a generator means you describe intent — "scripts from our CDN only, no inline styles, report violations to this endpoint" — and get a validated, reproducible header string every time. Designing the CSP struct A CSP is a collection of directives. Each directive has a name (script-src, style-src, img-src, …) and one or more source values. In Go, the natural representation is a struct with typed fields: package csp import ( "fmt" "strings" ) type Policy struct { DefaultSrc []string ScriptSrc []string StyleSrc []string ImgSrc []string FontSrc []string ConnectSrc []string FrameSrc []string ObjectSrc []string ReportURI string UpgradeInsecureRequests bool } func (p Policy) Build() string { var parts []string add := func(name string, values []string) { if len(values) > 0 { parts = append(parts, fmt.Sprintf("%s %s", name, strings.Join(values, " "))) } } add("default-src", p.DefaultSrc) add("script-src", p.ScriptSrc) add("style-src", p.StyleSrc) add("img-src", p.ImgSrc) add("font-src", p.FontSrc) add("connect-src", p.ConnectSrc) add("frame-src", p.FrameSrc) add("object-src", p.ObjectSrc) if p.UpgradeInsecureRequests { parts = append(parts, "upgrade-insecure-requests") } if p.ReportURI != "" { parts = append(parts, "report-uri "+p.ReportURI) } return strings.Join(parts, "; ") } This gives you a Build() method that emits a valid header value. Fields you leave nil simply do not appear in the output — no empty directives to confuse the browser. Wiring it into a Go HTTP middleware The generator is only useful if the header is actually sent. Here is a middleware for the standard net/http library that takes a Policy and attaches it to every response: package csp import "net/http" func Middleware(p Policy) func(http.Handler) http.Handler { header := p.Build() return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Security-Policy", header) next.ServeHTTP(w, r) }) } } Usage in main.go: policy := csp.Policy{ DefaultSrc: []string{"'self'"}, ScriptSrc: []string{"'self'", "https://cdn.example.com"}, StyleSrc: []string{"'self'", "'unsafe-inline'"}, ImgSrc: []string{"'self'", "data:", "https://images.example.com"}, ObjectSrc: []string{"'none'"}, ReportURI: "https://csp-report.example.com/collect", UpgradeInsecureRequests: true, } mux := http.NewServeMux() mux.HandleFunc("/", homeHandler) http.ListenAndServe(":8080", csp.Middleware(policy)(mux)) The header value is computed once at startup, not on every request. At scale, that matters. Handling nonces for inline scripts 'unsafe-inline' in script-src wipes out most of CSP's XSS protection. The correct alternative is nonces: a per-request cryptographically random value that must appear both in the header and on each tag. The browser rejects any inline script that does not carry the matching nonce. Nonces require the header to be generated per-request rather than once at startup: package csp import ( "context" "crypto/rand" "encoding/base64" "fmt" "net/http" ) type contextKey struct{} func generateNonce() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return base64.StdEncoding.EncodeToString(b), nil } func NonceMiddleware(p Policy) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nonce, err := generateNonce() if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } adjusted := p adjusted.ScriptSrc = append( append([]string{}, p.ScriptSrc...), fmt.Sprintf("'nonce-%s'", nonce), ) w.Header().Set("Content-Security-Policy", adjusted.Build()) ctx := context.WithValue(r.Context(), contextKey{}, nonce) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // NonceFromCtx retrieves the nonce for use in HTML templates. func NonceFromCtx(ctx context.Context) string { v, _ := ctx.Value(contextKey{}).(string) return v } In your HTML template, the nonce goes on each inline script tag: // initialization code that must stay inline With this in place you can drop 'unsafe-inline' from script-src entirely, which is the single most impactful change you can make to an existing CSP. Testing the output Before shipping, verify the generated header against known valid strings: package csp_test import ( "strings" "testing" ) func TestPolicyBuild(t *testing.T) { p := Policy{ DefaultSrc: []string{"'self'"}, ObjectSrc: []string{"'none'"}, } got := p.Build() if !strings.Contains(got, "default-src 'self'") { t.Errorf("missing default-src: %q", got) } if !strings.Contains(got, "object-src 'none'") { t.Errorf("missing object-src: %q", got) } } func TestEmptyFieldsOmitted(t *testing.T) { p := Policy{DefaultSrc: []string{"'self'"}} got := p.Build() if strings.Contains(got, "script-src") { t.Errorf("expected script-src to be omitted: %q", got) } } Run with go test ./.... You can also paste the generated header into Google's CSP Evaluator to catch gaps — it flags missing object-src 'none', which would otherwise allow plugin injection even in modern browsers. For a broader view of HTTP security headers and hardening steps that complement CSP, our free security hardening checklists cover CSP alongside HSTS, CORS, authentication controls, and more. The takeaway Writing CSP by hand does not scale. A typed Go struct enforces that every directive is deliberate, lets you unit-test the output, and makes policy reviews part of code review rather than an afterthought buried in Nginx config. The nonce approach is non-trivial to wire up but pays off immediately: you remove 'unsafe-inline' from script-src, which is the most common reason CSP fails to prevent XSS in practice. If you maintain multiple services, the next logical step is externalizing the policy definition to a JSON or YAML file loaded at startup — one source of truth, all services consistent, policy diffs visible in git. I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to