Skip to content

Commit eedc2b7

Browse files
authored
Add limit to JSON nesting depth (#31069)
* Add limit to JSON nesting depth * Add JSON limit check to http handler * Add changelog
1 parent e2273db commit eedc2b7

File tree

11 files changed

+587
-53
lines changed

11 files changed

+587
-53
lines changed

changelog/31069.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:change
2+
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`.
3+
```

command/server.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,26 @@ func (c *ServerCommand) InitListeners(config *server.Config, disableClustering b
899899
}
900900
props["max_request_size"] = fmt.Sprintf("%d", lnConfig.MaxRequestSize)
901901

902+
if lnConfig.CustomMaxJSONDepth == 0 {
903+
lnConfig.CustomMaxJSONDepth = vaulthttp.CustomMaxJSONDepth
904+
}
905+
props["max_json_depth"] = fmt.Sprintf("%d", lnConfig.CustomMaxJSONDepth)
906+
907+
if lnConfig.CustomMaxJSONStringValueLength == 0 {
908+
lnConfig.CustomMaxJSONStringValueLength = vaulthttp.CustomMaxJSONStringValueLength
909+
}
910+
props["max_json_string_value_length"] = fmt.Sprintf("%d", lnConfig.CustomMaxJSONStringValueLength)
911+
912+
if lnConfig.CustomMaxJSONObjectEntryCount == 0 {
913+
lnConfig.CustomMaxJSONObjectEntryCount = vaulthttp.CustomMaxJSONObjectEntryCount
914+
}
915+
props["max_json_object_entry_count"] = fmt.Sprintf("%d", lnConfig.CustomMaxJSONObjectEntryCount)
916+
917+
if lnConfig.CustomMaxJSONArrayElementCount == 0 {
918+
lnConfig.CustomMaxJSONArrayElementCount = vaulthttp.CustomMaxJSONArrayElementCount
919+
}
920+
props["max_json_array_element_count"] = fmt.Sprintf("%d", lnConfig.CustomMaxJSONArrayElementCount)
921+
902922
if lnConfig.MaxRequestDuration == 0 {
903923
lnConfig.MaxRequestDuration = vault.DefaultMaxRequestDuration
904924
}

http/handler.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,43 @@ const (
8585
// VaultSnapshotRecoverParam is the query parameter sent when Vault should
8686
// recover the data from a loaded snapshot
8787
VaultSnapshotRecoverParam = "recover_snapshot_id"
88+
89+
// CustomMaxJSONDepth specifies the maximum nesting depth of a JSON object.
90+
// This limit is designed to prevent stack exhaustion attacks from deeply
91+
// nested JSON payloads, which could otherwise lead to a denial-of-service
92+
// (DoS) vulnerability. The default value of 300 is intentionally generous
93+
// to support complex but legitimate configurations, while still providing
94+
// a safeguard against malicious or malformed input. This value is
95+
// configurable to accommodate unique environmental requirements.
96+
CustomMaxJSONDepth = 300
97+
98+
// CustomMaxJSONStringValueLength defines the maximum allowed length for a single
99+
// string value within a JSON payload, in bytes. This is a critical defense
100+
// against excessive memory allocation attacks where a client might send a
101+
// very large string value to exhaust server memory. The default of 1MB
102+
// (1024 * 1024 bytes) is chosen to comfortably accommodate large secrets
103+
// such as private keys, certificate chains, or detailed configuration data,
104+
// without permitting unbounded allocation. This value is configurable.
105+
CustomMaxJSONStringValueLength = 1024 * 1024 // 1MB
106+
107+
// CustomMaxJSONObjectEntryCount sets the maximum number of key-value pairs
108+
// allowed in a single JSON object. This limit helps mitigate the risk of
109+
// hash-collision denial-of-service (HashDoS) attacks and prevents general
110+
// resource exhaustion from parsing objects with an excessive number of
111+
// entries. A default of 10,000 entries is well beyond the scope of typical
112+
// Vault secrets or configurations, providing a high ceiling for normal
113+
// operations while ensuring stability. This value is configurable.
114+
CustomMaxJSONObjectEntryCount = 10000
115+
116+
// CustomMaxJSONArrayElementCount determines the maximum number of elements
117+
// permitted in a single JSON array. This is particularly relevant for API
118+
// endpoints that can return large lists, such as the result of a `LIST`
119+
// operation on a secrets engine path. The default limit of 10,000 elements
120+
// prevents a single request from causing excessive memory consumption. While
121+
// most environments will fall well below this limit, it is configurable for
122+
// systems that require handling larger datasets, though pagination is the
123+
// recommended practice for such cases.
124+
CustomMaxJSONArrayElementCount = 10000
88125
)
89126

90127
var (

http/handler_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -938,7 +938,7 @@ func TestHandler_MaxRequestSize(t *testing.T) {
938938
"bar": strings.Repeat("a", 1025),
939939
})
940940

941-
require.ErrorContains(t, err, "error parsing JSON")
941+
require.ErrorContains(t, err, "http: request body too large")
942942
}
943943

