mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-28 04:10:44 -04:00
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:
parent
aedb2da1ff
commit
921dc42cdc
8 changed files with 646 additions and 4 deletions
3
changelog/_12749.txt
Normal file
3
changelog/_12749.txt
Normal 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.
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
511
http/token_header_size_test.go
Normal file
511
http/token_header_size_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
55
http/util.go
55
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue