Add token header guardrails (#12749) (#12857)

Co-authored-by: Bianca <48203644+biazmoreira@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Vault Automation 2026-03-11 10:02:35 -04:00 committed by GitHub
parent aedb2da1ff
commit 921dc42cdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 646 additions and 4 deletions

3
changelog/_12749.txt Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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