From eedc2b7426f30e57e306229ce697ce81e203ab89 Mon Sep 17 00:00:00 2001 From: Bianca <48203644+biazmoreira@users.noreply.github.com> Date: Wed, 6 Aug 2025 14:08:01 +0200 Subject: [PATCH] Add limit to JSON nesting depth (#31069) * Add limit to JSON nesting depth * Add JSON limit check to http handler * Add changelog --- changelog/31069.txt | 3 + command/server.go | 20 +++ http/handler.go | 37 +++++ http/handler_test.go | 2 +- http/logical.go | 2 +- http/logical_test.go | 9 +- http/util.go | 64 +++++++- internalshared/configutil/listener.go | 54 +++++++ internalshared/configutil/listener_test.go | 131 ++++++++++------ sdk/helper/jsonutil/json.go | 148 +++++++++++++++++- sdk/helper/jsonutil/json_test.go | 170 ++++++++++++++++++++- 11 files changed, 587 insertions(+), 53 deletions(-) create mode 100644 changelog/31069.txt diff --git a/changelog/31069.txt b/changelog/31069.txt new file mode 100644 index 0000000000..4aceb520d7 --- /dev/null +++ b/changelog/31069.txt @@ -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`. +``` \ No newline at end of file diff --git a/command/server.go b/command/server.go index bed6194e8d..22cf4887d2 100644 --- a/command/server.go +++ b/command/server.go @@ -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 } diff --git a/http/handler.go b/http/handler.go index b66b1d83b3..ecfbee76bc 100644 --- a/http/handler.go +++ b/http/handler.go @@ -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 ( diff --git a/http/handler_test.go b/http/handler_test.go index c42adfc85b..f1befc37e7 100644 --- a/http/handler_test.go +++ b/http/handler_test.go @@ -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, diff --git a/http/logical.go b/http/logical.go index 6e974721b7..1899a7881d 100644 --- a/http/logical.go +++ b/http/logical.go @@ -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) } } } diff --git a/http/logical_test.go b/http/logical_test.go index 238a93f14f..c42123d4d0 100644 --- a/http/logical_test.go +++ b/http/logical_test.go @@ -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) } diff --git a/http/util.go b/http/util.go index 1687644076..79cfd48102 100644 --- a/http/util.go +++ b/http/util.go @@ -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( diff --git a/internalshared/configutil/listener.go b/internalshared/configutil/listener.go index d06a70bdcc..a0422eace8 100644 --- a/internalshared/configutil/listener.go +++ b/internalshared/configutil/listener.go @@ -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 +} diff --git a/internalshared/configutil/listener_test.go b/internalshared/configutil/listener_test.go index 02237ca45b..d56bddeafd 100644 --- a/internalshared/configutil/listener_test.go +++ b/internalshared/configutil/listener_test.go @@ -214,16 +214,24 @@ func TestListener_parseRequestSettings(t *testing.T) { t.Parallel() tests := map[string]struct { - rawMaxRequestSize any - expectedMaxRequestSize int64 - rawMaxRequestDuration any - expectedDuration time.Duration - rawRequireRequestHeader any - expectedRequireRequestHeader bool - rawDisableRequestLimiter any - expectedDisableRequestLimiter bool - isErrorExpected bool - errorMessage string + rawMaxRequestSize any + expectedMaxRequestSize int64 + rawMaxRequestDuration any + expectedDuration time.Duration + rawRequireRequestHeader any + expectedRequireRequestHeader bool + rawDisableRequestLimiter any + expectedDisableRequestLimiter bool + rawCustomMaxJSONDepth any + expectedCustomMaxJSONDepth int64 + rawCustomMaxJSONStringValueLength any + expectedCustomMaxJSONStringValueLength int64 + rawCustomMaxJSONObjectEntryCount any + expectedCustomMaxJSONObjectEntryCount int64 + rawCustomMaxJSONArrayElementCount any + expectedCustomMaxJSONArrayElementCount int64 + isErrorExpected bool + errorMessage string }{ "nil": { isErrorExpected: false, @@ -238,37 +246,65 @@ func TestListener_parseRequestSettings(t *testing.T) { expectedMaxRequestSize: 5, isErrorExpected: false, }, - "max-request-duration-bad": { - rawMaxRequestDuration: "juan", + "max-json-depth-bad": { + rawCustomMaxJSONDepth: "badvalue", isErrorExpected: true, - errorMessage: "error parsing max_request_duration", + errorMessage: "error parsing max_json_depth", }, - "max-request-duration-good": { - rawMaxRequestDuration: "30s", - expectedDuration: 30 * time.Second, - isErrorExpected: false, + "max-json-depth-negative": { + rawCustomMaxJSONDepth: "-1", + isErrorExpected: true, + errorMessage: "max_json_depth cannot be negative", }, - "require-request-header-bad": { - rawRequireRequestHeader: "juan", - expectedRequireRequestHeader: false, - isErrorExpected: true, - errorMessage: "invalid value for require_request_header", + "max-json-depth-good": { + rawCustomMaxJSONDepth: "100", + expectedCustomMaxJSONDepth: 100, + isErrorExpected: false, }, - "require-request-header-good": { - rawRequireRequestHeader: "true", - expectedRequireRequestHeader: true, - isErrorExpected: false, + "max-json-string-value-length-bad": { + rawCustomMaxJSONStringValueLength: "badvalue", + isErrorExpected: true, + errorMessage: "error parsing max_json_string_value_length", }, - "disable-request-limiter-bad": { - rawDisableRequestLimiter: "badvalue", - expectedDisableRequestLimiter: false, - isErrorExpected: true, - errorMessage: "invalid value for disable_request_limiter", + "max-json-string-value-length-negative": { + rawCustomMaxJSONStringValueLength: "-1", + isErrorExpected: true, + errorMessage: "max_json_string_value_length cannot be negative", }, - "disable-request-limiter-good": { - rawDisableRequestLimiter: "true", - expectedDisableRequestLimiter: true, - isErrorExpected: false, + "max-json-string-value-length-good": { + rawCustomMaxJSONStringValueLength: "2048", + expectedCustomMaxJSONStringValueLength: 2048, + isErrorExpected: false, + }, + "custom-max-json-object-entry-count-bad": { + rawCustomMaxJSONObjectEntryCount: "badvalue", + isErrorExpected: true, + errorMessage: "error parsing max_json_object_entry_count", + }, + "max-json-object-entry-count-negative": { + rawCustomMaxJSONObjectEntryCount: "-1", + isErrorExpected: true, + errorMessage: "max_json_object_entry_count cannot be negative", + }, + "max-json-object-entry-count-good": { + rawCustomMaxJSONObjectEntryCount: "500", + expectedCustomMaxJSONObjectEntryCount: 500, + isErrorExpected: false, + }, + "max-json-array-element-count-bad": { + rawCustomMaxJSONArrayElementCount: "badvalue", + isErrorExpected: true, + errorMessage: "error parsing max_json_array_element_count", + }, + "max-json-array-element-count-negative": { + rawCustomMaxJSONArrayElementCount: "-1", + isErrorExpected: true, + errorMessage: "max_json_array_element_count cannot be negative", + }, + "max-json-array-element-count-good": { + rawCustomMaxJSONArrayElementCount: "500", + expectedCustomMaxJSONArrayElementCount: 500, + isErrorExpected: false, }, } @@ -278,12 +314,15 @@ func TestListener_parseRequestSettings(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - // Configure listener with raw values l := &Listener{ - MaxRequestSizeRaw: tc.rawMaxRequestSize, - MaxRequestDurationRaw: tc.rawMaxRequestDuration, - RequireRequestHeaderRaw: tc.rawRequireRequestHeader, - DisableRequestLimiterRaw: tc.rawDisableRequestLimiter, + MaxRequestSizeRaw: tc.rawMaxRequestSize, + MaxRequestDurationRaw: tc.rawMaxRequestDuration, + RequireRequestHeaderRaw: tc.rawRequireRequestHeader, + DisableRequestLimiterRaw: tc.rawDisableRequestLimiter, + CustomMaxJSONDepthRaw: tc.rawCustomMaxJSONDepth, + CustomMaxJSONStringValueLengthRaw: tc.rawCustomMaxJSONStringValueLength, + CustomMaxJSONObjectEntryCountRaw: tc.rawCustomMaxJSONObjectEntryCount, + CustomMaxJSONArrayElementCountRaw: tc.rawCustomMaxJSONArrayElementCount, } err := l.parseRequestSettings() @@ -293,15 +332,21 @@ func TestListener_parseRequestSettings(t *testing.T) { require.Error(t, err) require.ErrorContains(t, err, tc.errorMessage) default: - // Assert we got the relevant values. require.NoError(t, err) require.Equal(t, tc.expectedMaxRequestSize, l.MaxRequestSize) - require.Equal(t, tc.expectedDuration, l.MaxRequestDuration) + require.Equal(t, tc.expectedCustomMaxJSONDepth, l.CustomMaxJSONDepth) + 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.expectedRequireRequestHeader, l.RequireRequestHeader) require.Equal(t, tc.expectedDisableRequestLimiter, l.DisableRequestLimiter) + require.Equal(t, tc.expectedDuration, l.MaxRequestDuration) - // Ensure the state was modified for the raw values. require.Nil(t, l.MaxRequestSizeRaw) + require.Nil(t, l.CustomMaxJSONDepthRaw) + require.Nil(t, l.CustomMaxJSONStringValueLengthRaw) + require.Nil(t, l.CustomMaxJSONObjectEntryCountRaw) + require.Nil(t, l.CustomMaxJSONArrayElementCountRaw) require.Nil(t, l.MaxRequestDurationRaw) require.Nil(t, l.RequireRequestHeaderRaw) require.Nil(t, l.DisableRequestLimiterRaw) diff --git a/sdk/helper/jsonutil/json.go b/sdk/helper/jsonutil/json.go index 1abd9fafeb..76c84ae1f7 100644 --- a/sdk/helper/jsonutil/json.go +++ b/sdk/helper/jsonutil/json.go @@ -4,6 +4,7 @@ package jsonutil import ( + "bufio" "bytes" "compress/gzip" "encoding/json" @@ -14,7 +15,7 @@ import ( "github.com/hashicorp/vault/sdk/helper/compressutil" ) -// Encodes/Marshals the given object into JSON +// EncodeJSON encodes/marshals the given object into JSON func EncodeJSON(in interface{}) ([]byte, error) { if in == nil { return nil, fmt.Errorf("input for encoding is nil") @@ -84,7 +85,7 @@ func DecodeJSON(data []byte, out interface{}) error { return DecodeJSONFromReader(bytes.NewReader(data), out) } -// Decodes/Unmarshals the given io.Reader pointing to a JSON, into a desired object +// DecodeJSONFromReader Decodes/Unmarshals the given io.Reader pointing to a JSON, into a desired object func DecodeJSONFromReader(r io.Reader, out interface{}) error { if r == nil { return fmt.Errorf("'io.Reader' being decoded is nil") @@ -101,3 +102,146 @@ func DecodeJSONFromReader(r io.Reader, out interface{}) error { // Since 'out' is an interface representing a pointer, pass it to the decoder without an '&' return dec.Decode(out) } + +// 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) +} + +// JSONLimits defines the configurable limits for JSON validation. +type JSONLimits struct { + MaxDepth int + MaxStringValueLength int + MaxObjectEntryCount int + MaxArrayElementCount int +} + +// isWhitespace checks if a byte is a JSON whitespace character. +func isWhitespace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} + +// 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. +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. + } + } + + // 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() + + var ( + maxDepth = 0 + currentDepth = 0 + isKeyExpected bool + ) + containerInfoStack := make([]containerState, 0, limits.MaxDepth) + + for { + t, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + // Any error from the decoder is now considered a real error. + 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 currentDepth > maxDepth { + maxDepth = currentDepth + } + + containerInfoStack = append(containerInfoStack, containerState{Type: v, Count: 0}) + if v == '{' { + isKeyExpected = true + } + case '}', ']': + if len(containerInfoStack) == 0 { + return 0, fmt.Errorf("malformed JSON: unmatched closing delimiter '%c'", v) + } + 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) + } + if len(containerInfoStack) > 0 && containerInfoStack[len(containerInfoStack)-1].Type == '{' { + isKeyExpected = false + } + } + 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") + } + } + } + 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 len(containerInfoStack) != 0 { + return 0, fmt.Errorf("malformed JSON, unclosed containers") + } + + return maxDepth, nil +} diff --git a/sdk/helper/jsonutil/json_test.go b/sdk/helper/jsonutil/json_test.go index 10aabf1b93..9018b1f5a5 100644 --- a/sdk/helper/jsonutil/json_test.go +++ b/sdk/helper/jsonutil/json_test.go @@ -12,6 +12,46 @@ import ( "testing" "github.com/hashicorp/vault/sdk/helper/compressutil" + "github.com/stretchr/testify/require" +) + +const ( + // 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 500 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 = 500 + + // 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 ) func TestJSONUtil_CompressDecompressJSON(t *testing.T) { @@ -59,7 +99,7 @@ func TestJSONUtil_CompressDecompressJSON(t *testing.T) { t.Fatalf("expected a failure") } - // Compress an object + // Compress an object with BestSpeed compressedBytes, err = EncodeJSONAndCompress(expected, &compressutil.CompressionConfig{ Type: compressutil.CompressionTypeGzip, GzipCompressionLevel: gzip.BestSpeed, @@ -142,3 +182,131 @@ func TestJSONUtil_DecodeJSONFromReader(t *testing.T) { t.Fatalf("bad: expected:%#v\nactual:%#v", expected, actual) } } + +func TestJSONUtil_Limits(t *testing.T) { + tests := []struct { + name string + jsonInput string + expectError bool + errorMsg string + }{ + // Depth Limits + { + name: "JSON exceeding max depth", + jsonInput: generateComplexJSON(CustomMaxJSONDepth + 1), + expectError: true, + errorMsg: "JSON input exceeds allowed nesting depth", + }, + { + name: "JSON at max allowed depth", + jsonInput: generateComplexJSON(CustomMaxJSONDepth), + expectError: false, + }, + // Malformed JSON + { + name: "Malformed - Unmatched opening brace", + jsonInput: `{"a": {`, + expectError: true, + errorMsg: "malformed JSON, unclosed containers", + }, + { + name: "Malformed - Unmatched closing brace", + jsonInput: `{}}`, + expectError: true, + errorMsg: "error reading JSON token: invalid character '}' looking for beginning of value", + }, + // String Length Limits + { + name: "String value exceeding max length", + jsonInput: fmt.Sprintf(`{"key": "%s"}`, strings.Repeat("a", CustomMaxJSONStringValueLength+1)), + expectError: true, + errorMsg: "JSON string value exceeds allowed length", + }, + { + name: "String at max length", + jsonInput: fmt.Sprintf(`{"key": "%s"}`, strings.Repeat("a", CustomMaxJSONStringValueLength)), + expectError: false, + }, + // Object Entry Count Limits + { + name: "Object exceeding max entry count", + jsonInput: fmt.Sprintf(`{%s}`, generateObjectEntries(CustomMaxJSONObjectEntryCount+1)), + expectError: true, + errorMsg: "JSON object exceeds allowed entry count", + }, + { + name: "Object at max entry count", + jsonInput: fmt.Sprintf(`{%s}`, generateObjectEntries(CustomMaxJSONObjectEntryCount)), + expectError: false, + }, + // Array Element Count Limits + { + name: "Array exceeding max element count", + jsonInput: fmt.Sprintf(`[%s]`, generateArrayElements(CustomMaxJSONArrayElementCount+1)), + expectError: true, + errorMsg: "JSON array exceeds allowed element count", + }, + { + name: "Array at max element count", + jsonInput: fmt.Sprintf(`[%s]`, generateArrayElements(CustomMaxJSONArrayElementCount)), + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + limits := JSONLimits{ + MaxDepth: CustomMaxJSONDepth, + MaxStringValueLength: CustomMaxJSONStringValueLength, + MaxObjectEntryCount: CustomMaxJSONObjectEntryCount, + MaxArrayElementCount: CustomMaxJSONArrayElementCount, + } + + _, err := VerifyMaxDepthStreaming(bytes.NewReader([]byte(tt.jsonInput)), limits) + + if tt.expectError { + require.Error(t, err, "expected an error but got nil") + require.Contains(t, err.Error(), tt.errorMsg, "error message mismatch") + } else { + require.NoError(t, err, "did not expect an error but got one") + } + }) + } +} + +// generateComplexJSON generates a valid JSON string with a specified nesting depth. +func generateComplexJSON(depth int) string { + if depth <= 0 { + return "{}" + } + // Build the nested structure from the inside out. + json := "1" + for i := 0; i < depth; i++ { + json = fmt.Sprintf(`{"a":%s}`, json) + } + return json +} + +// generateObjectEntries creates a string of object entries for testing. +func generateObjectEntries(count int) string { + var sb strings.Builder + for i := 0; i < count; i++ { + sb.WriteString(fmt.Sprintf(`"key%d":%d`, i, i)) + if i < count-1 { + sb.WriteString(",") + } + } + return sb.String() +} + +// generateArrayElements creates a string of array elements for testing. +func generateArrayElements(count int) string { + var sb strings.Builder + for i := 0; i < count; i++ { + sb.WriteString(fmt.Sprintf("%d", i)) + if i < count-1 { + sb.WriteString(",") + } + } + return sb.String() +}