WW-5618 feat(json): add configurable limits to JSON plugin for DoS prevention (#1626)

Add configurable limits to the JSON plugin to prevent denial-of-service
attacks via malicious JSON payloads. Limits are enforced directly in the
existing JSONReader class without breaking backward compatibility (no
interface extraction or class renames).

New configurable constants (struts-plugin.xml defaults):
- struts.json.maxElements (10000) - per-container element count
- struts.json.maxDepth (64) - maximum nesting depth
- struts.json.maxLength (2097152) - maximum input length in chars
- struts.json.maxStringLength (262144) - maximum string value length
- struts.json.maxKeyLength (512) - maximum object key length

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukasz Lenart
2026-03-21 12:11:22 +01:00
committed by GitHub
parent 0c47627910
commit f644ea54e2
7 changed files with 420 additions and 35 deletions
@@ -31,4 +31,10 @@ public class JSONConstants {
public static final String JSON_WRITER = "struts.json.writer";
public static final String RESULT_EXCLUDE_PROXY_PROPERTIES = "struts.json.result.excludeProxyProperties";
public static final String DATE_FORMAT = "struts.json.dateformat";
public static final String JSON_MAX_ELEMENTS = "struts.json.maxElements";
public static final String JSON_MAX_DEPTH = "struts.json.maxDepth";
public static final String JSON_MAX_LENGTH = "struts.json.maxLength";
public static final String JSON_MAX_STRING_LENGTH = "struts.json.maxStringLength";
public static final String JSON_MAX_KEY_LENGTH = "struts.json.maxKeyLength";
}
@@ -71,6 +71,13 @@ public class JSONInterceptor extends AbstractInterceptor {
private String jsonContentType = "application/json";
private String jsonRpcContentType = "application/json-rpc";
private JSONUtil jsonUtil = new JSONUtil();
private int maxElements = 10000;
private int maxDepth = 64;
private int maxLength = 2097152;
private int maxStringLength = 262144;
private int maxKeyLength = 512;
@SuppressWarnings("unchecked")
public String intercept(ActionInvocation invocation) throws Exception {
HttpServletRequest request = ServletActionContext.getRequest();
@@ -91,7 +98,8 @@ public class JSONInterceptor extends AbstractInterceptor {
if (jsonContentType.equalsIgnoreCase(requestContentType)) {
// load JSON object
Object obj = JSONUtil.deserialize(request.getReader());
Object obj = jsonUtil.deserializeInput(request.getReader(), maxLength, maxElements, maxDepth,
maxStringLength, maxKeyLength);
// JSON array (this.root cannot be null in this case)
if(obj instanceof List && this.root != null) {
@@ -133,7 +141,8 @@ public class JSONInterceptor extends AbstractInterceptor {
Object result;
if (this.enableSMD) {
// load JSON object
Object obj = JSONUtil.deserialize(request.getReader());
Object obj = jsonUtil.deserializeInput(request.getReader(), maxLength, maxElements, maxDepth,
maxStringLength, maxKeyLength);
if (obj instanceof Map) {
Map smd = (Map) obj;
@@ -168,9 +177,8 @@ public class JSONInterceptor extends AbstractInterceptor {
result = rpcResponse;
}
JSONUtil jsonUtil = invocation.getInvocationContext().getContainer().getInstance(JSONUtil.class);
String json = jsonUtil.serialize(result, excludeProperties, getIncludeProperties(),
JSONUtil smdJsonUtil = invocation.getInvocationContext().getContainer().getInstance(JSONUtil.class);
String json = smdJsonUtil.serialize(result, excludeProperties, getIncludeProperties(),
ignoreHierarchy, excludeNullProperties);
json = addCallbackIfApplicable(request, json);
boolean writeGzip = enableGZIP && JSONUtil.isGzipInRequest(request);
@@ -423,6 +431,36 @@ public class JSONInterceptor extends AbstractInterceptor {
setDebug(BooleanUtils.toBoolean(mode));
}
@Inject
public void setJsonUtil(JSONUtil jsonUtil) {
this.jsonUtil = jsonUtil;
}
@Inject(value = JSONConstants.JSON_MAX_ELEMENTS, required = false)
public void setMaxElements(String maxElements) {
this.maxElements = Integer.parseInt(maxElements);
}
@Inject(value = JSONConstants.JSON_MAX_DEPTH, required = false)
public void setMaxDepth(String maxDepth) {
this.maxDepth = Integer.parseInt(maxDepth);
}
@Inject(value = JSONConstants.JSON_MAX_LENGTH, required = false)
public void setMaxLength(String maxLength) {
this.maxLength = Integer.parseInt(maxLength);
}
@Inject(value = JSONConstants.JSON_MAX_STRING_LENGTH, required = false)
public void setMaxStringLength(String maxStringLength) {
this.maxStringLength = Integer.parseInt(maxStringLength);
}
@Inject(value = JSONConstants.JSON_MAX_KEY_LENGTH, required = false)
public void setMaxKeyLength(String maxKeyLength) {
this.maxKeyLength = Integer.parseInt(maxKeyLength);
}
/**
* Sets a comma-delimited list of regular expressions to match properties
* that should be excluded from the JSON output.
@@ -48,11 +48,38 @@ public class JSONReader {
escapes.put('t', '\t');
}
private static final int DEFAULT_MAX_ELEMENTS = 10000;
private static final int DEFAULT_MAX_DEPTH = 64;
private static final int DEFAULT_MAX_STRING_LENGTH = 262144;
private static final int DEFAULT_MAX_KEY_LENGTH = 512;
private int maxElements = DEFAULT_MAX_ELEMENTS;
private int maxDepth = DEFAULT_MAX_DEPTH;
private int maxStringLength = DEFAULT_MAX_STRING_LENGTH;
private int maxKeyLength = DEFAULT_MAX_KEY_LENGTH;
private int depth;
private CharacterIterator it;
private char c;
private Object token;
private StringBuilder buf = new StringBuilder();
public void setMaxElements(int maxElements) {
this.maxElements = maxElements;
}
public void setMaxDepth(int maxDepth) {
this.maxDepth = maxDepth;
}
public void setMaxStringLength(int maxStringLength) {
this.maxStringLength = maxStringLength;
}
public void setMaxKeyLength(int maxKeyLength) {
this.maxKeyLength = maxKeyLength;
}
protected char next() {
this.c = this.it.next();
@@ -124,29 +151,51 @@ public class JSONReader {
@SuppressWarnings("unchecked")
protected Map object() throws JSONException {
Map ret = new HashMap();
Object next = this.read();
if (next != OBJECT_END) {
String key = (String) next;
while (this.token != OBJECT_END) {
this.read(); // should be a colon
this.depth++;
if (this.depth > this.maxDepth) {
throw new JSONException("JSON object depth exceeds maximum allowed depth of " + this.maxDepth);
}
try {
Map ret = new HashMap();
Object next = this.read();
if (next != OBJECT_END) {
String key = (String) next;
while (this.token != OBJECT_END) {
validateKeyLength(key);
this.read(); // should be a colon
if (this.token != OBJECT_END) {
ret.put(key, this.read());
if (this.token != OBJECT_END) {
ret.put(key, this.read());
validateElementCount(ret.size());
if (this.read() == COMMA) {
Object name = this.read();
if (this.read() == COMMA) {
Object name = this.read();
if (name instanceof String) {
key = (String) name;
} else
throw buildInvalidInputException();
if (name instanceof String) {
key = (String) name;
} else
throw buildInvalidInputException();
}
}
}
}
}
return ret;
return ret;
} finally {
this.depth--;
}
}
private void validateKeyLength(String key) throws JSONException {
if (key != null && key.length() > this.maxKeyLength) {
throw new JSONException("JSON key length " + key.length() + " exceeds maximum allowed length of " + this.maxKeyLength);
}
}
private void validateElementCount(int count) throws JSONException {
if (count > this.maxElements) {
throw new JSONException("JSON element count exceeds maximum allowed count of " + this.maxElements);
}
}
protected JSONException buildInvalidInputException() {
@@ -156,21 +205,30 @@ public class JSONReader {
@SuppressWarnings("unchecked")
protected List array() throws JSONException {
List ret = new ArrayList();
Object value = this.read();
while (this.token != ARRAY_END) {
ret.add(value);
Object read = this.read();
if (read == COMMA) {
value = this.read();
} else if (read != ARRAY_END) {
throw buildInvalidInputException();
}
this.depth++;
if (this.depth > this.maxDepth) {
throw new JSONException("JSON array depth exceeds maximum allowed depth of " + this.maxDepth);
}
try {
List ret = new ArrayList();
Object value = this.read();
return ret;
while (this.token != ARRAY_END) {
ret.add(value);
validateElementCount(ret.size());
Object read = this.read();
if (read == COMMA) {
value = this.read();
} else if (read != ARRAY_END) {
throw buildInvalidInputException();
}
}
return ret;
} finally {
this.depth--;
}
}
protected Object number() throws JSONException {
@@ -215,7 +273,7 @@ public class JSONReader {
}
}
protected Object string(char quote) {
protected Object string(char quote) throws JSONException {
this.buf.setLength(0);
while ((this.c != quote) && (this.c != CharacterIterator.DONE)) {
@@ -234,6 +292,9 @@ public class JSONReader {
} else {
this.add();
}
if (this.buf.length() > this.maxStringLength) {
throw new JSONException("JSON string length exceeds maximum allowed length of " + this.maxStringLength);
}
}
this.next();
@@ -323,6 +323,44 @@ public class JSONUtil {
return deserialize(buffer.toString());
}
/**
* Deserializes a JSON object from a Reader with configurable limits to prevent DoS attacks.
*
* @param reader Reader to read JSON string from
* @param maxLength maximum allowed input length in characters
* @param maxElements maximum allowed number of elements in a single object/array
* @param maxDepth maximum allowed nesting depth
* @param maxStringLength maximum allowed string value length
* @param maxKeyLength maximum allowed key length
* @return deserialized object
* @throws JSONException when limits are exceeded or input is malformed
*/
public Object deserializeInput(Reader reader, int maxLength, int maxElements, int maxDepth,
int maxStringLength, int maxKeyLength) throws JSONException {
BufferedReader bufferReader = new BufferedReader(reader);
StringBuilder buffer = new StringBuilder();
String line;
try {
while ((line = bufferReader.readLine()) != null) {
buffer.append(line);
if (buffer.length() > maxLength) {
throw new JSONException("JSON input length exceeds maximum allowed length of " + maxLength);
}
}
} catch (IOException e) {
throw new JSONException(e);
}
JSONReader jsonReader = new JSONReader();
jsonReader.setMaxElements(maxElements);
jsonReader.setMaxDepth(maxDepth);
jsonReader.setMaxStringLength(maxStringLength);
jsonReader.setMaxKeyLength(maxKeyLength);
return jsonReader.read(buffer.toString());
}
public static void writeJSONToResponse(SerializationParams serializationParams) throws IOException {
StringBuilder stringBuilder = new StringBuilder();
if (StringUtils.isNotBlank(serializationParams.getSerializedJSON()))
@@ -27,6 +27,11 @@
<bean type="org.apache.struts2.json.JSONWriter" name="struts" class="org.apache.struts2.json.DefaultJSONWriter"
scope="prototype"/>
<constant name="struts.json.writer" value="struts"/>
<constant name="struts.json.maxElements" value="10000"/>
<constant name="struts.json.maxDepth" value="64"/>
<constant name="struts.json.maxLength" value="2097152"/>
<constant name="struts.json.maxStringLength" value="262144"/>
<constant name="struts.json.maxKeyLength" value="512"/>
<!-- TODO: Make DefaultJSONWriter thread-safe to remove "prototype"s -->
<bean class="org.apache.struts2.json.JSONUtil" scope="prototype"/>
@@ -0,0 +1,196 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.struts2.json;
import org.junit.Test;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
/**
* Tests for JSON reader configurable limits (WW-5618).
*/
public class JSONReaderLimitsTest {
// --- maxDepth ---
@Test
public void testMaxDepthExceededByNestedObjects() {
JSONReader reader = new JSONReader();
reader.setMaxDepth(2);
// 3 levels of nesting exceeds limit of 2
try {
reader.read("{\"a\":{\"b\":{\"c\":1}}}");
fail("Expected JSONException for exceeding max depth");
} catch (JSONException e) {
assertTrue(e.getMessage().contains("2"));
}
}
@Test
public void testMaxDepthExceededByNestedArrays() {
JSONReader reader = new JSONReader();
reader.setMaxDepth(2);
try {
reader.read("[[[1]]]");
fail("Expected JSONException for exceeding max depth");
} catch (JSONException e) {
assertTrue(e.getMessage().contains("2"));
}
}
@Test
public void testMaxDepthAtBoundaryIsAllowed() throws JSONException {
JSONReader reader = new JSONReader();
// 3 levels of nesting, limit is exactly 3 — should pass
reader.setMaxDepth(3);
Object result = reader.read("{\"a\":{\"b\":{\"c\":1}}}");
assertNotNull(result);
}
@Test
public void testMaxDepthOneBelowBoundaryIsRejected() {
JSONReader reader = new JSONReader();
// 3 levels of nesting, limit is 2 — should fail
reader.setMaxDepth(2);
try {
reader.read("{\"a\":{\"b\":{\"c\":1}}}");
fail("Expected JSONException");
} catch (JSONException e) {
assertTrue(e.getMessage().contains("depth"));
}
}
// --- maxElements (per-container) ---
@Test
public void testMaxElementsExceededInObject() {
JSONReader reader = new JSONReader();
reader.setMaxElements(2);
// 3 keys in one object exceeds per-container limit of 2
try {
reader.read("{\"a\":1,\"b\":2,\"c\":3}");
fail("Expected JSONException for exceeding max elements");
} catch (JSONException e) {
assertTrue(e.getMessage().contains("2"));
}
}
@Test
public void testMaxElementsExceededInArray() {
JSONReader reader = new JSONReader();
reader.setMaxElements(2);
try {
reader.read("[1,2,3]");
fail("Expected JSONException for exceeding max elements");
} catch (JSONException e) {
assertTrue(e.getMessage().contains("2"));
}
}
@Test
public void testMaxElementsAtBoundaryIsAllowed() throws JSONException {
JSONReader reader = new JSONReader();
// exactly 3 elements, limit is 3 — should pass
reader.setMaxElements(3);
Object result = reader.read("{\"a\":1,\"b\":2,\"c\":3}");
assertNotNull(result);
}
@Test
public void testMaxElementsIsPerContainerNotGlobal() throws JSONException {
JSONReader reader = new JSONReader();
// limit is 2; two containers each with 2 elements — should pass
// because the limit applies per-container, not globally
reader.setMaxElements(2);
Object result = reader.read("{\"a\":[1,2],\"b\":[3,4]}");
assertNotNull(result);
}
// --- maxStringLength (applies to both values and keys) ---
@Test
public void testMaxStringLengthExceededByValue() {
JSONReader reader = new JSONReader();
reader.setMaxStringLength(5);
// "abcdef" is 6 chars, exceeds limit of 5
try {
reader.read("{\"k\":\"abcdef\"}");
fail("Expected JSONException for exceeding max string length");
} catch (JSONException e) {
assertTrue(e.getMessage().contains("5"));
}
}
@Test
public void testMaxStringLengthExceededByKey() {
JSONReader reader = new JSONReader();
reader.setMaxStringLength(3);
// key "longkey" is 7 chars, parsed through string() before validateKeyLength
try {
reader.read("{\"longkey\":1}");
fail("Expected JSONException for exceeding max string length on key");
} catch (JSONException e) {
assertTrue(e.getMessage().contains("string length") || e.getMessage().contains("key length"));
}
}
@Test
public void testMaxStringLengthAtBoundaryIsAllowed() throws JSONException {
JSONReader reader = new JSONReader();
// "abcdef" is 6 chars, limit is 6 — should pass
reader.setMaxStringLength(6);
Object result = reader.read("{\"k\":\"abcdef\"}");
assertNotNull(result);
}
// --- maxKeyLength ---
@Test
public void testMaxKeyLengthExceeded() {
JSONReader reader = new JSONReader();
reader.setMaxKeyLength(3);
// "longkey" is 7 chars, exceeds limit of 3
try {
reader.read("{\"longkey\":1}");
fail("Expected JSONException for exceeding max key length");
} catch (JSONException e) {
assertTrue(e.getMessage().contains("key length") || e.getMessage().contains("string length"));
}
}
@Test
public void testMaxKeyLengthAtBoundaryIsAllowed() throws JSONException {
JSONReader reader = new JSONReader();
// "key" is 3 chars, limit is 3 — should pass
reader.setMaxKeyLength(3);
Object result = reader.read("{\"key\":1}");
assertNotNull(result);
}
// --- smoke test ---
@Test
public void testDefaultLimitsAcceptTypicalPayload() throws JSONException {
JSONReader reader = new JSONReader();
Object result = reader.read("{\"name\":\"test\",\"values\":[1,2,3],\"nested\":{\"a\":true}}");
assertNotNull(result);
}
}
@@ -18,6 +18,7 @@
*/
package org.apache.struts2.json;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -79,4 +80,44 @@ public class JSONUtilTest extends TestCase {
assertEquals("{\"listOfLists\":[[\"1\",\"2\"],[\"3\",\"4\"],[\"5\",\"6\"],[\"7\",\"8\"],[\"9\",\"0\"]]}", actual);
}
public void testDeserializeInputWithinLimits() throws Exception {
JSONUtil jsonUtil = new JSONUtil();
String json = "{\"name\":\"test\",\"value\":123}";
Object result = jsonUtil.deserializeInput(new StringReader(json), 1000, 100, 10, 1000, 100);
assertNotNull(result);
assertTrue(result instanceof Map);
assertEquals("test", ((Map) result).get("name"));
}
public void testDeserializeInputMaxLengthExceeded() throws Exception {
JSONUtil jsonUtil = new JSONUtil();
String json = "{\"name\":\"test\",\"value\":123}";
try {
jsonUtil.deserializeInput(new StringReader(json), 10, 100, 10, 1000, 100);
fail("Expected JSONException for exceeding max length");
} catch (JSONException e) {
assertTrue(e.getMessage().contains("10"));
}
}
public void testDeserializeInputMaxLengthAtBoundaryIsAllowed() throws Exception {
JSONUtil jsonUtil = new JSONUtil();
String json = "{\"a\":1}";
// length is 7, limit is 7 — should pass
Object result = jsonUtil.deserializeInput(new StringReader(json), 7, 100, 10, 1000, 100);
assertNotNull(result);
}
public void testDeserializeInputPropagatesReaderLimits() throws Exception {
JSONUtil jsonUtil = new JSONUtil();
// depth 3, but maxDepth is 2
String json = "{\"a\":{\"b\":{\"c\":1}}}";
try {
jsonUtil.deserializeInput(new StringReader(json), 10000, 100, 2, 1000, 100);
fail("Expected JSONException for exceeding max depth");
} catch (JSONException e) {
assertTrue(e.getMessage().contains("depth"));
}
}
}