Security context
UnratedGO-2025-3924 CVE-2025-6203Published Sep 8, 2025

HashiCorp Vault Community Edition Denial of Service Though Complex JSON Payloads in github.com/hashicorp/vault

Research this vulnerability

Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.

Affected versions

0 → fixed in 1.20.3

Details

HashiCorp Vault Community Edition Denial of Service Though Complex JSON Payloads in github.com/hashicorp/vault

The fix

Add limit to JSON nesting depth (#31069)

Bianca· Aug 6, 2025, 12:08 PM+1847eedc2b7426
changelog/31069.txt+3 0
@@ -0,0 +1,3 @@
+```release-note:change
+http: Add JSON configurable limits to HTTP handling for JSON payloads: `max_json_depth`, `max_json_string_value_length`, `max_json_object_entry_count`, `max_json_array_element_count`.
+```
command/server.go+20 0
@@ -899,6 +899,26 @@ func (c *ServerCommand) InitListeners(config *server.Config, disableClustering b
}
props["max_request_size"] = fmt.Sprintf("%d", lnConfig.MaxRequestSize)
+ if lnConfig.CustomMaxJSONDepth == 0 {
+ lnConfig.CustomMaxJSONDepth = vaulthttp.CustomMaxJSONDepth
+ }
+ props["max_json_depth"] = fmt.Sprintf("%d", lnConfig.CustomMaxJSONDepth)
+
+ if lnConfig.CustomMaxJSONStringValueLength == 0 {
+ lnConfig.CustomMaxJSONStringValueLength = vaulthttp.CustomMaxJSONStringValueLength
+ }
+ props["max_json_string_value_length"] = fmt.Sprintf("%d", lnConfig.CustomMaxJSONStringValueLength)
+
+ if lnConfig.CustomMaxJSONObjectEntryCount == 0 {
+ lnConfig.CustomMaxJSONObjectEntryCount = vaulthttp.CustomMaxJSONObjectEntryCount
+ }
+ props["max_json_object_entry_count"] = fmt.Sprintf("%d", lnConfig.CustomMaxJSONObjectEntryCount)
+
+ if lnConfig.CustomMaxJSONArrayElementCount == 0 {
+ lnConfig.CustomMaxJSONArrayElementCount = vaulthttp.CustomMaxJSONArrayElementCount
+ }
+ props["max_json_array_element_count"] = fmt.Sprintf("%d", lnConfig.CustomMaxJSONArrayElementCount)
+
if lnConfig.MaxRequestDuration == 0 {
lnConfig.MaxRequestDuration = vault.DefaultMaxRequestDuration
}
http/handler.go+37 0
@@ -85,6 +85,43 @@ const (
// VaultSnapshotRecoverParam is the query parameter sent when Vault should
// recover the data from a loaded snapshot
VaultSnapshotRecoverParam = "recover_snapshot_id"
+
+ // 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
+ // (DoS) vulnerability. The default value of 300 is intentionally generous
+ // to support complex but legitimate configurations, while still providing
+ // a safeguard against malicious or malformed input. This value is
+ // configurable to accommodate unique environmental requirements.
+ CustomMaxJSONDepth = 300
+
+ // CustomMaxJSONStringValueLength defines the maximum allowed length for a single
+ // string value within a JSON payload, in bytes. This is a critical defense
+ // against excessive memory allocation attacks where a client might send a
+ // very large string value to exhaust server memory. The default of 1MB
+ // (1024 * 1024 bytes) is chosen to comfortably accommodate large secrets
+ // such as private keys, certificate chains, or detailed configuration data,
+ // without permitting unbounded allocation. This value is configurable.
+ CustomMaxJSONStringValueLength = 1024 * 1024 // 1MB
+
+ // CustomMaxJSONObjectEntryCount sets the maximum number of key-value pairs
+ // allowed in a single JSON object. This limit helps mitigate the risk of
+ // hash-collision denial-of-service (HashDoS) attacks and prevents general
+ // resource exhaustion from parsing objects with an excessive number of
+ // entries. A default of 10,000 entries is well beyond the scope of typical
+ // Vault secrets or configurations, providing a high ceiling for normal
+ // operations while ensuring stability. This value is configurable.
+ CustomMaxJSONObjectEntryCount = 10000
+
+ // CustomMaxJSONArrayElementCount determines the maximum number of elements
+ // permitted in a single JSON array. This is particularly relevant for API
+ // endpoints that can return large lists, such as the result of a `LIST`
+ // operation on a secrets engine path. The default limit of 10,000 elements
+ // prevents a single request from causing excessive memory consumption. While
+ // most environments will fall well below this limit, it is configurable for
+ // systems that require handling larger datasets, though pagination is the
+ // recommended practice for such cases.
+ CustomMaxJSONArrayElementCount = 10000
)
var (
http/handler_test.go+1 1
@@ -938,7 +938,7 @@ func TestHandler_MaxRequestSize(t *testing.T) {
"bar": strings.Repeat("a", 1025),
})
- require.ErrorContains(t, err, "error parsing JSON")
+ require.ErrorContains(t, err, "http: request body too large")
}
// TestHandler_MaxRequestSize_Memory sets the max request size to 1024 bytes,
http/logical.go+1 1
@@ -147,7 +147,7 @@ func buildLogicalRequestNoAuth(perfStandby bool, ra *vault.RouterAccess, w http.
if err != nil {
status := http.StatusBadRequest
logical.AdjustErrorStatusCode(&status, err)
- return nil, nil, status, fmt.Errorf("error parsing JSON")
+ return nil, nil, status, fmt.Errorf("error parsing JSON: %w", err)
}
}
}
http/logical_test.go+8 1
@@ -310,8 +310,15 @@ func TestLogical_RequestSizeDisableLimit(t *testing.T) {
// Write a very large object, should pass as MaxRequestSize set to -1/Negative value
+ // Test change: Previously used DefaultMaxRequestSize to create a large payload.
+ // However, after introducing JSON limits, the test successfully disables the first layer (MaxRequestSize),
+ // but its large 32MB payload is then correctly caught by the second layer—specifically,
+ // the CustomMaxStringValueLength limit, which defaults to 1MB.
+ // Create a payload that is larger than a typical small limit (e.g., > 1KB),
+ // but is well within the default JSON string length limit (1MB).
+ // This isolates the test to *only* the MaxRequestSize behavior.
resp := testHttpPut(t, token, addr+"/v1/secret/foo", map[string]interface{}{
- "data": make([]byte, DefaultMaxRequestSize),
+ "data": make([]byte, 2048),
})
testResponseStatus(t, resp, http.StatusNoContent)
}
http/util.go+60 4
@@ -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/jsonutil"
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/vault/vault"
"github.com/hashicorp/vault/vault/quotas"
@@ -24,25 +25,80 @@ var nonVotersAllowed = false
func wrapMaxRequestSizeHandler(handler http.Handler, props *vault.HandlerProperties) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- var maxRequestSize int64
+ var maxRequestSize, maxJSONDepth, maxStringValueLength, maxObjectEntryCount, maxArrayElementCount int64
+
if props.ListenerConfig != nil {
maxRequestSize = props.ListenerConfig.MaxRequestSize
+ maxJSONDepth = props.ListenerConfig.CustomMaxJSONDepth
+ maxStringValueLength = props.ListenerConfig.CustomMaxJSONStringValueLength
+ maxObjectEntryCount = props.ListenerConfig.CustomMaxJSONObjectEntryCount
+ maxArrayElementCount = props.ListenerConfig.CustomMaxJSONArrayElementCount
}
+
if maxRequestSize == 0 {
maxRequestSize = DefaultMaxRequestSize
}
- ctx := r.Context()
- originalBody := r.Body
+ if maxJSONDepth == 0 {
+ maxJSONDepth = CustomMaxJSONDepth
+ }
+ if maxStringValueLength == 0 {
+ maxStringValueLength = CustomMaxJSONStringValueLength
+ }
+ if maxObjectEntryCount == 0 {
+ maxObjectEntryCount = CustomMaxJSONObjectEntryCount
+ }
+ if maxArrayElementCount == 0 {
+ maxArrayElementCount = CustomMaxJSONArrayElementCount
+ }
+
+ jsonLimits := jsonutil.JSONLimits{
+ MaxDepth: int(maxJSONDepth),
+ MaxStringValueLength: int(maxStringValueLength),
+ MaxObjectEntryCount: int(maxObjectEntryCount),
+ MaxArrayElementCount: int(maxArrayElementCount),
+ }
+
+ // If the payload is JSON, the VerifyMaxDepthStreaming function will perform validations.
+ buf, err := jsonLimitsValidation(w, r, maxRequestSize, jsonLimits)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err)
+ return
+ }
+
+ // Replace the body and update the context.
+ // This ensures the request object is in a consistent state for all downstream handlers.
+ // Because the original request body stream has been fully consumed by io.ReadAll,
+ // we must replace it so that subsequent handlers can read the content.
+ r.Body = newMultiReaderCloser(buf, r.Body)
+ contextBody := r.Body
+ ctx := logical.CreateContextOriginalBody(r.Context(), contextBody)
+
if maxRequestSize > 0 {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
}
- ctx = logical.CreateContextOriginalBody(ctx, originalBody)
r = r.WithContext(ctx)
handler.ServeHTTP(w, r)
})
}
+func jsonLimitsValidation(w http.ResponseWriter, r *http.Request, maxRequestSize int64, jsonLimits jsonutil.JSONLimits) (*bytes.Buffer, error) {
+ // The TeeReader reads from the original body and writes a copy to our buffer.
+ // We wrap the original body with a MaxBytesReader first to enforce the hard size limit.
+ var limitedTeeReader io.Reader
+ buf := &bytes.Buffer{}
+ bodyReader := r.Body
+ if maxRequestSize > 0 {
+ bodyReader = http.MaxBytesReader(w, r.Body, maxRequestSize)
+ }
+ limitedTeeReader = io.TeeReader(bodyReader, buf)
+ _, err := jsonutil.VerifyMaxDepthStreaming(limitedTeeReader, jsonLimits)
+ if err != nil {
+ return nil, err
+ }
+ return buf, nil
+}
+
func wrapRequestLimiterHandler(handler http.Handler, props *vault.HandlerProperties) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
request := r.WithContext(
internalshared/configutil/listener.go+54 0
@@ -149,6 +149,24 @@ type Listener struct {
// DisableRequestLimiter allows per-listener disabling of the Request Limiter.
DisableRequestLimiterRaw any `hcl:"disable_request_limiter"`
DisableRequestLimiter bool `hcl:"-"`
+
+ // JSON-specific limits
+
+ // CustomMaxJSONDepth specifies the maximum nesting depth of a JSON object.
+ CustomMaxJSONDepthRaw interface{} `hcl:"max_json_depth"`
+ CustomMaxJSONDepth int64 `hcl:"-"`
+
+ // CustomMaxJSONStringValueLength defines the maximum allowed length for a string in a JSON payload.
+ CustomMaxJSONStringValueLengthRaw interface{} `hcl:"max_json_string_value_length"`
+ CustomMaxJSONStringValueLength int64 `hcl:"-"`
+
+ // CustomMaxJSONObjectEntryCount sets the maximum number of key-value pairs in a JSON object.
+ CustomMaxJSONObjectEntryCountRaw interface{} `hcl:"max_json_object_entry_count"`
+ CustomMaxJSONObjectEntryCount int64 `hcl:"-"`
+
+ // CustomMaxJSONArrayElementCount determines the maximum number of elements in a JSON array.
+ CustomMaxJSONArrayElementCountRaw interface{} `hcl:"max_json_array_element_count"`
+ CustomMaxJSONArrayElementCount int64 `hcl:"-"`
}
// AgentAPI allows users to select which parts of the Agent API they want enabled.
@@ -468,6 +486,10 @@ func (l *Listener) parseRequestSettings() error {
return fmt.Errorf("invalid value for disable_request_limiter: %w", err)
}
+ if err := l.parseJSONLimitsSettings(); err != nil {
+ return err
+ }
+
return nil
}
@@ -710,3 +732,35 @@ func (l *Listener) parseRedactionSettings() error {
return nil
}
+
+func (l *Listener) parseJSONLimitsSettings() error {
+ if err := parseAndClearInt(&l.CustomMaxJSONDepthRaw, &l.CustomMaxJSONDepth); err != nil {
+ return fmt.Errorf("error parsing max_json_depth: %w", err)
+ }
+ if l.CustomMaxJSONDepth < 0 {
+ return fmt.Errorf("max_json_depth cannot be negative")
+ }
+
+ if err := parseAndClearInt(&l.CustomMaxJSONStringValueLengthRaw, &l.CustomMaxJSONStringValueLength); err != nil {
+ return fmt.Errorf("error parsing max_json_string_value_length: %w", err)
+ }
+ if l.CustomMaxJSONStringValueLength < 0 {
+ return fmt.Errorf("max_json_string_value_length cannot be negative")
+ }
+
+ if err := parseAndClearInt(&l.CustomMaxJSONObjectEntryCountRaw, &l.CustomMaxJSONObjectEntryCount); err != nil {
+ return fmt.Errorf("error parsing max_json_object_entry_count: %w", err)
+ }
+ if l.CustomMaxJSONObjectEntryCount < 0 {
+ return fmt.Errorf("max_json_object_entry_count cannot be negative")
+ }
+
+ if err := parseAndClearInt(&l.CustomMaxJSONArrayElementCountRaw, &l.CustomMaxJSONArrayElementCount); err != nil {
+ return fmt.Errorf("error parsing max_json_array_element_count: %w", err)
+ }
+ if l.CustomMaxJSONArrayElementCount < 0 {
+ return fmt.Errorf("max_json_array_element_count cannot be negative")
+ }
+
+ return nil
+}
More files changed — see the full commit.

References