diff --git a/changelog/_12749.txt b/changelog/_12749.txt new file mode 100644 index 0000000000..9136ce5989 --- /dev/null +++ b/changelog/_12749.txt @@ -0,0 +1,3 @@ +```release-note:security +http: Added configurable `max_token_header_size` listener option (default 8 KB) to bound the size of authentication token headers (`X-Vault-Token` and `Authorization: Bearer`), preventing a potential denial-of-service attack via oversized header contents. The stdlib-level `MaxHeaderBytes` backstop is also now set on the HTTP server. Set `max_token_header_size = -1` to disable the limit. +``` diff --git a/command/server.go b/command/server.go index 3a6445ad82..031fe98937 100644 --- a/command/server.go +++ b/command/server.go @@ -691,6 +691,7 @@ func (c *ServerCommand) runRecoveryMode() int { ReadTimeout: 30 * time.Second, IdleTimeout: 5 * time.Minute, ErrorLog: c.logger.StandardLogger(nil), + MaxHeaderBytes: vaulthttp.TokenHeaderMaxBytes(ln.Config), } go server.Serve(ln.Listener) @@ -1516,7 +1517,8 @@ func (c *ServerCommand) Run(args []string) int { core.SetClusterHandler(vaulthttp.Handler.Handler(&vault.HandlerProperties{ Core: core, ListenerConfig: &configutil.Listener{ - DisableJSONLimitParsing: true, + DisableJSONLimitParsing: true, + DisableTokenHeaderSizeParsing: true, }, })) @@ -3194,6 +3196,7 @@ func startHttpServers(c *ServerCommand, core *vault.Core, config *server.Config, ReadTimeout: 30 * time.Second, IdleTimeout: 5 * time.Minute, ErrorLog: c.logger.StandardLogger(nil), + MaxHeaderBytes: vaulthttp.TokenHeaderMaxBytes(ln.Config), } // override server defaults with config values for read/write/idle timeouts if configured diff --git a/http/handler.go b/http/handler.go index 00d0d13876..59230666c3 100644 --- a/http/handler.go +++ b/http/handler.go @@ -94,6 +94,12 @@ const ( // to pass the snapshot ID VaultSnapshotRecoverHeader = "X-Vault-Recover-Snapshot-Id" + // DefaultMaxTokenHeaderSize is the default maximum size in bytes for an + // authentication token passed in the X-Vault-Token and Authorization: Bearer + // headers. This is to prevent a denial of service attack via unbounded + // header values. Can be overridden per listener. + DefaultMaxTokenHeaderSize = 8 * 1024 // 8 KB + // CustomMaxJSONDepth specifies the maximum nesting depth of a JSON object. // This limit is designed to prevent stack exhaustion attacks from deeply // nested JSON payloads, which could otherwise lead to a denial-of-service @@ -181,6 +187,25 @@ var ( oidcProtectedPathRegex = regexp.MustCompile(`^identity/oidc/provider/\w(([\w-.]+)?\w)?/userinfo$`) ) +// TokenHeaderMaxBytes returns the http.Server.MaxHeaderBytes value for the +// given listener configuration. A negative CustomMaxTokenHeaderSize disables +// the limit; zero falls back to DefaultMaxTokenHeaderSize. +func TokenHeaderMaxBytes(lnConfig *configutil.Listener) int { + if lnConfig == nil { + return DefaultMaxTokenHeaderSize + } + switch { + case lnConfig.CustomMaxTokenHeaderSize < 0: + // Limit explicitly disabled; leave http.Server.MaxHeaderBytes unset so + // the stdlib default (1 MB) applies. + return 0 + case lnConfig.CustomMaxTokenHeaderSize > 0: + return int(lnConfig.CustomMaxTokenHeaderSize) + default: + return DefaultMaxTokenHeaderSize + } +} + func init() { alwaysRedirectPaths.AddPaths([]string{ "sys/storage/raft/snapshot", @@ -313,6 +338,7 @@ func handler(props *vault.HandlerProperties) http.Handler { wrappedHandler = rateLimitQuotaWrapping(wrappedHandler, core) wrappedHandler = entWrapGenericHandler(core, wrappedHandler, props) wrappedHandler = wrapMaxRequestSizeHandler(wrappedHandler, props) + wrappedHandler = wrapTokenHeaderSizeHandler(wrappedHandler, props) wrappedHandler = priority.WrapRequestPriorityHandler(wrappedHandler) // Add an extra wrapping handler if the DisablePrintableCheck listener diff --git a/http/testing.go b/http/testing.go index 795582ee58..c29c35c79c 100644 --- a/http/testing.go +++ b/http/testing.go @@ -36,10 +36,16 @@ func TestServerWithListenerAndProperties(tb testing.TB, ln net.Listener, addr st mux.Handle("/_test/auth", http.HandlerFunc(testHandleAuth)) mux.Handle("/", Handler.Handler(props)) + var lnConfig *configutil.Listener + if props != nil { + lnConfig = props.ListenerConfig + } + server := &http.Server{ - Addr: ln.Addr().String(), - Handler: mux, - ErrorLog: core.Logger().StandardLogger(nil), + Addr: ln.Addr().String(), + Handler: mux, + ErrorLog: core.Logger().StandardLogger(nil), + MaxHeaderBytes: TokenHeaderMaxBytes(lnConfig), } go server.Serve(ln) } diff --git a/http/token_header_size_test.go b/http/token_header_size_test.go new file mode 100644 index 0000000000..40fa39b2b8 --- /dev/null +++ b/http/token_header_size_test.go @@ -0,0 +1,511 @@ +// Copyright IBM Corp. 2016, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +package http + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + cleanhttp "github.com/hashicorp/go-cleanhttp" + "github.com/hashicorp/vault/internalshared/configutil" + "github.com/hashicorp/vault/sdk/helper/consts" + "github.com/hashicorp/vault/vault" + "github.com/stretchr/testify/require" +) + +// sendTokenHeaderRequest builds and dispatches an HTTP GET to addr, placing +// token in the named header. For "Authorization", the value is wrapped as a +// Bearer token per RFC 6750. +func sendTokenHeaderRequest(t *testing.T, client *http.Client, addr, headerName, token string) (*http.Response, error) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, addr+"/v1/auth/token/lookup-self", nil) + require.NoError(t, err) + if headerName == "Authorization" { + req.Header.Set("Authorization", "Bearer "+token) + } else { + req.Header.Set(headerName, token) + } + return client.Do(req) +} + +// newTestClusterForTokenHeader creates a NewTestCluster with default settings +// and returns the cluster, an HTTP client configured with TLS, and the address. +func newTestClusterForTokenHeader(t *testing.T, opts *vault.TestClusterOptions) (*http.Client, string) { + t.Helper() + if opts == nil { + opts = &vault.TestClusterOptions{} + } + opts.HandlerFunc = Handler + cluster := vault.NewTestCluster(t, nil, opts) + cluster.Start() + + core := cluster.Cores[0] + transport := cleanhttp.DefaultPooledTransport() + transport.TLSClientConfig = core.TLSConfig() + httpClient := &http.Client{Transport: transport, Timeout: 15 * time.Second} + return httpClient, core.Client.Address() +} + +// TestTokenHeader_ExceedsDefaultLimit_IsRejected verifies that tokens larger than +// DefaultMaxTokenHeaderSize are rejected with 400 before token validation occurs. +func TestTokenHeader_ExceedsDefaultLimit_IsRejected(t *testing.T) { + t.Parallel() + + client, addr := newTestClusterForTokenHeader(t, nil) + + cases := []struct { + name string + headerSize int + headerName string + }{ + { + name: "just-over-limit-x-vault-token", + headerSize: DefaultMaxTokenHeaderSize + 1, + headerName: consts.AuthHeaderName, + }, + { + name: "just-over-limit-authorization-bearer", + headerSize: DefaultMaxTokenHeaderSize + 1, + headerName: "Authorization", + }, + { + name: "100kb-x-vault-token", + headerSize: 100 * 1024, + headerName: consts.AuthHeaderName, + }, + { + name: "100kb-authorization-bearer", + headerSize: 100 * 1024, + headerName: "Authorization", + }, + { + name: "900kb-x-vault-token", + headerSize: 900 * 1024, + headerName: consts.AuthHeaderName, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + token := strings.Repeat("x", tc.headerSize) + resp, err := sendTokenHeaderRequest(t, client, addr, tc.headerName, token) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "token of %d bytes must be rejected by Vault middleware (want 400, not 403)", + tc.headerSize) + }) + } +} + +// TestTokenHeader_WithinDefaultLimit_IsProcessed verifies that tokens at or +// below DefaultMaxTokenHeaderSize reach token validation normally (403, not 400). +func TestTokenHeader_WithinDefaultLimit_IsProcessed(t *testing.T) { + t.Parallel() + + client, addr := newTestClusterForTokenHeader(t, nil) + + cases := []struct { + name string + headerSize int + headerName string + }{ + { + name: "512b-x-vault-token", + headerSize: 512, + headerName: consts.AuthHeaderName, + }, + { + name: "512b-authorization-bearer", + headerSize: 512, + headerName: "Authorization", + }, + { + name: "4kb-x-vault-token", + headerSize: 4 * 1024, + headerName: consts.AuthHeaderName, + }, + { + name: "at-limit-x-vault-token", + headerSize: DefaultMaxTokenHeaderSize, + headerName: consts.AuthHeaderName, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + token := strings.Repeat("x", tc.headerSize) + resp, err := sendTokenHeaderRequest(t, client, addr, tc.headerName, token) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusForbidden, resp.StatusCode, + "token of %d bytes is within the limit and must reach validation", tc.headerSize) + }) + } +} + +// TestTokenHeader_ConfigurableLimit_Enforced verifies that an operator can +// lower the token header size limit via the listener configuration, and that +// Vault enforces the configured value rather than DefaultMaxTokenHeaderSize. +func TestTokenHeader_ConfigurableLimit_Enforced(t *testing.T) { + t.Parallel() + + const customLimit = 1024 // 1 KB — well below the default 8 KB + client, addr := newTestClusterForTokenHeader(t, &vault.TestClusterOptions{ + DefaultHandlerProperties: vault.HandlerProperties{ + ListenerConfig: &configutil.Listener{ + CustomMaxTokenHeaderSize: customLimit, + }, + }, + }) + + oversized := strings.Repeat("x", customLimit+1) + resp, err := sendTokenHeaderRequest(t, client, addr, consts.AuthHeaderName, oversized) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "token exceeding custom limit of %d bytes must be rejected with 400", customLimit) + + atLimit := strings.Repeat("x", customLimit) + resp2, err := sendTokenHeaderRequest(t, client, addr, consts.AuthHeaderName, atLimit) + require.NoError(t, err) + defer resp2.Body.Close() + + require.Equal(t, http.StatusForbidden, resp2.StatusCode, + "token at the custom limit (%d bytes) must reach validation", customLimit) +} + +// TestTokenHeader_MultipleAuthorizationHeaders_BypassPrevented verifies that an +// oversized Bearer token is rejected even when a non-Bearer Authorization header +// precedes it in the request. +func TestTokenHeader_MultipleAuthorizationHeaders_BypassPrevented(t *testing.T) { + t.Parallel() + + client, addr := newTestClusterForTokenHeader(t, nil) + + oversizedBearer := strings.Repeat("x", DefaultMaxTokenHeaderSize+1) + + req, err := http.NewRequest(http.MethodGet, addr+"/v1/auth/token/lookup-self", nil) + require.NoError(t, err) + req.Header.Add("Authorization", "Basic dXNlcjpwYXNz") + req.Header.Add("Authorization", "Bearer "+oversizedBearer) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "oversized Bearer token must be rejected even when a non-Bearer Authorization header precedes it") +} + +// TestTokenHeader_DisabledLimit_AllowsOversizedToken verifies that setting +// max_token_header_size = -1 in the listener config fully disables the check, +// allowing tokens larger than DefaultMaxTokenHeaderSize to reach validation. +func TestTokenHeader_DisabledLimit_AllowsOversizedToken(t *testing.T) { + t.Parallel() + + client, addr := newTestClusterForTokenHeader(t, &vault.TestClusterOptions{ + DefaultHandlerProperties: vault.HandlerProperties{ + ListenerConfig: &configutil.Listener{ + CustomMaxTokenHeaderSize: -1, + }, + }, + }) + + oversized := strings.Repeat("x", DefaultMaxTokenHeaderSize*2) + resp, err := sendTokenHeaderRequest(t, client, addr, consts.AuthHeaderName, oversized) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusForbidden, resp.StatusCode, + "with max_token_header_size = -1 the size guard must not fire; token must reach validation") +} + +// TestTokenHeader_ErrorResponseFormat verifies that oversized-token rejections +// return a valid Vault JSON error envelope with an "errors" array. +func TestTokenHeader_ErrorResponseFormat(t *testing.T) { + t.Parallel() + + client, addr := newTestClusterForTokenHeader(t, nil) + + token := strings.Repeat("x", DefaultMaxTokenHeaderSize+1) + resp, err := sendTokenHeaderRequest(t, client, addr, consts.AuthHeaderName, token) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + require.Equal(t, "application/json", resp.Header.Get("Content-Type")) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var envelope struct { + Errors []string `json:"errors"` + } + require.NoError(t, json.Unmarshal(body, &envelope), + "response body must be valid JSON: %s", string(body)) + require.NotEmpty(t, envelope.Errors, + "response must contain at least one error message") + require.Contains(t, envelope.Errors[0], "authentication token", + "error message must describe the token size violation") +} + +// TestTokenHeader_NoAuthHeader_Unaffected verifies that requests with no +// authentication header pass through wrapTokenHeaderSizeHandler unchanged. +func TestTokenHeader_NoAuthHeader_Unaffected(t *testing.T) { + t.Parallel() + + client, addr := newTestClusterForTokenHeader(t, nil) + + req, err := http.NewRequest(http.MethodGet, addr+"/v1/auth/token/lookup-self", nil) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusForbidden, resp.StatusCode, + "requests with no auth header must not be rejected by the size guard") +} + +// BenchmarkTokenHeader_ProcessingCost measures the per-request overhead of +// wrapTokenHeaderSizeHandler at increasing token sizes. +func BenchmarkTokenHeader_ProcessingCost(b *testing.B) { + core, _, _ := vault.TestCoreUnsealed(b) + ln, addr := TestServer(b, core) + defer ln.Close() + + sizes := []struct { + label string + bytes int + }{ + {"1KB", 1 * 1024}, + {"8KB", 8 * 1024}, // at default limit + {"64KB", 64 * 1024}, // above default limit — fast-rejected by wrapTokenHeaderSizeHandler + {"512KB", 512 * 1024}, + } + + client := cleanhttp.DefaultClient() + client.Timeout = 30 * time.Second + + for _, sz := range sizes { + token := strings.Repeat("x", sz.bytes) + b.Run(fmt.Sprintf("size=%s", sz.label), func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + req, _ := http.NewRequest(http.MethodGet, addr+"/v1/auth/token/lookup-self", nil) + req.Header.Set(consts.AuthHeaderName, token) + resp, err := client.Do(req) + if err == nil { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + } + }) + } +} + +// TestTokenHeaderMaxBytes_NilConfig verifies that a nil listener config +// returns DefaultMaxTokenHeaderSize. +func TestTokenHeaderMaxBytes_NilConfig(t *testing.T) { + t.Parallel() + require.Equal(t, DefaultMaxTokenHeaderSize, TokenHeaderMaxBytes(nil)) +} + +// TestTokenHeaderMaxBytes_ZeroCustomSize verifies that a zero CustomMaxTokenHeaderSize +// falls back to DefaultMaxTokenHeaderSize. +func TestTokenHeaderMaxBytes_ZeroCustomSize(t *testing.T) { + t.Parallel() + lnConfig := &configutil.Listener{} // CustomMaxTokenHeaderSize == 0 + require.Equal(t, DefaultMaxTokenHeaderSize, TokenHeaderMaxBytes(lnConfig)) +} + +// TestTokenHeaderMaxBytes_CustomSize verifies that a positive CustomMaxTokenHeaderSize +// is returned verbatim. +func TestTokenHeaderMaxBytes_CustomSize(t *testing.T) { + t.Parallel() + lnConfig := &configutil.Listener{CustomMaxTokenHeaderSize: 4096} + require.Equal(t, 4096, TokenHeaderMaxBytes(lnConfig)) +} + +// TestTokenHeaderMaxBytes_Disabled verifies that max_token_header_size = -1 +// returns 0, leaving http.Server.MaxHeaderBytes at the Go stdlib default. +func TestTokenHeaderMaxBytes_Disabled(t *testing.T) { + t.Parallel() + lnConfig := &configutil.Listener{CustomMaxTokenHeaderSize: -1} + require.Equal(t, 0, TokenHeaderMaxBytes(lnConfig)) +} + +// TestTokenHeader_StdlibBackstop_NonAuthHeaderRejected verifies that setting +// MaxHeaderBytes on http.Server rejects oversized non-authentication headers that +// wrapTokenHeaderSizeHandler does not inspect. +func TestTokenHeader_StdlibBackstop_NonAuthHeaderRejected(t *testing.T) { + t.Parallel() + + core, _, _ := vault.TestCoreUnsealed(t) + ln, addr := TestServer(t, core) + defer ln.Close() + + oversizedValue := strings.Repeat("x", DefaultMaxTokenHeaderSize*2) + + req, err := http.NewRequest(http.MethodGet, addr+"/v1/sys/health", nil) + require.NoError(t, err) + req.Header.Set("X-Evil-Header", oversizedValue) + + client := cleanhttp.DefaultClient() + client.Timeout = 15 * time.Second + resp, err := client.Do(req) + if err == nil { + defer resp.Body.Close() + require.NotEqual(t, http.StatusOK, resp.StatusCode, + "oversized non-auth header must not reach the application layer") + } +} + +// TestTokenHeaderMaxBytes_ServerUsesCorrectDefault verifies that an http.Server +// built with TokenHeaderMaxBytes(nil) enforces the limit on any header. +func TestTokenHeaderMaxBytes_ServerUsesCorrectDefault(t *testing.T) { + t.Parallel() + + reached := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + w.WriteHeader(http.StatusOK) + }) + + srv := &http.Server{ + Handler: inner, + MaxHeaderBytes: TokenHeaderMaxBytes(nil), + } + + // Start on a random port. + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + go func() { + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + t.Errorf("srv.Serve: %v", err) + } + }() + t.Cleanup(func() { srv.Close() }) + + addr := "http://" + ln.Addr().String() + + t.Run("within_limit_reaches_handler", func(t *testing.T) { + reached = false + req, err := http.NewRequest(http.MethodGet, addr+"/", nil) + require.NoError(t, err) + req.Header.Set("X-Test", strings.Repeat("a", DefaultMaxTokenHeaderSize-200)) + resp, err := cleanhttp.DefaultClient().Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.True(t, reached, "handler should have been called for a within-limit request") + }) + + t.Run("exceeds_limit_rejected_by_stdlib", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, addr+"/", nil) + require.NoError(t, err) + // 2× to exceed the stdlib's effective limit (MaxHeaderBytes + 4096). + req.Header.Set("X-Evil", strings.Repeat("x", DefaultMaxTokenHeaderSize*2)) + resp, err := cleanhttp.DefaultClient().Do(req) + if err == nil { + defer resp.Body.Close() + require.NotEqual(t, http.StatusOK, resp.StatusCode, + "stdlib must reject a request whose headers exceed MaxHeaderBytes") + } + }) +} + +// TestTokenHeader_ClusterListener_SkipsCheck verifies that the cluster listener +// does not re-enforce the token header size limit on forwarded requests. +// This prevents a regression where a user raising CustomMaxTokenHeaderSize above +// the default would see forwarded requests rejected by the active node's cluster +// listener, which only knows the default limit (same failure mode as the JSON +// limits regression fixed by DisableJSONLimitParsing). +func TestTokenHeader_ClusterListener_SkipsCheck(t *testing.T) { + // A token that exceeds the default limit but would be allowed by a custom + // API-listener limit of 16 KB. The cluster listener must pass it through + // without re-checking. + oversizedForDefault := strings.Repeat("a", DefaultMaxTokenHeaderSize+100) + + // Cluster listener is configured identically to how server.go sets it up: + // DisableTokenHeaderSizeParsing = true, no CustomMaxTokenHeaderSize override. + clusterProps := &vault.HandlerProperties{ + ListenerConfig: &configutil.Listener{ + DisableTokenHeaderSizeParsing: true, + }, + } + + reached := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + w.WriteHeader(http.StatusOK) + }) + + clusterHandler := wrapTokenHeaderSizeHandler(inner, clusterProps) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + srv := &http.Server{Handler: clusterHandler} + go func() { + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + t.Errorf("srv.Serve: %v", err) + } + }() + t.Cleanup(func() { srv.Close() }) + + addr := "http://" + ln.Addr().String() + + t.Run("oversized_token_passes_cluster_listener", func(t *testing.T) { + reached = false + req, err := http.NewRequest(http.MethodGet, addr+"/", nil) + require.NoError(t, err) + req.Header.Set(consts.AuthHeaderName, oversizedForDefault) + resp, err := cleanhttp.DefaultClient().Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, + "cluster listener must not re-enforce the token size limit on forwarded requests") + require.True(t, reached, "inner handler should be reached on the cluster listener") + }) + + t.Run("api_listener_still_enforces_default_limit", func(t *testing.T) { + // Confirm the same token is rejected by a normal API-listener handler + // (no DisableTokenHeaderSizeParsing), so we know the test token really does + // exceed the default limit. + apiProps := &vault.HandlerProperties{ + ListenerConfig: &configutil.Listener{}, + } + apiHandler := wrapTokenHeaderSizeHandler(inner, apiProps) + + apiLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + apiSrv := &http.Server{Handler: apiHandler} + go func() { + if err := apiSrv.Serve(apiLn); err != nil && err != http.ErrServerClosed { + t.Errorf("apiSrv.Serve: %v", err) + } + }() + t.Cleanup(func() { apiSrv.Close() }) + + req, err := http.NewRequest(http.MethodGet, "http://"+apiLn.Addr().String()+"/", nil) + require.NoError(t, err) + req.Header.Set(consts.AuthHeaderName, oversizedForDefault) + resp, err := cleanhttp.DefaultClient().Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "API listener must reject a token that exceeds the default size limit") + }) +} diff --git a/http/util.go b/http/util.go index b89255888a..3efef4a092 100644 --- a/http/util.go +++ b/http/util.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/go-multierror" "github.com/hashicorp/vault/helper/namespace" "github.com/hashicorp/vault/limits" + "github.com/hashicorp/vault/sdk/helper/consts" "github.com/hashicorp/vault/sdk/helper/jsonutil" "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/vault" @@ -23,6 +24,8 @@ import ( var nonVotersAllowed = false +const maxTokenHeaderSizeDefault = 0 + // ctxKeyRoleBasedQuota is used to signal that role-based quota resolution // is needed for the request. type ctxKeyRoleBasedQuota struct{} @@ -45,6 +48,58 @@ func resetBodyIfRead(r *http.Request, buf *bytes.Buffer) *http.Request { return r } +// wrapTokenHeaderSizeHandler rejects requests whose authentication token header +// exceeds the configured size limit. +func wrapTokenHeaderSizeHandler(handler http.Handler, props *vault.HandlerProperties) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var maxTokenHeaderSize int64 + if props.ListenerConfig != nil { + // Skip the check on the cluster listener. Forwarded requests have + // already been validated at the API listener on the originating node, + // so re-checking here would cause a regression when a custom limit + // larger than the default is configured. + if props.ListenerConfig.DisableTokenHeaderSizeParsing { + handler.ServeHTTP(w, r) + return + } + maxTokenHeaderSize = props.ListenerConfig.CustomMaxTokenHeaderSize + } + if maxTokenHeaderSize == maxTokenHeaderSizeDefault { + maxTokenHeaderSize = DefaultMaxTokenHeaderSize + } + + if maxTokenHeaderSize > 0 { + bearerPrefix := "Bearer " + tokenLen := int64(len(r.Header.Get(consts.AuthHeaderName))) + if tokenLen > maxTokenHeaderSize { + respondError(w, http.StatusBadRequest, + fmt.Errorf("authentication token exceeds maximum allowed header size of %d bytes", maxTokenHeaderSize)) + return + } + + // Iterate all Authorization headers to mirror getTokenFromReq, which + // ranges over r.Header["Authorization"] to find the first Bearer value. + // Using r.Header.Get would only check the first header and could be + // bypassed by placing a non-Bearer Authorization header first. + for _, v := range r.Header["Authorization"] { + if !strings.HasPrefix(v, bearerPrefix) { + continue + } + bearerTokenLen := int64(len(strings.TrimSpace(v[len(bearerPrefix):]))) + if bearerTokenLen > maxTokenHeaderSize { + respondError(w, http.StatusBadRequest, + fmt.Errorf("authentication token exceeds maximum allowed header size of %d bytes", maxTokenHeaderSize)) + return + } + // Only the first Bearer value is used by getTokenFromReq; stop after checking it. + break + } + } + + handler.ServeHTTP(w, r) + }) +} + // wrapMaxRequestSizeHandler limits the size of the request body to the // configured size func wrapMaxRequestSizeHandler(handler http.Handler, props *vault.HandlerProperties) http.Handler { diff --git a/internalshared/configutil/listener.go b/internalshared/configutil/listener.go index d0413f8aed..07f0d8a201 100644 --- a/internalshared/configutil/listener.go +++ b/internalshared/configutil/listener.go @@ -176,6 +176,15 @@ type Listener struct { // CustomMaxJSONToken determines the maximum number of tokens in a JSON. CustomMaxJSONTokenRaw interface{} `hcl:"max_json_token"` CustomMaxJSONToken int64 `hcl:"-"` + + // DisableTokenHeaderSizeParsing disables the token header size check. This is only applicable + // to the listener config passed into the Cluster listener since forwarded requests have already + // been checked via the API listener on the originating node. + DisableTokenHeaderSizeParsing bool `hcl:"-"` + + // CustomMaxTokenHeaderSize defines the maximum allowed size in bytes for an authentication token header. + CustomMaxTokenHeaderSizeRaw interface{} `hcl:"max_token_header_size"` + CustomMaxTokenHeaderSize int64 `hcl:"-"` } // AgentAPI allows users to select which parts of the Agent API they want enabled. @@ -499,6 +508,13 @@ func (l *Listener) parseRequestSettings() error { return err } + if err := parseAndClearInt(&l.CustomMaxTokenHeaderSizeRaw, &l.CustomMaxTokenHeaderSize); err != nil { + return fmt.Errorf("error parsing max_token_header_size: %w", err) + } + // A negative value disables the check entirely, matching the max_request_size + // convention. Unlike the CustomMaxJSON* fields, negative is intentionally + // allowed here (not an error). + return nil } diff --git a/internalshared/configutil/listener_test.go b/internalshared/configutil/listener_test.go index 4620f164c6..730ed1dd17 100644 --- a/internalshared/configutil/listener_test.go +++ b/internalshared/configutil/listener_test.go @@ -232,6 +232,8 @@ func TestListener_parseRequestSettings(t *testing.T) { expectedCustomMaxJSONArrayElementCount int64 rawCustomMaxJSONToken any expectedCustomMaxJSONToken int64 + rawCustomMaxTokenHeaderSize any + expectedCustomMaxTokenHeaderSize int64 isErrorExpected bool errorMessage string }{ @@ -323,6 +325,23 @@ func TestListener_parseRequestSettings(t *testing.T) { expectedCustomMaxJSONToken: 500000, isErrorExpected: false, }, + "max-token-header-size-bad": { + rawCustomMaxTokenHeaderSize: "badvalue", + isErrorExpected: true, + errorMessage: "error parsing max_token_header_size", + }, + // -1 is valid: it disables the token header size limit entirely, + // following the same convention as max_request_size = -1. + "max-token-header-size-disable": { + rawCustomMaxTokenHeaderSize: "-1", + expectedCustomMaxTokenHeaderSize: -1, + isErrorExpected: false, + }, + "max-token-header-size-good": { + rawCustomMaxTokenHeaderSize: "4096", + expectedCustomMaxTokenHeaderSize: 4096, + isErrorExpected: false, + }, } for name, tc := range tests { @@ -341,6 +360,7 @@ func TestListener_parseRequestSettings(t *testing.T) { CustomMaxJSONObjectEntryCountRaw: tc.rawCustomMaxJSONObjectEntryCount, CustomMaxJSONArrayElementCountRaw: tc.rawCustomMaxJSONArrayElementCount, CustomMaxJSONTokenRaw: tc.rawCustomMaxJSONToken, + CustomMaxTokenHeaderSizeRaw: tc.rawCustomMaxTokenHeaderSize, } err := l.parseRequestSettings() @@ -357,6 +377,7 @@ func TestListener_parseRequestSettings(t *testing.T) { require.Equal(t, tc.expectedCustomMaxJSONObjectEntryCount, l.CustomMaxJSONObjectEntryCount) require.Equal(t, tc.expectedCustomMaxJSONArrayElementCount, l.CustomMaxJSONArrayElementCount) require.Equal(t, tc.expectedCustomMaxJSONToken, l.CustomMaxJSONToken) + require.Equal(t, tc.expectedCustomMaxTokenHeaderSize, l.CustomMaxTokenHeaderSize) require.Equal(t, tc.expectedRequireRequestHeader, l.RequireRequestHeader) require.Equal(t, tc.expectedDisableRequestLimiter, l.DisableRequestLimiter) require.Equal(t, tc.expectedDuration, l.MaxRequestDuration) @@ -367,6 +388,7 @@ func TestListener_parseRequestSettings(t *testing.T) { require.Nil(t, l.CustomMaxJSONObjectEntryCountRaw) require.Nil(t, l.CustomMaxJSONArrayElementCountRaw) require.Nil(t, l.CustomMaxJSONTokenRaw) + require.Nil(t, l.CustomMaxTokenHeaderSizeRaw) require.Nil(t, l.MaxRequestDurationRaw) require.Nil(t, l.RequireRequestHeaderRaw) require.Nil(t, l.DisableRequestLimiterRaw)