mirror of
https://github.com/hashicorp/vault.git
synced 2026-03-30 02:42:15 +02:00
Co-authored-by: Bianca <48203644+biazmoreira@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
512 lines
17 KiB
Go
512 lines
17 KiB
Go
// 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")
|
||
})
|
||
}
|