HashiCorp Vault Community Edition Denial of Service Though Complex JSON Payloads in github.com/hashicorp/vault
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)
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 snapshotVaultSnapshotRecoverParam = "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.StatusBadRequestlogical.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 = falsefunc 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
- ADVISORYhttps://github.com/advisories/GHSA-8f82-53h8-2p34
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2025-6203
- FIXhttps://github.com/hashicorp/vault/commit/eedc2b7426f30e57e306229ce697ce81e203ab89
- WEBhttps://discuss.hashicorp.com
- WEBhttps://discuss.hashicorp.com/t/hcsec-2025-24-vault-denial-of-service-though-complex-json-payloads/76393