From 2ce222c3cd8a1b0837344fc1b7d1cbe5e4989ac8 Mon Sep 17 00:00:00 2001 From: bensynapse <118375461+bensynapse@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:28:57 +0300 Subject: [PATCH] fix: preserve CSP values during nonce substitution --- csp_test.go | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++ secure.go | 17 +++--------- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/csp_test.go b/csp_test.go index f09042b..8d88863 100644 --- a/csp_test.go +++ b/csp_test.go @@ -63,3 +63,78 @@ func TestWithCSPNonce(t *testing.T) { expect(t, CSPNonce(withCSPNonce(req, nonce).Context()), nonce) } + +func TestCSPNoncePreservesStaticPolicy(t *testing.T) { + policies := []string{ + "default-src 'self'", + "default-src 'self'; report-uri https://example.com/a%20b%2Fc%25", + } + + for _, policy := range policies { + for _, nonceHeader := range []string{cspHeader, cspReportOnlyHeader} { + t.Run(nonceHeader+"/"+policy, func(t *testing.T) { + options := Options{ContentSecurityPolicy: policy, ContentSecurityPolicyReportOnly: policy} + staticHeader := cspReportOnlyHeader + if nonceHeader == cspHeader { + options.ContentSecurityPolicy = "script-src $NONCE" + } else { + options.ContentSecurityPolicyReportOnly = "script-src $NONCE" + staticHeader = cspHeader + } + + res := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/foo", nil) + New(options).Handler(cspHandler).ServeHTTP(res, req) + + nonce := res.Body.String() + if nonce == "" { + t.Fatal("handler received no nonce") + } + expect(t, res.Header().Get(nonceHeader), "script-src 'nonce-"+nonce+"'") + expect(t, res.Header().Get(staticHeader), policy) + }) + } + } +} + +func TestCSPNoncePreservesPercentEncodedURLs(t *testing.T) { + const policy = "script-src $NONCE; style-src $NONCE; report-uri https://example.com/a%20b%2Fc%25" + cases := []struct { + name string + options Options + headers []string + }{ + {"enforced", Options{ContentSecurityPolicy: policy}, []string{cspHeader}}, + {"report only", Options{ContentSecurityPolicyReportOnly: policy}, []string{cspReportOnlyHeader}}, + { + "both", + Options{ContentSecurityPolicy: policy, ContentSecurityPolicyReportOnly: policy}, + []string{cspHeader, cspReportOnlyHeader}, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + handler := New(c.options).Handler(cspHandler) + var previousNonce string + for request := 0; request < 2; request++ { + res := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/foo", nil) + handler.ServeHTTP(res, req) + + nonce := res.Body.String() + if nonce == "" || nonce == previousNonce { + t.Fatal("handler did not receive a fresh nonce") + } + previousNonce = nonce + + want := "script-src 'nonce-" + nonce + "'; style-src 'nonce-" + nonce + + "'; report-uri https://example.com/a%20b%2Fc%25" + for _, header := range c.headers { + expect(t, res.Header().Get(header), want) + } + expect(t, CSPNonce(req.Context()), "") + } + }) + } +} diff --git a/secure.go b/secure.go index 122c43c..302ec15 100644 --- a/secure.go +++ b/secure.go @@ -156,10 +156,7 @@ func New(options ...Options) *Secure { o = options[0] } - o.ContentSecurityPolicy = strings.ReplaceAll(o.ContentSecurityPolicy, "$NONCE", "'nonce-%[1]s'") - o.ContentSecurityPolicyReportOnly = strings.ReplaceAll(o.ContentSecurityPolicyReportOnly, "$NONCE", "'nonce-%[1]s'") - - o.nonceEnabled = strings.Contains(o.ContentSecurityPolicy, "%[1]s") || strings.Contains(o.ContentSecurityPolicyReportOnly, "%[1]s") + o.nonceEnabled = strings.Contains(o.ContentSecurityPolicy, "$NONCE") || strings.Contains(o.ContentSecurityPolicyReportOnly, "$NONCE") s := &Secure{ opt: o, @@ -445,20 +442,12 @@ func (s *Secure) processRequest(w http.ResponseWriter, r *http.Request) (http.He // Content Security Policy header. if len(s.opt.ContentSecurityPolicy) > 0 { - if s.opt.nonceEnabled { - responseHeader.Set(cspHeader, fmt.Sprintf(s.opt.ContentSecurityPolicy, CSPNonce(r.Context()))) - } else { - responseHeader.Set(cspHeader, s.opt.ContentSecurityPolicy) - } + responseHeader.Set(cspHeader, strings.ReplaceAll(s.opt.ContentSecurityPolicy, "$NONCE", "'nonce-"+CSPNonce(r.Context())+"'")) } // Content Security Policy Report Only header. if len(s.opt.ContentSecurityPolicyReportOnly) > 0 { - if s.opt.nonceEnabled { - responseHeader.Set(cspReportOnlyHeader, fmt.Sprintf(s.opt.ContentSecurityPolicyReportOnly, CSPNonce(r.Context()))) - } else { - responseHeader.Set(cspReportOnlyHeader, s.opt.ContentSecurityPolicyReportOnly) - } + responseHeader.Set(cspReportOnlyHeader, strings.ReplaceAll(s.opt.ContentSecurityPolicyReportOnly, "$NONCE", "'nonce-"+CSPNonce(r.Context())+"'")) } // Referrer Policy header.