944944
// TestHandler_MaxRequestSize_Memory sets the max request size to 1024 bytes,

http/logical.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ func buildLogicalRequestNoAuth(perfStandby bool, ra *vault.RouterAccess, w http.
147147
if err != nil {
148148
status := http.StatusBadRequest
149149
logical.AdjustErrorStatusCode(&status, err)
150-
return nil, nil, status, fmt.Errorf("error parsing JSON")
150+
return nil, nil, status, fmt.Errorf("error parsing JSON: %w", err)
151151
}
152152
}
153153
}

http/logical_test.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,15 @@ func TestLogical_RequestSizeDisableLimit(t *testing.T) {
310310

311311
// Write a very large object, should pass as MaxRequestSize set to -1/Negative value
312312

313+
// Test change: Previously used DefaultMaxRequestSize to create a large payload.
314+
// However, after introducing JSON limits, the test successfully disables the first layer (MaxRequestSize),
315+
// but its large 32MB payload is then correctly caught by the second layer—specifically,
316+
// the CustomMaxStringValueLength limit, which defaults to 1MB.
317+
// Create a payload that is larger than a typical small limit (e.g., > 1KB),
318+
// but is well within the default JSON string length limit (1MB).
319+
// This isolates the test to *only* the MaxRequestSize behavior.
313320
resp := testHttpPut(t, token, addr+"/v1/secret/foo", map[string]interface{}{
314-
"data": make([]byte, DefaultMaxRequestSize),
321+
"data": make([]byte, 2048),
315322
})
316323
testResponseStatus(t, resp, http.StatusNoContent)
317324
}

http/util.go

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/hashicorp/go-multierror"
1616
"github.com/hashicorp/vault/helper/namespace"
1717
"github.com/hashicorp/vault/limits"
18+
"github.com/hashicorp/vault/sdk/helper/jsonutil"
1819
"github.com/hashicorp/vault/sdk/logical"
1920
"github.com/hashicorp/vault/vault"
2021
"github.com/hashicorp/vault/vault/quotas"
@@ -24,25 +25,80 @@ var nonVotersAllowed = false
2425

2526
func wrapMaxRequestSizeHandler(handler http.Handler, props *vault.HandlerProperties) http.Handler {
2627
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27-
var maxRequestSize int64
28+
var maxRequestSize, maxJSONDepth, maxStringValueLength, maxObjectEntryCount, maxArrayElementCount int64
29+
2830
if props.ListenerConfig != nil {
2931
maxRequestSize = props.ListenerConfig.MaxRequestSize
32+
maxJSONDepth = props.ListenerConfig.CustomMaxJSONDepth
33+
maxStringValueLength = props.ListenerConfig.CustomMaxJSONStringValueLength
34+
maxObjectEntryCount = props.ListenerConfig.CustomMaxJSONObjectEntryCount
35+
maxArrayElementCount = props.ListenerConfig.CustomMaxJSONArrayElementCount
3036
}
37+
3138
if maxRequestSize == 0 {
3239
maxRequestSize = DefaultMaxRequestSize
3340
}
34-
ctx := r.Context()
35-
originalBody := r.Body
41+
if maxJSONDepth == 0 {
42+
maxJSONDepth = CustomMaxJSONDepth
43+
}
44+
if maxStringValueLength == 0 {
45+
maxStringValueLength = CustomMaxJSONStringValueLength
46+
}
47+
if maxObjectEntryCount == 0 {
48+
maxObjectEntryCount = CustomMaxJSONObjectEntryCount
49+
}
50+
if maxArrayElementCount == 0 {
51+
maxArrayElementCount = CustomMaxJSONArrayElementCount
52+
}
53+
54+
jsonLimits := jsonutil.JSONLimits{
55+
MaxDepth: int(maxJSONDepth),
56+
MaxStringValueLength: int(maxStringValueLength),
57+
MaxObjectEntryCount: int(maxObjectEntryCount),
58+
MaxArrayElementCount: int(maxArrayElementCount),
59+
}
60+
61+
// If the payload is JSON, the VerifyMaxDepthStreaming function will perform validations.
62+
buf, err := jsonLimitsValidation(w, r, maxRequestSize, jsonLimits)
63+
if err != nil {
64+
respondError(w, http.StatusInternalServerError, err)
65+
return
66+
}
67+
68+
// Replace the body and update the context.
69+
// This ensures the request object is in a consistent state for all downstream handlers.
70+
// Because the original request body stream has been fully consumed by io.ReadAll,
71+
// we must replace it so that subsequent handlers can read the content.
72+
r.Body = newMultiReaderCloser(buf, r.Body)
73+
contextBody := r.Body
74+
ctx := logical.CreateContextOriginalBody(r.Context(), contextBody)
75+
3676
if maxRequestSize > 0 {
3777
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
3878
}
39-
ctx = logical.CreateContextOriginalBody(ctx, originalBody)
4079
r = r.WithContext(ctx)
4180

4281
handler.ServeHTTP(w, r)
4382
})
4483
}
4584

