Merge remote-tracking branch 'remotes/from/ce/main'

This commit is contained in:
hc-github-team-secure-vault-core 2026-04-17 16:19:08 +00:00
commit 524e02f814
8 changed files with 166 additions and 7 deletions

3
changelog/_13303.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: `unsafe-inline` is no longer included in the `style-src` CSP directive.
```

View File

@ -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(`<meta name="csp-nonce" content="%s">`, nrw.nonce)
htmlContent = strings.Replace(htmlContent, "<head>", "<head>\n "+nonceMetaTag, 1)
}
// Write the modified content
nrw.ResponseWriter.Write([]byte(htmlContent))
}
func handleUIStub() http.Handler {
stubHTML := `
<!DOCTYPE html>

View File

@ -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)
}
}

View File

@ -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

View File

@ -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',

View File

@ -20,6 +20,7 @@
{{else}}
<Hds::CodeEditor
@ariaLabel={{this.ariaLabel}}
@cspNonce={{this.cspNonce}}
@extraKeys={{@extraKeys}}
@hasCopyButton={{true}}
@hasFullScreenButton={{@hasFullScreenButton}}

View File

@ -50,6 +50,12 @@ export default class JsonEditorComponent extends Component {
return this.args.title ?? 'JSON Editor';
}
get cspNonce() {
// Read the CSP nonce from the meta tag injected by the server
const metaTag = document.querySelector('meta[name="csp-nonce"]');
return metaTag ? metaTag.getAttribute('content') : null;
}
@action
onSetup(editor) {
this._codemirrorEditor = editor;

View File

@ -43,7 +43,7 @@ func NewUIConfig(enabled bool, physicalStorage physical.Backend, barrierStorage
if isHVD != "" && isHVD != "0" { // only if HVD, set connect-src to include posthog
connectSrcHeader = "connect-src 'self' https://eu.i.posthog.com;"
}
defaultHeaders.Set("Content-Security-Policy", "default-src 'none'; "+connectSrcHeader+" img-src 'self' data:; script-src 'self'; style-src 'unsafe-inline' 'self'; form-action 'none'; frame-ancestors 'none'; font-src 'self'")
defaultHeaders.Set("Content-Security-Policy", "default-src 'none'; "+connectSrcHeader+" img-src 'self' data:; script-src 'self'; style-src 'self'; form-action 'none'; frame-ancestors 'none'; font-src 'self'")
return &UIConfig{
physicalStorage: physicalStorage,
barrierStorage: barrierStorage,