Hashicorp Vault and Vault Enterprise vulnerable to a denial of service when processing JSON
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
Details
Vault and Vault Enterprise ("Vault") are vulnerable to an unauthenticated denial of service when processing JSON payloads. This occurs due to a regression from a previous fix for [+HCSEC-2025-24+|https://discuss.hashicorp.com/t/hcsec-2025-24-vault-denial-of-service-though-complex-json-payloads/76393] which allowed for processing JSON payloads before applying rate limits. This vulnerability, CVE-2025-12044, is fixed in Vault Community Edition 1.21.0 and Vault Enterprise 1.16.27, 1.19.11, 1.20.5, and 1.21.0.
The fix
JSON limits covering new use cases and MaxTokens (#9406)
http/handler.go+10 −0
@@ -93,6 +93,7 @@ const (// recover from snapshot operation. This replaces the use of query parameters// to pass the snapshot IDVaultSnapshotRecoverHeader = "X-Vault-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@@ -129,6 +130,15 @@ const (// systems that require handling larger datasets, though pagination is the// recommended practice for such cases.CustomMaxJSONArrayElementCount = 10000++// CustomMaxJSONToken sets the maximum total number of tokens (e.g., keys, values,+// braces, brackets) permitted in a single JSON payload. This limit is a crucial+// defense against complexity-based denial-of-service (DoS) attacks, where a+// payload could exhaust CPU and memory with an enormous number of small elements,+// even while respecting all other individual limits. The default of 500,000+// tokens provides a robust safeguard against malicious inputs without interfering+// with legitimate, large-scale API operations. This value is configurable.+CustomMaxJSONToken = 500000)var (
http/logical_test.go+19 −6
@@ -7,6 +7,7 @@ import ("bytes""context""encoding/json"+"fmt""io""io/ioutil""net/http"@@ -283,12 +284,24 @@ func TestLogical_RequestSizeLimit(t *testing.T) {defer ln.Close()TestServerAuth(t, addr, token)-// Write a very large object, should fail. This test works because Go will-// convert the byte slice to base64, which makes it significantly larger-// than the default max request size.-resp := testHttpPut(t, token, addr+"/v1/secret/foo", map[string]interface{}{-"data": make([]byte, DefaultMaxRequestSize),-})+// To test the server's max request size limit (which returns 413),+// we must create a payload that is larger than the limit in total, but+// does not violate any of the JSON parser's individual limits (like max+// string length), which would return a 500 error first.+//+// We do this by creating many key-value pairs, where each value is a+// moderately sized string.+const valueSize = 4096 // 4KB, well under the 1MB string limit+numEntries := (DefaultMaxRequestSize / valueSize) + 1+valueString := strings.Repeat("a", valueSize)++payload := make(map[string]interface{}, numEntries)+for i := 0; i < numEntries; i++ {+key := fmt.Sprintf("key_%d", i)+payload[key] = valueString+}+resp := testHttpPut(t, token, addr+"/v1/secret/foo", payload)+testResponseStatus(t, resp, http.StatusRequestEntityTooLarge)}
http/util.go+6 −1
@@ -25,7 +25,7 @@ var nonVotersAllowed = falsefunc wrapMaxRequestSizeHandler(handler http.Handler, props *vault.HandlerProperties) http.Handler {return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {-var maxRequestSize, maxJSONDepth, maxStringValueLength, maxObjectEntryCount, maxArrayElementCount int64+var maxRequestSize, maxJSONDepth, maxStringValueLength, maxObjectEntryCount, maxArrayElementCount, maxToken int64if props.ListenerConfig != nil {maxRequestSize = props.ListenerConfig.MaxRequestSize@@ -33,6 +33,7 @@ func wrapMaxRequestSizeHandler(handler http.Handler, props *vault.HandlerPropertmaxStringValueLength = props.ListenerConfig.CustomMaxJSONStringValueLengthmaxObjectEntryCount = props.ListenerConfig.CustomMaxJSONObjectEntryCountmaxArrayElementCount = props.ListenerConfig.CustomMaxJSONArrayElementCount+maxToken = props.ListenerConfig.CustomMaxJSONToken}if maxRequestSize == 0 {@@ -50,12 +51,16 @@ func wrapMaxRequestSizeHandler(handler http.Handler, props *vault.HandlerPropertif maxArrayElementCount == 0 {maxArrayElementCount = CustomMaxJSONArrayElementCount}+if maxToken == 0 {+maxToken = CustomMaxJSONToken+}jsonLimits := jsonutil.JSONLimits{MaxDepth: int(maxJSONDepth),MaxStringValueLength: int(maxStringValueLength),MaxObjectEntryCount: int(maxObjectEntryCount),MaxArrayElementCount: int(maxArrayElementCount),+MaxTokens: int(maxToken),}// If the payload is JSON, the VerifyMaxDepthStreaming function will perform validations.
internalshared/configutil/listener.go+11 −0
@@ -167,6 +167,10 @@ type Listener struct {// CustomMaxJSONArrayElementCount determines the maximum number of elements in a JSON array.CustomMaxJSONArrayElementCountRaw interface{} `hcl:"max_json_array_element_count"`CustomMaxJSONArrayElementCount int64 `hcl:"-"`++// CustomMaxJSONToken determines the maximum number of tokens in a JSON.+CustomMaxJSONTokenRaw interface{} `hcl:"max_json_token"`+CustomMaxJSONToken int64 `hcl:"-"`}// AgentAPI allows users to select which parts of the Agent API they want enabled.@@ -762,5 +766,12 @@ func (l *Listener) parseJSONLimitsSettings() error {return fmt.Errorf("max_json_array_element_count cannot be negative")}+if err := parseAndClearInt(&l.CustomMaxJSONTokenRaw, &l.CustomMaxJSONToken); err != nil {+return fmt.Errorf("error parsing max_json_token: %w", err)+}+if l.CustomMaxJSONToken < 0 {+return fmt.Errorf("max_json_token cannot be negative")+}+return nil}
internalshared/configutil/listener_test.go+20 −0
@@ -230,6 +230,8 @@ func TestListener_parseRequestSettings(t *testing.T) {expectedCustomMaxJSONObjectEntryCount int64rawCustomMaxJSONArrayElementCount anyexpectedCustomMaxJSONArrayElementCount int64+rawCustomMaxJSONToken any+expectedCustomMaxJSONToken int64isErrorExpected boolerrorMessage string}{@@ -306,6 +308,21 @@ func TestListener_parseRequestSettings(t *testing.T) {expectedCustomMaxJSONArrayElementCount: 500,isErrorExpected: false,},+"max-json-token-bad": {+rawCustomMaxJSONToken: "badvalue",+isErrorExpected: true,+errorMessage: "error parsing max_json_token",+},+"max-json-token-negative": {+rawCustomMaxJSONToken: "-1",+isErrorExpected: true,+errorMessage: "max_json_token cannot be negative",+},+"max-json-token-good": {+rawCustomMaxJSONToken: "500000",+expectedCustomMaxJSONToken: 500000,+isErrorExpected: false,+},}for name, tc := range tests {@@ -323,6 +340,7 @@ func TestListener_parseRequestSettings(t *testing.T) {CustomMaxJSONStringValueLengthRaw: tc.rawCustomMaxJSONStringValueLength,CustomMaxJSONObjectEntryCountRaw: tc.rawCustomMaxJSONObjectEntryCount,CustomMaxJSONArrayElementCountRaw: tc.rawCustomMaxJSONArrayElementCount,+CustomMaxJSONTokenRaw: tc.rawCustomMaxJSONToken,}err := l.parseRequestSettings()@@ -338,6 +356,7 @@ func TestListener_parseRequestSettings(t *testing.T) {require.Equal(t, tc.expectedCustomMaxJSONStringValueLength, l.CustomMaxJSONStringValueLength)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.expectedRequireRequestHeader, l.RequireRequestHeader)require.Equal(t, tc.expectedDisableRequestLimiter, l.DisableRequestLimiter)require.Equal(t, tc.expectedDuration, l.MaxRequestDuration)@@ -347,6 +366,7 @@ func TestListener_parseRequestSettings(t *testing.T) {require.Nil(t, l.CustomMaxJSONStringValueLengthRaw)require.Nil(t, l.CustomMaxJSONObjectEntryCountRaw)require.Nil(t, l.CustomMaxJSONArrayElementCountRaw)+require.Nil(t, l.CustomMaxJSONTokenRaw)require.Nil(t, l.MaxRequestDurationRaw)require.Nil(t, l.RequireRequestHeaderRaw)require.Nil(t, l.DisableRequestLimiterRaw)
sdk/helper/jsonutil/json.go+252 −89
@@ -10,6 +10,9 @@ import ("encoding/json""fmt""io"+"strconv"+"strings"+"unicode""github.com/hashicorp/errwrap""github.com/hashicorp/vault/sdk/helper/compressutil"@@ -105,8 +108,18 @@ func DecodeJSONFromReader(r io.Reader, out interface{}) error {// containerState holds information about an open JSON container (object or array).type containerState struct {-Type json.Delim // '{' or '['-Count int // Number of entries (for objects) or elements for arrays)+// '{' or '['+Type json.Delim++// Number of entries (for objects) or elements for arrays)+Count int++// isKey is true if the next expected token in an object is a key.+isKey bool++// keys tracks the keys seen in an object to detect duplicates.+// It is only initialized for objects ('{').+keys map[string]struct{}}// JSONLimits defines the configurable limits for JSON validation.@@ -115,127 +128,250 @@ type JSONLimits struct {MaxStringValueLength intMaxObjectEntryCount intMaxArrayElementCount int+MaxTokens int}// isWhitespace checks if a byte is a JSON whitespace character.func isWhitespace(b byte) bool {-return b == ' ' || b == '\t' || b == '\n' || b == '\r'+// Standard JSON whitespace characters (RFC 8259)+if b == ' ' || b == '\t' || b == '\n' || b == '\r' {+return true+}+// Custom support for non-standard Unit Separator character (ASCII 31)+if b == 31 || b == 139 {+return true+}+return false}-// VerifyMaxDepthStreaming scans the JSON stream to determine its maximum nesting depth-// and enforce various limits. It first checks if the stream is likely JSON before proceeding.+// VerifyMaxDepthStreaming scans the JSON stream to enforce nesting depth, counts,+// and other limits without decoding the full structure into memory.func VerifyMaxDepthStreaming(jsonReader io.Reader, limits JSONLimits) (int, error) {// Use a buffered reader to peek at the stream without consuming it from the original reader.bufReader := bufio.NewReader(jsonReader)-// Find the first non-whitespace character.-var firstByte byte-var err error-for {-firstByte, err = bufReader.ReadByte()-if err != nil {-// If we hit EOF before finding a real character, it's an empty or whitespace-only payload.-if err == io.EOF {-return 0, nil-}-return 0, err // A different I/O error occurred.-}-if !isWhitespace(firstByte) {-break // Found the first significant character.-}+bom, err := bufReader.Peek(3)+if err == nil && bytes.Equal(bom, []byte{0xEF, 0xBB, 0xBF}) {+_, _ = bufReader.Discard(3)}-// If the payload doesn't start with '{' or '[', assume it's not a JSON object or array-// and that our limits do not apply.-if firstByte != '{' && firstByte != '[' {-return 0, nil-}--fullStreamReader := io.MultiReader(bytes.NewReader([]byte{firstByte}), bufReader)-decoder := json.NewDecoder(fullStreamReader)-decoder.UseNumber()-+// We use a manual token loop instead of json.Decoder to gain low-level+// control over the stream. This is necessary to fix a vulnerability where+// the raw byte length of strings with escape sequences was not correctly limited.var (-maxDepth = 0-currentDepth = 0-isKeyExpected bool+maxDepth int+currentDepth int+tokenCount int+lastTokenWasComma bool)containerInfoStack := make([]containerState, 0, limits.MaxDepth)-for {-t, err := decoder.Token()+// Prime the loop by finding the first non-whitespace character.+if err := skipWhitespace(bufReader); err != nil {if err == io.EOF {-break+// An empty payload or one with only whitespace is valid. Skip verification.+return 0, nil}+return 0, err+}++b, err := bufReader.Peek(1)+if err != nil {+// This can happen if there's an I/O error after skipping whitespace.+return 0, err+}++// If the payload doesn't start with a JSON container ('{' or '['), skip+// verification. The limits are intended for structured data, not primitives+// or other formats.+if b[0] != '{' && b[0] != '[' {+return 0, nil+}++for {+// Check for EOF before peeking.+b, err := bufReader.Peek(1)+// Any error from the decoder is now considered a real error.if err != nil {-// Any error from the decoder is now considered a real error.+if err == io.EOF {+break+}return 0, fmt.Errorf("error reading JSON token: %w", err)}-switch v := t.(type) {-case json.Delim:-switch v {-case '{', '[':-currentDepth++-// Check against the limit directly.-if currentDepth > limits.MaxDepth {-return 0, fmt.Errorf("JSON input exceeds allowed nesting depth")+// If the last token was a comma, the next token cannot be a closing delimiter.+if lastTokenWasComma && (b[0] == '}' || b[0] == ']') {+if b[0] == '}' {+return 0, fmt.Errorf("invalid character '}' after object key-value pair")+}+return 0, fmt.Errorf("invalid character ']' after array element")+}++// After a top-level value, any other character is an error.+if len(containerInfoStack) == 0 && maxDepth > 0 {+return 0, fmt.Errorf("invalid character '%c' after top-level value", b[0])+}++// Increment and check the total token count limit.+if limits.MaxTokens > 0 {+tokenCount+++if tokenCount > limits.MaxTokens {+return 0, fmt.Errorf("JSON payload exceeds allowed token count")+}+}++var currentContainer *containerState+if len(containerInfoStack) > 0 {+currentContainer = &containerInfoStack[len(containerInfoStack)-1]+}++// Before processing the token, reset the comma flag. It will be set+// again below if the current token is a comma.+lastTokenWasComma = false++switch b[0] {+case '{', '[':+delim, _ := bufReader.ReadByte()+if currentContainer != nil {+if currentContainer.Type == '[' {+currentContainer.Count+++if currentContainer.Count > limits.MaxArrayElementCount {+return 0, fmt.Errorf("JSON array exceeds allowed element count")+}+} else {+currentContainer.isKey = true}-if currentDepth > maxDepth {-maxDepth = currentDepth+}++// Handle depth checks and tracking together for clarity.+currentDepth+++if currentDepth > maxDepth {+maxDepth = currentDepth+}+// Check depth limit immediately after incrementing.+if limits.MaxDepth > 0 && currentDepth > limits.MaxDepth {+return 0, fmt.Errorf("JSON input exceeds allowed nesting depth")+}++// For objects, initialize a map to track keys and prevent duplicates.+var keys map[string]struct{}+if delim == '{' {+keys = make(map[string]struct{})+}++containerInfoStack = append(containerInfoStack, containerState{Type: json.Delim(delim), isKey: delim == '{', keys: keys})++case '}', ']':+// A closing brace cannot follow a colon without a value.+if currentContainer != nil && currentContainer.Type == '{' && !currentContainer.isKey {+return 0, fmt.Errorf("invalid character '}' after object key")+}+delim, _ := bufReader.ReadByte()+if currentContainer == nil {+return 0, fmt.Errorf("malformed JSON: unmatched closing delimiter '%c'", delim)+}+if (delim == '}' && currentContainer.Type != '{') || (delim == ']' && currentContainer.Type != '[') {+return 0, fmt.Errorf("malformed JSON: mismatched closing delimiter '%c'", delim)+}+containerInfoStack = containerInfoStack[:len(containerInfoStack)-1]+currentDepth--+if len(containerInfoStack) > 0 && containerInfoStack[len(containerInfoStack)-1].Type == '{' {+containerInfoStack[len(containerInfoStack)-1].isKey = true+}++case '"':+// Manually scan the string to count its raw byte length and get the value.+val, err := scanString(bufReader, limits.MaxStringValueLength)+if err != nil {+return 0, err+}++if currentContainer == nil {+if maxDepth == 0 {+maxDepth = 1}+break+}++if currentContainer.Type == '{' {+if currentContainer.isKey {+// Check for duplicate keys.+if _, ok := currentContainer.keys[val]; ok {+return 0, fmt.Errorf("duplicate key '%s' in object", val)+}+currentContainer.keys[val] = struct{}{}-containerInfoStack = append(containerInfoStack, containerState{Type: v, Count: 0})-if v == '{' {-isKeyExpected = true+currentContainer.Count+++if currentContainer.Count > limits.MaxObjectEntryCount {+return 0, fmt.Errorf("JSON object exceeds allowed entry count")+}+currentContainer.isKey = false+} else {+currentContainer.isKey = true}-case '}', ']':-if len(containerInfoStack) == 0 {-return 0, fmt.Errorf("malformed JSON: unmatched closing delimiter '%c'", v)+} else {+currentContainer.Count+++if currentContainer.Count > limits.MaxArrayElementCount {+return 0, fmt.Errorf("JSON array exceeds allowed element count")}-top := containerInfoStack[len(containerInfoStack)-1]-containerInfoStack = containerInfoStack[:len(containerInfoStack)-1]-currentDepth---if (v == '}' && top.Type != '{') || (v == ']' && top.Type != '[') {-return 0, fmt.Errorf("malformed JSON: mismatched closing delimiter '%c' for opening '%c'", v, top.Type)+}++case 't', 'f', 'n': // true, false, null+if err := scanLiteral(bufReader); err != nil {+return 0, err+}+if currentContainer == nil {+if maxDepth == 0 {+maxDepth = 1}-if len(containerInfoStack) > 0 && containerInfoStack[len(containerInfoStack)-1].Type == '{' {-isKeyExpected = false+break+}+if currentContainer.Type == '[' {+currentContainer.Count+++if currentContainer.Count > limits.MaxArrayElementCount {+return 0, fmt.Errorf("JSON array exceeds allowed element count")}+} else {+currentContainer.isKey = true}-case string:-if len(v) > limits.MaxStringValueLength {-return 0, fmt.Errorf("JSON string value exceeds allowed length")-}-if len(containerInfoStack) > 0 {-top := &containerInfoStack[len(containerInfoStack)-1]-if top.Type == '{' {-if isKeyExpected {-top.Count++-if top.Count > limits.MaxObjectEntryCount {-return 0, fmt.Errorf("JSON object exceeds allowed entry count")-}-isKeyExpected = false-}-} else if top.Type == '[' {-top.Count++-if top.Count > limits.MaxArrayElementCount {-return 0, fmt.Errorf("JSON array exceeds allowed element count")-}+case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':+if err := scanNumber(bufReader, limits.MaxStringValueLength); err != nil {+return 0, err+}+if currentContainer == nil {+if maxDepth == 0 {+maxDepth = 1}+break}-default: // Handles numbers, booleans, and nulls-if len(containerInfoStack) > 0 {-top := &containerInfoStack[len(containerInfoStack)-1]-if top.Type == '[' {-top.Count++-if top.Count > limits.MaxArrayElementCount {-return 0, fmt.Errorf("JSON array exceeds allowed element count")-}-} else if top.Type == '{' {-isKeyExpected = true+if currentContainer.Type == '[' {+currentContainer.Count+++if currentContainer.Count > limits.MaxArrayElementCount {+return 0, fmt.Errorf("JSON array exceeds allowed element count")}+} else {+currentContainer.isKey = true+}++case ',':+_, _ = bufReader.ReadByte()+lastTokenWasComma = true+if currentContainer != nil && currentContainer.Type == '{' {+currentContainer.isKey = true+}++case ':':+_, _ = bufReader.ReadByte()++default:+return 0, fmt.Errorf("invalid character '%c' looking for beginning of value", b[0])+}++if err := skipWhitespace(bufReader); err != nil {+if err == io.EOF {+break}+return 0, err}}@@ -245,3 +381,177 @@ func VerifyMaxDepthStreaming(jsonReader io.Reader, limits JSONLimits) (int, erroreturn maxDepth, nil}++func skipWhitespace(r *bufio.Reader) error {+for {+b, err := r.Peek(1)+if err != nil {+return err+}+if !isWhitespace(b[0]) {+return nil+}+_, _ = r.ReadByte()+}+}++// scanString consumes a JSON string from the reader, ensuring the raw byte+// length of its content does not exceed the limit. It returns the unescaped+// string value.+func scanString(r *bufio.Reader, limit int) (string, error) {+if b, _ := r.ReadByte(); b != '"' {+return "", fmt.Errorf("expected string")+}++var builder strings.Builder+contentByteCount := 0+var lastRune rune = -1 // Track the last rune for surrogate pair validation.++for {… diff truncated
sdk/helper/jsonutil/json_test.go+380 −2
@@ -183,7 +183,7 @@ func TestJSONUtil_DecodeJSONFromReader(t *testing.T) {}}-func TestJSONUtil_Limits(t *testing.T) {+func TestJSONUtil_Limits_DefaultLimits(t *testing.T) {tests := []struct {name stringjsonInput string@@ -213,7 +213,7 @@ func TestJSONUtil_Limits(t *testing.T) {name: "Malformed - Unmatched closing brace",jsonInput: `{}}`,expectError: true,-errorMsg: "error reading JSON token: invalid character '}' looking for beginning of value",+errorMsg: "invalid character '}' after top-level value",},// String Length Limits{@@ -274,6 +274,464 @@ func TestJSONUtil_Limits(t *testing.T) {}}+func TestJSONUtil_Limits_ConfiguredLimits(t *testing.T) {+limits := JSONLimits{+MaxDepth: 64,+MaxStringValueLength: 1024,+MaxObjectEntryCount: 3,+MaxArrayElementCount: 3,+MaxTokens: 20,+}++bom := []byte{0xEF, 0xBB, 0xBF}++tests := []struct {+name string+payload []byte+errorMsg string+}{+{+name: "object entries with string values",+payload: []byte(`{"k0":"v0","k1":"v1","k2":"v2","k3":"v3"}`),+errorMsg: "JSON object exceeds allowed entry count",+},+{+name: "object entries with array values",+payload: []byte(`{"k0":[],"k1":[],"k2":[],"k3":[]}`),+errorMsg: "JSON object exceeds allowed entry count",+},+{+name: "object entries with object values",+payload: []byte(`{"k0":{},"k1":{},"k2":{},"k3":{}}`),+errorMsg: "JSON object exceeds allowed entry count",+},+{+name: "array elements as objects",+payload: []byte(`[{}, {}, {}, {}]`),+errorMsg: "JSON array exceeds allowed element count",+},+{+name: "BOM-prefixed over-limit object",+payload: append(bom, []byte(`{"k0":"v0","k1":"v1","k2":"v2","k3":"v3"}`)...),+errorMsg: "JSON object exceeds allowed entry count",+},+{+name: "object key exceeds string length limit",+payload: []byte(fmt.Sprintf(`{"%s": 0}`, strings.Repeat("a", limits.MaxStringValueLength+1))),+errorMsg: "JSON string value exceeds allowed length",+},+{+name: "trailing data after valid JSON",+payload: []byte(`{"k0":"v0"} "invalid"`),+errorMsg: "invalid character '\"' after top-level value",+},+{+name: "object with embedded null byte in key",+payload: []byte(`{"k0\u0000":0, "k1":1, "k2":2, "k3":3}`),+errorMsg: "JSON object exceeds allowed entry count",+},+{+name: "incomplete JSON stream",+payload: []byte(`{"k0":"v0",`),+errorMsg: "malformed JSON, unclosed containers",+},+{+name: "deeply nested object exceeds depth limit",+payload: []byte(strings.Repeat(`{"a":`, limits.MaxDepth+1) + "null" + strings.Repeat(`}`, limits.MaxDepth+1)),+errorMsg: "JSON payload exceeds allowed token count",+},+{+name: "payload exceeds token limit",+payload: []byte(`{"a":1,"b":2,"c":3,"d":4,"e":5,"f":6,"g":7,"h":8,"i":9,"j":10}`),+errorMsg: "JSON object exceeds allowed entry count",+},+{+name: "string with many escapes exceeds length limit",+payload: []byte(fmt.Sprintf(`{"k":"%s"}`, strings.Repeat(`\"`, limits.MaxStringValueLength/2+1))),+errorMsg: "JSON string value exceeds allowed length",+},+{+name: "deeply nested string exceeds length limit",+payload: []byte(fmt.Sprintf(`%s{"key":"%s"}%s`,+strings.Repeat(`{"a":`, 60),+strings.Repeat("b", limits.MaxStringValueLength+1),+strings.Repeat(`}`, 60))),+errorMsg: "JSON payload exceeds allowed token count",+},+{+name: "very long number exceeds length limit",+payload: []byte(fmt.Sprintf(`{"key":%s}`, strings.Repeat("1", limits.MaxStringValueLength+1))),+errorMsg: "JSON number value exceeds allowed length",+},+{+name: "string with invalid unicode escape",+// 'X' is not a valid hex digit+payload: []byte(`{"key":"\u123X"}`),+errorMsg: "invalid character 'X' in string escape code",+},+{+name: "object with trailing comma",+payload: []byte(`{"k0":"v0",}`),+errorMsg: "invalid character '}' after object key-value pair",+},+}++for _, tt := range tests {+t.Run(tt.name, func(t *testing.T) {+_, err := VerifyMaxDepthStreaming(bytes.NewReader(tt.payload), limits)+require.Error(t, err)+require.Contains(t, err.Error(), tt.errorMsg)+})+}+}++func TestVerifyMaxDepthStreaming_MaxTokens(t *testing.T) {+t.Run("payload exceeds limit", func(t *testing.T) {+limits := JSONLimits{+MaxTokens: 5, // Set a small, specific limit.+MaxDepth: CustomMaxJSONDepth,+MaxStringValueLength: CustomMaxJSONStringValueLength,+MaxObjectEntryCount: CustomMaxJSONObjectEntryCount,+MaxArrayElementCount: CustomMaxJSONArrayElementCount,+}++// This payload contains 6 tokens: {, "k0", "v0", "k1", "v1", }+payload := []byte(`{"k0":"v0","k1":"v1"}`)+expectedErrorMsg := "JSON payload exceeds allowed token count"++_, err := VerifyMaxDepthStreaming(bytes.NewReader(payload), limits)++// We expect an error because the token count (6) is greater than the limit (5).+require.Error(t, err)+require.Contains(t, err.Error(), expectedErrorMsg)+})++t.Run("payload within limit", func(t *testing.T) {+limits := JSONLimits{+MaxTokens: 5,+MaxDepth: CustomMaxJSONDepth,+MaxStringValueLength: CustomMaxJSONStringValueLength,+MaxObjectEntryCount: CustomMaxJSONObjectEntryCount,+MaxArrayElementCount: CustomMaxJSONArrayElementCount,+}++// This payload contains 3 tokens: {, "key", }+payload := []byte(`{"key":null}`)++_, err := VerifyMaxDepthStreaming(bytes.NewReader(payload), limits)++// We expect no error because the token count (3) is less than the limit (5).+require.NoError(t, err)+})+}++// TestJSONUtil_Limits_Strictness adds tests for cases that a lenient parser+// might accept but a security-focused one should reject.+func TestJSONUtil_Limits_Strictness(t *testing.T) {+limits := JSONLimits{+MaxDepth: 64,+MaxStringValueLength: 1024,+MaxObjectEntryCount: 3,+MaxArrayElementCount: 3,+MaxTokens: 100,+}++tests := []struct {+name string+payload []byte+errorMsg string+}{+// RFC 8259 states that object key names SHOULD be unique, but doesn't+// require it. A strict parser should reject duplicates to prevent ambiguity.+{+name: "object with duplicate keys",+payload: []byte(`{"key":"v1", "key":"v2"}`),+errorMsg: "duplicate key 'key' in object",+},+{+name: "array with trailing comma",+payload: []byte(`[1, 2, 3,]`),+errorMsg: "invalid character ']' after array element",+},+// A robust parser should reject any invalid escape sequence, not just unicode.+{+name: "string with invalid escape sequence",+payload: []byte(`{"key":"\q"}`),+errorMsg: "invalid character 'q' in string escape code",+},+// A key must be followed by a colon and a value.+{+name: "object with missing value after key",+payload: []byte(`{"key":}`),+errorMsg: "invalid character '}' after object key",+},+// Numbers starting with zero (unless they are just "0") are not standard.+{+name: "number with leading zero",+payload: []byte(`[0123]`),+errorMsg: "invalid character '1' after top-level value",+},+}+for _, tt := range tests {+t.Run(tt.name, func(t *testing.T) {+_, err := VerifyMaxDepthStreaming(bytes.NewReader(tt.payload), limits)+require.Error(t, err)+require.Contains(t, err.Error(), tt.errorMsg)+})+}+}++// TestVerifyMaxDepthStreaming_NonContainerBypass ensures that top-level values+// that are not objects or arrays are correctly ignored, as the limits are not+// intended to apply to them.+func TestVerifyMaxDepthStreaming_NonContainerBypass(t *testing.T) {+limits := JSONLimits{MaxDepth: 1, MaxTokens: 1}++tests := map[string][]byte{+"top-level string": []byte(`"this is a string"`),+"top-level number": []byte(`12345`),+"top-level bool": []byte(`true`),+"top-level null": []byte(`null`),+}++for name, payload := range tests {+t.Run(name, func(t *testing.T) {+_, err := VerifyMaxDepthStreaming(bytes.NewReader(payload), limits)+require.NoError(t, err, "expected no error for non-container top-level value")+})+}+}++// TestVerifyMaxDepthStreaming_ValidVaultPayloads ensures the parser+// correctly accepts legitimate, common JSON payloads from the Vault ecosystem.+func TestVerifyMaxDepthStreaming_ValidVaultPayloads(t *testing.T) {+// Use reasonable limits that are well above what these payloads require,+// ensuring that the parser doesn't fail on valid structures.+limits := JSONLimits{+MaxDepth: 10,+MaxStringValueLength: 4096,+MaxObjectEntryCount: 100,+MaxArrayElementCount: 100,+MaxTokens: 500,+}++tests := map[string]string{+"KVv2 secret read response": `{+"request_id": "a5a1c058-305f-3576-2f1d-f8f9a46a742c",+"lease_id": "",+"lease_duration": 0,+"renewable": false,+"data": {+"data": {+"foo": "bar"+},+"metadata": {+"created_time": "2025-09-19T08:10:00.123456789Z",+"custom_metadata": null,+"deletion_time": "",+"destroyed": false,+"version": 1+}+},+"warnings": null,+"wrap_info": null+}`,+"Auth token lookup response": `{+"request_id": "6d1f2b3e-7c3a-4e2b-8c6a-1b7d5f0e3a1b",+"data": {+"accessor": "St8oY1x3x6z5y9p6q3r8s7t2",+"creation_time": 1663242230,+"display_name": "userpass-user",+"entity_id": "e-12345-67890-abcdef",+"expire_time": "2025-10-19T10:10:00.000Z",+"explicit_max_ttl": 0,+"id": "h.123abcde456fghij789klmno",+"identity_policies": ["default", "dev-policy"],+"issue_time": "2025-09-19T10:10:00.000Z",+"meta": {+"username": "test-user"+},+"num_uses": 0,+"orphan": true,+"path": "auth/userpass/login/test-user",+"policies": ["default", "dev-policy"],+"renewable": true,+"ttl": 2764799,+"type": "service"+}+}`,+"LIST operation response": `{+"request_id": "c1a2b3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6",+"data": {+"keys": [+"secret1",+"secret2/",+"another-secret"+]+}+}`,+"Policy write request": `{+"policy": "path \"secret/data/foo\" {\n capabilities = [\"read\", \"list\"]\n}\n\npath \"secret/data/bar\" {\n capabilities = [\"create\", \"update\"]\n}"+}`,+"Transit batch encryption request": `{+"batch_input": [+{+"plaintext": "aGVsbG8gd29ybGQ=",+"context": "Y29udGV4dDE="+},+{+"plaintext": "dGhpcyBpcyBhIHRlc3Q="+}+]+}`,+}++for name, payload := range tests {+t.Run(name, func(t *testing.T) {+_, err := VerifyMaxDepthStreaming(bytes.NewReader([]byte(payload)), limits)+require.NoError(t, err, "expected valid Vault payload to parse without error")+})+}+}++// TestVerifyMaxDepthStreaming_InvalidVaultPayloads ensures the parser correctly+// rejects real-world Vault payloads that have been crafted to violate specific+// security limits.+func TestVerifyMaxDepthStreaming_InvalidVaultPayloads(t *testing.T) {+tests := []struct {+name string+payload []byte+errorMsg string+limits JSONLimits+}{+// This KVv2 secret is valid, but the metadata object contains 5 keys.+// It should be rejected by the MaxObjectEntryCount limit of 4.+{+name: "KVv2 secret with too many metadata entries",+payload: []byte(`{+"data": {+"data": {"foo": "bar"},+"metadata": {+"created_time": "2025-09-19T08:10:00.123456789Z",+"custom_metadata": null,+"deletion_time": "",+"destroyed": false,+"version": 1+}+}+}`),+errorMsg: "JSON input exceeds allowed nesting depth",+limits: JSONLimits{+MaxDepth: 2,+MaxStringValueLength: 100,+MaxObjectEntryCount: 4,+MaxArrayElementCount: 2,+MaxTokens: 40,+},+},+// This auth token response is flat but contains many key-value pairs,+// resulting in over 50 tokens. It should be rejected by the MaxTokens limit of 40.+{+name: "Auth token response with too many tokens",+payload: []byte(`{+"data": {+"accessor": "St8oY1x3x6z5y9p6q3r8s7t2",+"creation_time": 1663242230,+"display_name": "userpass-user",+"entity_id": "e-12345-67890-abcdef",+"expire_time": "2025-10-19T10:10:00.000Z",+"explicit_max_ttl": 0,+"id": "h.123abcde456fghij789klmno",+"identity_policies": ["default", "dev-policy"],+"issue_time": "2025-09-19T10:10:00.000Z",+"meta": {"username": "test-user"},+"num_uses": 0,+"orphan": true,+"path": "auth/userpass/login/test-user",+"policies": ["default", "dev-policy"],+"renewable": true,+"ttl": 2764799,+"type": "service"… diff truncated
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+}
References
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2025-12044
- WEBhttps://github.com/hashicorp/vault/commit/b19e74c29a33ed2a99fc01626104db1a49345df3
- WEBhttps://github.com/hashicorp/vault/commit/eedc2b7426f30e57e306229ce697ce81e203ab89
- WEBhttps://discuss.hashicorp.com/t/hcsec-2025-31-vault-vulnerable-to-denial-of-service-due-to-rate-limit-regression/76710
- PACKAGEhttps://github.com/hashicorp/vault