Security context
High· 7.5GHSA-vp5w-xcfc-73wf CVE-2025-12044CWE-770Published Oct 23, 2025

Hashicorp Vault and Vault Enterprise vulnerable to a denial of service when processing JSON

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

1.20.3 → fixed in 1.21.0

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)

Vault Automation· Sep 24, 2025, 07:13 AM+69898b19e74c29a
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 ID
VaultSnapshotRecoverHeader = "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 = false
func 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 int64
if props.ListenerConfig != nil {
maxRequestSize = props.ListenerConfig.MaxRequestSize
@@ -33,6 +33,7 @@ func wrapMaxRequestSizeHandler(handler http.Handler, props *vault.HandlerPropert
maxStringValueLength = props.ListenerConfig.CustomMaxJSONStringValueLength
maxObjectEntryCount = props.ListenerConfig.CustomMaxJSONObjectEntryCount
maxArrayElementCount = props.ListenerConfig.CustomMaxJSONArrayElementCount
+ maxToken = props.ListenerConfig.CustomMaxJSONToken
}
if maxRequestSize == 0 {
@@ -50,12 +51,16 @@ func wrapMaxRequestSizeHandler(handler http.Handler, props *vault.HandlerPropert
if 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 int64
rawCustomMaxJSONArrayElementCount any
expectedCustomMaxJSONArrayElementCount int64
+ rawCustomMaxJSONToken any
+ expectedCustomMaxJSONToken int64
isErrorExpected bool
errorMessage 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 int
MaxObjectEntryCount int
MaxArrayElementCount 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, erro
return 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 string
jsonInput 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)

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