85+
func jsonLimitsValidation(w http.ResponseWriter, r *http.Request, maxRequestSize int64, jsonLimits jsonutil.JSONLimits) (*bytes.Buffer, error) {
86+
// The TeeReader reads from the original body and writes a copy to our buffer.
87+
// We wrap the original body with a MaxBytesReader first to enforce the hard size limit.
88+
var limitedTeeReader io.Reader
89+
buf := &bytes.Buffer{}
90+
bodyReader := r.Body
91+
if maxRequestSize > 0 {
92+
bodyReader = http.MaxBytesReader(w, r.Body, maxRequestSize)
93+
}
94+
limitedTeeReader = io.TeeReader(bodyReader, buf)
95+
_, err := jsonutil.VerifyMaxDepthStreaming(limitedTeeReader, jsonLimits)
96+
if err != nil {
97+
return nil, err
98+
}
99+
return buf, nil
100+
}
101+
46102
func wrapRequestLimiterHandler(handler http.Handler, props *vault.HandlerProperties) http.Handler {
47103
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48104
request := r.WithContext(

internalshared/configutil/listener.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,24 @@ type Listener struct {
149149
// DisableRequestLimiter allows per-listener disabling of the Request Limiter.
150150
DisableRequestLimiterRaw any `hcl:"disable_request_limiter"`
151151
DisableRequestLimiter bool `hcl:"-"`
152+
153+
// JSON-specific limits
154+
155+
// CustomMaxJSONDepth specifies the maximum nesting depth of a JSON object.
156+
CustomMaxJSONDepthRaw interface{} `hcl:"max_json_depth"`
157+
CustomMaxJSONDepth int64 `hcl:"-"`
158+
159+
// CustomMaxJSONStringValueLength defines the maximum allowed length for a string in a JSON payload.
160+
CustomMaxJSONStringValueLengthRaw interface{} `hcl:"max_json_string_value_length"`
161+
CustomMaxJSONStringValueLength int64 `hcl:"-"`
162+
163+
// CustomMaxJSONObjectEntryCount sets the maximum number of key-value pairs in a JSON object.
164+
CustomMaxJSONObjectEntryCountRaw interface{} `hcl:"max_json_object_entry_count"`
165+
CustomMaxJSONObjectEntryCount int64 `hcl:"-"`
166+
167+
// CustomMaxJSONArrayElementCount determines the maximum number of elements in a JSON array.
168+
CustomMaxJSONArrayElementCountRaw interface{} `hcl:"max_json_array_element_count"`
169+
CustomMaxJSONArrayElementCount int64 `hcl:"-"`
152170
}
153171

154172
// AgentAPI allows users to select which parts of the Agent API they want enabled.
@@ -468,6 +486,10 @@ func (l *Listener) parseRequestSettings() error {
468486
return fmt.Errorf("invalid value for disable_request_limiter: %w", err)
469487
}
470488

489+
if err := l.parseJSONLimitsSettings(); err != nil {
490+
return err
491+
}
492+
471493
return nil
472494
}
473495

@@ -710,3 +732,35 @@ func (l *Listener) parseRedactionSettings() error {
710732

711733
return nil
712734
}
735+
736+
func (l *Listener) parseJSONLimitsSettings() error {
737+
if err := parseAndClearInt(&l.CustomMaxJSONDepthRaw, &l.CustomMaxJSONDepth); err != nil {
738+
return fmt.Errorf("error parsing max_json_depth: %w", err)
739+
}
740+
if l.CustomMaxJSONDepth < 0 {
741+
return fmt.Errorf("max_json_depth cannot be negative")
742+
}
743+
744+
if err := parseAndClearInt(&l.CustomMaxJSONStringValueLengthRaw, &l.CustomMaxJSONStringValueLength); err != nil {
745+
return fmt.Errorf("error parsing max_json_string_value_length: %w", err)
746+
}
747+
if l.CustomMaxJSONStringValueLength < 0 {
748+
return fmt.Errorf("max_json_string_value_length cannot be negative")
749+
}
750+
751+
if err := parseAndClearInt(&l.CustomMaxJSONObjectEntryCountRaw, &l.CustomMaxJSONObjectEntryCount); err != nil {
752+
return fmt.Errorf("error parsing max_json_object_entry_count: %w", err)
753+
}
754+
if l.CustomMaxJSONObjectEntryCount < 0 {
755+
return fmt.Errorf("max_json_object_entry_count cannot be negative")
756+
}
757+
758+
if err := parseAndClearInt(&l.CustomMaxJSONArrayElementCountRaw, &l.CustomMaxJSONArrayElementCount); err != nil {
759+
return fmt.Errorf("error parsing max_json_array_element_count: %w", err)
760+
}
761+
if l.CustomMaxJSONArrayElementCount < 0 {
762+
return fmt.Errorf("max_json_array_element_count cannot be negative")
763+
}
764+
765+
return nil
766+
}

0 commit comments

Comments
 (0)