From 8afaf73d419e9863cad82c2a31e38bea4b0b8056 Mon Sep 17 00:00:00 2001
From: g0w6y
Date: Thu, 9 Jul 2026 20:44:07 +0530
Subject: [PATCH 1/2] fix(json): confine StrutsJSONReader parse state to the
parsing thread
JSONInterceptor obtains its JSONReader once via @Inject and reuses that
same instance across every concurrent request handled by that
interceptor. StrutsJSONReader kept its parse cursor, token buffer and
nesting-depth counter (used to enforce maxDepth/maxElements/
maxStringLength/maxKeyLength) as plain instance fields, so two
concurrent read() calls on the same instance tore each other's state:
one request's depth counter could be decremented by an unrelated
concurrent request finishing its own parse, letting payloads deeper
than the configured maxDepth through, and the shared character cursor
and string/number buffer let fragments of one request's JSON body leak
into a different, concurrently-parsed request's result.
Move the cursor, current character, token, buffer and depth into a
ParseState confined to a ThreadLocal, scoped to a single read() call.
Method signatures and behavior are otherwise unchanged so existing
StrutsJSONReader subclasses keep working; the limit fields
(maxElements/maxDepth/maxStringLength/maxKeyLength) stay as plain
instance fields since they are set to the same value on every call for
a given interceptor configuration and are safe to share.
---
.../apache/struts2/json/StrutsJSONReader.java | 134 ++++++++++--------
.../struts2/json/StrutsJSONReaderTest.java | 131 +++++++++++++++++
2 files changed, 209 insertions(+), 56 deletions(-)
diff --git a/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONReader.java b/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONReader.java
index 530a479cc6..96593d2b97 100644
--- a/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONReader.java
+++ b/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONReader.java
@@ -30,6 +30,13 @@
* Deserializes an object from a JSON string with configurable limits
* to prevent denial-of-service attacks via malicious payloads.
*
+ *
+ *
+ * A single StrutsJSONReader instance is shared across all concurrent requests handled by a given
+ * JSONInterceptor (it is injected once, not created per request), so the cursor, token buffer and
+ * nesting depth of an in-progress parse are kept in a {@link ThreadLocal}, not instance fields --
+ * otherwise two concurrent {@link #read(String)} calls would corrupt each other's parse state.
+ *
*/
public class StrutsJSONReader implements JSONReader {
private static final Object OBJECT_END = new Object();
@@ -47,16 +54,20 @@ public class StrutsJSONReader implements JSONReader {
't', '\t'
);
- private CharacterIterator it;
- private char c;
- private Object token;
- private final StringBuilder buf = new StringBuilder();
+ private static final class ParseState {
+ private CharacterIterator it;
+ private char c;
+ private Object token;
+ private final StringBuilder buf = new StringBuilder();
+ private int depth;
+ }
+
+ private static final ThreadLocal PARSE_STATE = new ThreadLocal<>();
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;
@Override
public void setMaxElements(int maxElements) {
@@ -79,92 +90,99 @@ public void setMaxKeyLength(int maxKeyLength) {
}
protected char next() {
- this.c = this.it.next();
+ ParseState state = PARSE_STATE.get();
+ state.c = state.it.next();
- return this.c;
+ return state.c;
}
protected void skipWhiteSpace() {
- while (Character.isWhitespace(this.c)) {
+ while (Character.isWhitespace(PARSE_STATE.get().c)) {
this.next();
}
}
@Override
public Object read(String string) throws JSONException {
- this.it = new StringCharacterIterator(string);
- this.c = this.it.first();
- this.depth = 0;
-
- return this.read();
+ ParseState state = new ParseState();
+ state.it = new StringCharacterIterator(string);
+ state.c = state.it.first();
+ PARSE_STATE.set(state);
+ try {
+ return this.read();
+ } finally {
+ PARSE_STATE.remove();
+ }
}
protected Object read() throws JSONException {
+ ParseState state = PARSE_STATE.get();
Object ret;
this.skipWhiteSpace();
- if (this.c == '"') {
+ if (state.c == '"') {
this.next();
ret = this.string('"');
- } else if (this.c == '\'') {
+ } else if (state.c == '\'') {
this.next();
ret = this.string('\'');
- } else if (this.c == '[') {
+ } else if (state.c == '[') {
this.next();
ret = this.array();
- } else if (this.c == ']') {
+ } else if (state.c == ']') {
ret = ARRAY_END;
this.next();
- } else if (this.c == ',') {
+ } else if (state.c == ',') {
ret = COMMA;
this.next();
- } else if (this.c == '{') {
+ } else if (state.c == '{') {
this.next();
ret = this.object();
- } else if (this.c == '}') {
+ } else if (state.c == '}') {
ret = OBJECT_END;
this.next();
- } else if (this.c == ':') {
+ } else if (state.c == ':') {
ret = COLON;
this.next();
- } else if ((this.c == 't') && (this.next() == 'r') && (this.next() == 'u') && (this.next() == 'e')) {
+ } else if ((state.c == 't') && (this.next() == 'r') && (this.next() == 'u') && (this.next() == 'e')) {
ret = Boolean.TRUE;
this.next();
- } else if ((this.c == 'f') && (this.next() == 'a') && (this.next() == 'l') && (this.next() == 's')
+ } else if ((state.c == 'f') && (this.next() == 'a') && (this.next() == 'l') && (this.next() == 's')
&& (this.next() == 'e')) {
ret = Boolean.FALSE;
this.next();
- } else if ((this.c == 'n') && (this.next() == 'u') && (this.next() == 'l') && (this.next() == 'l')) {
+ } else if ((state.c == 'n') && (this.next() == 'u') && (this.next() == 'l') && (this.next() == 'l')) {
ret = null;
this.next();
- } else if (Character.isDigit(this.c) || (this.c == '-')) {
+ } else if (Character.isDigit(state.c) || (state.c == '-')) {
ret = this.number();
} else {
throw buildInvalidInputException();
}
- this.token = ret;
+ state.token = ret;
return ret;
}
protected Map object() throws JSONException {
- if (this.depth >= this.maxDepth) {
+ ParseState state = PARSE_STATE.get();
+ if (state.depth >= this.maxDepth) {
throw new JSONException("JSON object nesting exceeds maximum allowed depth ("
+ this.maxDepth + "). Use " + JSONConstants.JSON_MAX_DEPTH + " to increase the limit.");
}
- this.depth++;
+ state.depth++;
try {
Map ret = new HashMap<>();
Object next = this.read();
if (next != OBJECT_END) {
String key = (String) next;
validateKeyLength(key);
- while (this.token != OBJECT_END) {
+ while (state.token != OBJECT_END) {
this.read(); // should be a colon
- if (this.token != OBJECT_END) {
+ if (state.token != OBJECT_END) {
if (ret.size() >= this.maxElements) {
throw new JSONException("JSON object exceeds maximum allowed elements ("
+ this.maxElements + "). Use " + JSONConstants.JSON_MAX_ELEMENTS + " to increase the limit.");
@@ -187,7 +205,7 @@ protected Map object() throws JSONException {
return ret;
} finally {
- this.depth--;
+ state.depth--;
}
}
@@ -199,21 +217,22 @@ private void validateKeyLength(String key) throws JSONException {
}
protected JSONException buildInvalidInputException() {
- return new JSONException("Input string is not well formed JSON (invalid char " + this.c + ")");
+ return new JSONException("Input string is not well formed JSON (invalid char " + PARSE_STATE.get().c + ")");
}
protected List