From cc00343f1b4ae763f3403f5678a9fdebb4c73665 Mon Sep 17 00:00:00 2001
From: Lukasz Lenart
Date: Sun, 19 Jul 2026 20:25:47 +0200
Subject: [PATCH] WW-5650 Obtain a fresh JSON reader/writer per request in
JSONInterceptor (#1782)
* WW-5650 revert StrutsJSONReader to plain single-use instance fields
* WW-5650 revert StrutsJSONWriter to plain single-use instance fields
* WW-5650 obtain a fresh JSONUtil per request in JSONInterceptor
* WW-5650 resolve JSONUtil lazily only on JSON request paths
Move getJSONUtil() into the JSON and JSON-RPC branches of intercept() so
requests with a non-JSON content type no longer construct and discard an
unused JSONUtil/reader/writer graph. Also trim a stray trailing blank line
in StrutsJSONWriter.
Co-Authored-By: Claude Opus 4.8
* WW-5650 test(json): assert JSONWriter bean stays prototype-scoped
Guards the response-side invariant from WW-5644: StrutsJSONWriter now uses
plain instance fields and is not thread-safe, so cross-request safety relies
solely on the writer bean being prototype-scoped. Assert distinct instances
per container lookup so a future switch to singleton scope fails the build.
Addresses review feedback on #1782 without adding a getWriter() accessor
purely for tests.
Co-Authored-By: Claude Opus 4.8
---------
Co-authored-by: Claude Opus 4.8
---
.../apache/struts2/json/JSONInterceptor.java | 19 ++-
.../apache/struts2/json/StrutsJSONReader.java | 134 ++++++++----------
.../apache/struts2/json/StrutsJSONWriter.java | 113 ++++++---------
.../struts2/json/JSONInterceptorTest.java | 51 ++++---
.../struts2/json/StrutsJSONReaderTest.java | 80 -----------
.../struts2/json/StrutsJSONWriterTest.java | 74 ----------
6 files changed, 149 insertions(+), 322 deletions(-)
diff --git a/plugins/json/src/main/java/org/apache/struts2/json/JSONInterceptor.java b/plugins/json/src/main/java/org/apache/struts2/json/JSONInterceptor.java
index b1c172388..804542399 100644
--- a/plugins/json/src/main/java/org/apache/struts2/json/JSONInterceptor.java
+++ b/plugins/json/src/main/java/org/apache/struts2/json/JSONInterceptor.java
@@ -22,6 +22,7 @@ import org.apache.struts2.action.Action;
import org.apache.struts2.action.ParameterNameAware;
import org.apache.struts2.action.ParameterValueAware;
import org.apache.struts2.ActionInvocation;
+import org.apache.struts2.inject.Container;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.interceptor.AbstractInterceptor;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
@@ -77,7 +78,7 @@ public class JSONInterceptor extends AbstractInterceptor {
private String jsonContentType = "application/json";
private String jsonRpcContentType = "application/json-rpc";
- private JSONUtil jsonUtil;
+ private Container container;
private ParameterAuthorizer parameterAuthorizer;
private ExcludedPatternsChecker excludedPatterns;
private AcceptedPatternsChecker acceptedPatterns;
@@ -111,7 +112,8 @@ public class JSONInterceptor extends AbstractInterceptor {
if (jsonContentType.equalsIgnoreCase(requestContentType)) {
// load JSON object
- applyLimitsToReader();
+ JSONUtil jsonUtil = getJSONUtil();
+ applyLimitsToReader(jsonUtil);
Object obj = jsonUtil.deserializeInput(request.getReader(), maxLength);
// JSON array (this.root cannot be null in this case)
@@ -154,10 +156,11 @@ public class JSONInterceptor extends AbstractInterceptor {
throw new JSONException("Unable to deserialize JSON object from request");
}
} else if (jsonRpcContentType.equalsIgnoreCase(requestContentType)) {
+ JSONUtil jsonUtil = getJSONUtil();
Object result;
if (this.enableSMD) {
// load JSON object
- applyLimitsToReader();
+ applyLimitsToReader(jsonUtil);
Object obj = jsonUtil.deserializeInput(request.getReader(), maxLength);
if (obj instanceof Map) {
@@ -208,7 +211,7 @@ public class JSONInterceptor extends AbstractInterceptor {
return invocation.invoke();
}
- private void applyLimitsToReader() {
+ private void applyLimitsToReader(JSONUtil jsonUtil) {
JSONReader reader = jsonUtil.getReader();
reader.setMaxElements(maxElements);
reader.setMaxDepth(maxDepth);
@@ -744,8 +747,12 @@ public class JSONInterceptor extends AbstractInterceptor {
}
@Inject
- public void setJsonUtil(JSONUtil jsonUtil) {
- this.jsonUtil = jsonUtil;
+ public void setContainer(Container container) {
+ this.container = container;
+ }
+
+ protected JSONUtil getJSONUtil() {
+ return container.getInstance(JSONUtil.class);
}
@Inject
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 96593d2b9..a1399a461 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,12 +30,9 @@ import java.util.Map;
* 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.
+ * This reader keeps per-parse state in instance fields and is not thread-safe;
+ * obtain a fresh instance per parse (the container serves it as a prototype bean). See WW-5650.
*
*/
public class StrutsJSONReader implements JSONReader {
@@ -54,20 +51,16 @@ public class StrutsJSONReader implements JSONReader {
't', '\t'
);
- 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 CharacterIterator it;
+ private char c;
+ private Object token;
+ private final StringBuilder buf = new StringBuilder();
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) {
@@ -90,99 +83,92 @@ public class StrutsJSONReader implements JSONReader {
}
protected char next() {
- ParseState state = PARSE_STATE.get();
- state.c = state.it.next();
+ this.c = this.it.next();
- return state.c;
+ return this.c;
}
protected void skipWhiteSpace() {
- while (Character.isWhitespace(PARSE_STATE.get().c)) {
+ while (Character.isWhitespace(this.c)) {
this.next();
}
}
@Override
public Object read(String string) throws JSONException {
- 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();
- }
+ this.it = new StringCharacterIterator(string);
+ this.c = this.it.first();
+ this.depth = 0;
+
+ return this.read();
}
protected Object read() throws JSONException {
- ParseState state = PARSE_STATE.get();
Object ret;
this.skipWhiteSpace();
- if (state.c == '"') {
+ if (this.c == '"') {
this.next();
ret = this.string('"');
- } else if (state.c == '\'') {
+ } else if (this.c == '\'') {
this.next();
ret = this.string('\'');
- } else if (state.c == '[') {
+ } else if (this.c == '[') {
this.next();
ret = this.array();
- } else if (state.c == ']') {
+ } else if (this.c == ']') {
ret = ARRAY_END;
this.next();
- } else if (state.c == ',') {
+ } else if (this.c == ',') {
ret = COMMA;
this.next();
- } else if (state.c == '{') {
+ } else if (this.c == '{') {
this.next();
ret = this.object();
- } else if (state.c == '}') {
+ } else if (this.c == '}') {
ret = OBJECT_END;
this.next();
- } else if (state.c == ':') {
+ } else if (this.c == ':') {
ret = COLON;
this.next();
- } else if ((state.c == 't') && (this.next() == 'r') && (this.next() == 'u') && (this.next() == 'e')) {
+ } else if ((this.c == 't') && (this.next() == 'r') && (this.next() == 'u') && (this.next() == 'e')) {
ret = Boolean.TRUE;
this.next();
- } else if ((state.c == 'f') && (this.next() == 'a') && (this.next() == 'l') && (this.next() == 's')
+ } else if ((this.c == 'f') && (this.next() == 'a') && (this.next() == 'l') && (this.next() == 's')
&& (this.next() == 'e')) {
ret = Boolean.FALSE;
this.next();
- } else if ((state.c == 'n') && (this.next() == 'u') && (this.next() == 'l') && (this.next() == 'l')) {
+ } else if ((this.c == 'n') && (this.next() == 'u') && (this.next() == 'l') && (this.next() == 'l')) {
ret = null;
this.next();
- } else if (Character.isDigit(state.c) || (state.c == '-')) {
+ } else if (Character.isDigit(this.c) || (this.c == '-')) {
ret = this.number();
} else {
throw buildInvalidInputException();
}
- state.token = ret;
+ this.token = ret;
return ret;
}
protected Map object() throws JSONException {
- ParseState state = PARSE_STATE.get();
- if (state.depth >= this.maxDepth) {
+ if (this.depth >= this.maxDepth) {
throw new JSONException("JSON object nesting exceeds maximum allowed depth ("
+ this.maxDepth + "). Use " + JSONConstants.JSON_MAX_DEPTH + " to increase the limit.");
}
- state.depth++;
+ this.depth++;
try {
Map ret = new HashMap<>();
Object next = this.read();
if (next != OBJECT_END) {
String key = (String) next;
validateKeyLength(key);
- while (state.token != OBJECT_END) {
+ while (this.token != OBJECT_END) {
this.read(); // should be a colon
- if (state.token != OBJECT_END) {
+ if (this.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.");
@@ -205,7 +191,7 @@ public class StrutsJSONReader implements JSONReader {
return ret;
} finally {
- state.depth--;
+ this.depth--;
}
}
@@ -217,22 +203,21 @@ public class StrutsJSONReader implements JSONReader {
}
protected JSONException buildInvalidInputException() {
- return new JSONException("Input string is not well formed JSON (invalid char " + PARSE_STATE.get().c + ")");
+ return new JSONException("Input string is not well formed JSON (invalid char " + this.c + ")");
}
protected List