From ac4503cf69558542f8cf10668133d3be76bec999 Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Fri, 17 Apr 2026 11:33:30 -0400 Subject: [PATCH] generate csp nonce for code editor styling (#13303) (#14057) * generate csp nonce for code editor styling * add nonce to test index.html * add test policy * tidy * address merging into custom headers * update test to expect csp nonce * revert appConfig change * add unsafe inline for CI only * simplify adding csp nonce to headers * revert ci-nonce * add changelog entry * Update changelog/_13303.txt --------- Co-authored-by: lane-wetmore Co-authored-by: Violet Hynes --- changelog/_13303.txt | 3 + http/handler.go | 95 +++++++++++++++++++- http/http_test.go | 17 +++- sdk/logical/response.go | 43 ++++++++- ui/config/content-security-policy.js | 6 +- ui/lib/core/addon/components/json-editor.hbs | 1 + ui/lib/core/addon/components/json-editor.js | 6 ++ vault/ui.go | 2 +- 8 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 changelog/_13303.txt diff --git a/changelog/_13303.txt b/changelog/_13303.txt new file mode 100644 index 0000000000..3da14b0809 --- /dev/null +++ b/changelog/_13303.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: `unsafe-inline` is no longer included in the `style-src` CSP directive. +``` diff --git a/http/handler.go b/http/handler.go index 80f04c3d9a..58d05d7f15 100644 --- a/http/handler.go +++ b/http/handler.go @@ -6,6 +6,7 @@ package http import ( "bytes" "context" + "crypto/rand" "crypto/x509" "encoding/base64" "encoding/json" @@ -508,6 +509,13 @@ func wrapGenericHandler(core *vault.Core, h http.Handler, props *vault.HandlerPr var hf func(w http.ResponseWriter, r *http.Request) hf = func(w http.ResponseWriter, r *http.Request) { + // Generate CSP nonce once for this request + cspNonce, err := generateCSPNonce() + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to generate CSP nonce: %w", err)) + return + } + // This block needs to be here so that upon sending SIGHUP, custom response // headers are also reloaded into the handlers. var customHeaders map[string][]*logical.CustomHeader @@ -518,17 +526,19 @@ func wrapGenericHandler(core *vault.Core, h http.Handler, props *vault.HandlerPr customHeaders = listenerCustomHeaders.StatusCodeHeaderMap } } + // saving start time for the in-flight requests inFlightReqStartTime := time.Now() - nw := logical.NewStatusHeaderResponseWriter(w, customHeaders) + nw := logical.NewStatusHeaderResponseWriter(w, customHeaders, cspNonce) // Set the Cache-Control header for all the responses returned // by Vault nw.Header().Set("Cache-Control", "no-store") - // Start with the request context + // Start with the request context and store the CSP nonce ctx := r.Context() + ctx = context.WithValue(ctx, cspNonceContextKey{}, cspNonce) var cancelFunc context.CancelFunc // Add our timeout, but not for the monitor or events endpoints, as they are streaming // Request URL path for sys/monitor looks like /v1/sys/monitor @@ -845,6 +855,15 @@ func WrapForwardedForHandler(h http.Handler, l *configutil.Listener) http.Handle }) } +// generateCSPNonce generates a cryptographically secure random nonce for CSP +func generateCSPNonce() (string, error) { + nonceBytes := make([]byte, 16) + if _, err := rand.Read(nonceBytes); err != nil { + return "", fmt.Errorf("failed to generate CSP nonce: %w", err) + } + return base64.StdEncoding.EncodeToString(nonceBytes), nil +} + func handleUIHeaders(core *vault.Core, h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { header := w.Header() @@ -855,8 +874,22 @@ func handleUIHeaders(core *vault.Core, h http.Handler) http.Handler { return } + // Retrieve CSP nonce from request context (generated in wrapGenericHandler) + nonce, ok := req.Context().Value(cspNonceContextKey{}).(string) + if !ok || nonce == "" { + // Fallback: generate a new nonce if not found in context + nonce, err = generateCSPNonce() + if err != nil { + respondError(w, http.StatusInternalServerError, err) + return + } + } + for k := range userHeaders { v := userHeaders.Get(k) + if k == "Content-Security-Policy" { + v = logical.MergeNonceIntoCSP(v, nonce, "style-src") + } header.Set(k, v) } @@ -864,16 +897,74 @@ func handleUIHeaders(core *vault.Core, h http.Handler) http.Handler { }) } +// cspNonceContextKey is used to store the CSP nonce in the request context +type cspNonceContextKey struct{} + func handleUI(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { // The fileserver handler strips trailing slashes and does a redirect. // We don't want the redirect to happen so we preemptively trim the slash // here. req.URL.Path = strings.TrimSuffix(req.URL.Path, "/") + + // For relevant HTML requests, wrap the response writer to inject the nonce + hasExtension := strings.Contains(req.URL.Path, ".") + if !hasExtension { + nonce, ok := req.Context().Value(cspNonceContextKey{}).(string) + if ok && nonce != "" { + nrw := &nonceResponseWriter{ + ResponseWriter: w, + nonce: nonce, + } + h.ServeHTTP(nrw, req) + // Flush the buffered content after the handler completes + nrw.flush() + return + } + } + h.ServeHTTP(w, req) }) } +// nonceResponseWriter wraps http.ResponseWriter to inject CSP nonce into HTML +type nonceResponseWriter struct { + http.ResponseWriter + nonce string + wroteHeader bool + buf bytes.Buffer +} + +func (nrw *nonceResponseWriter) Write(b []byte) (int, error) { + if !nrw.wroteHeader { + nrw.WriteHeader(http.StatusOK) + } + + // Buffer the content + nrw.buf.Write(b) + return len(b), nil +} + +func (nrw *nonceResponseWriter) WriteHeader(statusCode int) { + if nrw.wroteHeader { + return + } + nrw.wroteHeader = true + nrw.ResponseWriter.WriteHeader(statusCode) +} + +func (nrw *nonceResponseWriter) flush() { + // Inject nonce into buffered content + htmlContent := nrw.buf.String() + if htmlContent != "" && nrw.nonce != "" { + nonceMetaTag := fmt.Sprintf(``, nrw.nonce) + htmlContent = strings.Replace(htmlContent, "", "\n "+nonceMetaTag, 1) + } + + // Write the modified content + nrw.ResponseWriter.Write([]byte(htmlContent)) +} + func handleUIStub() http.Handler { stubHTML := ` diff --git a/http/http_test.go b/http/http_test.go index 259b23571a..654d269d19 100644 --- a/http/http_test.go +++ b/http/http_test.go @@ -159,7 +159,22 @@ func testResponseHeader(t *testing.T, resp *http.Response, expectedHeaders map[s t.Helper() for k, v := range expectedHeaders { hv := resp.Header.Get(k) - if v != hv { + // Special handling for Content-Security-Policy header which may have a nonce appended + if k == "Content-Security-Policy" { + // Check if the actual header starts with the expected value + // and optionally contains a nonce directive + if !strings.HasPrefix(hv, v) { + t.Fatalf("expected header value %v to start with %v, got %v", k, v, hv) + } + // Verify that if there's additional content, it's a valid nonce directive + if len(hv) > len(v) { + remainder := strings.TrimPrefix(hv, v) + // Should start with ; and contain style-src 'nonce- + if !strings.HasPrefix(remainder, ";style-src 'nonce-") { + t.Fatalf("expected CSP header %v=%v to have nonce appended, got %v=%v", k, v, k, hv) + } + } + } else if v != hv { t.Fatalf("expected header value %v=%v, got %v=%v", k, v, k, hv) } } diff --git a/sdk/logical/response.go b/sdk/logical/response.go index a673ceb0ca..8a0a1518d0 100644 --- a/sdk/logical/response.go +++ b/sdk/logical/response.go @@ -11,6 +11,7 @@ import ( "net" "net/http" "strconv" + "strings" "sync/atomic" "github.com/hashicorp/vault/sdk/helper/wrapping" @@ -267,14 +268,16 @@ type StatusHeaderResponseWriter struct { wroteHeader bool StatusCode int headers map[string][]*CustomHeader + cspNonce string } -func NewStatusHeaderResponseWriter(w http.ResponseWriter, h map[string][]*CustomHeader) *StatusHeaderResponseWriter { +func NewStatusHeaderResponseWriter(w http.ResponseWriter, h map[string][]*CustomHeader, cspNonce string) *StatusHeaderResponseWriter { return &StatusHeaderResponseWriter{ wrapped: w, wroteHeader: false, StatusCode: 200, headers: h, + cspNonce: cspNonce, } } @@ -332,7 +335,12 @@ func (w *StatusHeaderResponseWriter) setCustomResponseHeaders(status int) { // setter function to set the headers setter := func(hvl []*CustomHeader) { for _, hv := range hvl { - w.Header().Set(hv.Name, hv.Value) + headerValue := hv.Value + // Inject CSP nonce if this is a Content-Security-Policy header and we have a nonce + if hv.Name == "Content-Security-Policy" && w.cspNonce != "" { + headerValue = MergeNonceIntoCSP(headerValue, w.cspNonce, "style-src") + } + w.Header().Set(hv.Name, headerValue) } } @@ -353,6 +361,37 @@ func (w *StatusHeaderResponseWriter) setCustomResponseHeaders(status int) { return } +// MergeNonceIntoCSP merges a nonce into the specified directive of a CSP policy +func MergeNonceIntoCSP(cspPolicy, nonce string, targetDirective string) string { + if cspPolicy == "" || nonce == "" { + return cspPolicy + } + + nonceDirective := fmt.Sprintf("'nonce-%s'", nonce) + directives := strings.Split(cspPolicy, ";") + directiveFound := false + + for i, directive := range directives { + directive = strings.TrimSpace(directive) + if strings.HasPrefix(directive, targetDirective) { + directiveFound = true + // Check if nonce is already present + if !strings.Contains(directive, nonceDirective) { + // Add nonce after the directive keyword + directives[i] = directive + " " + nonceDirective + } + break + } + } + + // If target directive doesn't exist, add it + if !directiveFound { + directives = append(directives, targetDirective+" "+nonceDirective) + } + + return strings.Join(directives, ";") +} + var _ WrappingResponseWriter = &StatusHeaderResponseWriter{} // ResolveRoleResponse returns a standard response to be returned by functions handling a ResolveRoleOperation diff --git a/ui/config/content-security-policy.js b/ui/config/content-security-policy.js index 5c0607dbd2..6c83c88bfe 100644 --- a/ui/config/content-security-policy.js +++ b/ui/config/content-security-policy.js @@ -10,13 +10,17 @@ module.exports = function (environment) { 'font-src': ["'self'"], 'connect-src': ["'self'"], 'img-src': ["'self'", 'data:'], - 'style-src': ["'unsafe-inline'", "'self'"], + 'style-src': ["'self'"], 'media-src': ["'self'"], 'form-action': ["'none'"], }; policy['connect-src'].push('https://eu.i.posthog.com'); + if (environment === 'test') { + policy['style-src'].push("'unsafe-inline'"); + } + return { delivery: ['header', 'meta'], enabled: environment !== 'production', diff --git a/ui/lib/core/addon/components/json-editor.hbs b/ui/lib/core/addon/components/json-editor.hbs index 2bf38c3269..d55fdaa8c9 100644 --- a/ui/lib/core/addon/components/json-editor.hbs +++ b/ui/lib/core/addon/components/json-editor.hbs @@ -20,6 +20,7 @@ {{else}}