This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/main by this push:
new cc00343f1 WW-5650 Obtain a fresh JSON reader/writer per request in
JSONInterceptor (#1782)
cc00343f1 is described below
commit cc00343f1b4ae763f3403f5678a9fdebb4c73665
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Jul 19 20:25:47 2026 +0200
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 <[email protected]>
* 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 <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
.../org/apache/struts2/json/JSONInterceptor.java | 19 ++-
.../org/apache/struts2/json/StrutsJSONReader.java | 134 +++++++++------------
.../org/apache/struts2/json/StrutsJSONWriter.java | 113 ++++++++---------
.../apache/struts2/json/JSONInterceptorTest.java | 51 +++++---
.../apache/struts2/json/StrutsJSONReaderTest.java | 80 ------------
.../apache/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.
* </p>
- *
* <p>
- * 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 <strong>not
thread-safe</strong>;
+ * obtain a fresh instance per parse (the container serves it as a prototype
bean). See WW-5650.
* </p>
*/
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<ParseState> 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<String, Object> 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<String, Object> 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<Object> array() throws JSONException {
- ParseState state = PARSE_STATE.get();
- if (state.depth >= this.maxDepth) {
+ if (this.depth >= this.maxDepth) {
throw new JSONException("JSON array nesting exceeds maximum
allowed depth ("
+ this.maxDepth + "). Use " + JSONConstants.JSON_MAX_DEPTH
+ " to increase the limit.");
}
- state.depth++;
+ this.depth++;
try {
List<Object> ret = new ArrayList<>();
Object value = this.read();
- while (state.token != ARRAY_END) {
+ while (this.token != ARRAY_END) {
if (ret.size() >= this.maxElements) {
throw new JSONException("JSON array exceeds maximum
allowed elements ("
+ this.maxElements + "). Use " +
JSONConstants.JSON_MAX_ELEMENTS + " to increase the limit.");
@@ -249,32 +234,31 @@ public class StrutsJSONReader implements JSONReader {
return ret;
} finally {
- state.depth--;
+ this.depth--;
}
}
protected Object number() throws JSONException {
- ParseState state = PARSE_STATE.get();
- state.buf.setLength(0);
+ this.buf.setLength(0);
boolean toDouble = false;
- if (state.c == '-') {
+ if (this.c == '-') {
this.add();
}
this.addDigits();
- if (state.c == '.') {
+ if (this.c == '.') {
toDouble = true;
this.add();
this.addDigits();
}
- if ((state.c == 'e') || (state.c == 'E')) {
+ if ((this.c == 'e') || (this.c == 'E')) {
toDouble = true;
this.add();
- if ((state.c == '+') || (state.c == '-')) {
+ if ((this.c == '+') || (this.c == '-')) {
this.add();
}
@@ -283,13 +267,13 @@ public class StrutsJSONReader implements JSONReader {
if (toDouble) {
try {
- return Double.parseDouble(state.buf.toString());
+ return Double.parseDouble(this.buf.toString());
} catch (NumberFormatException e) {
throw buildInvalidInputException();
}
} else {
try {
- return Long.parseLong(state.buf.toString());
+ return Long.parseLong(this.buf.toString());
} catch (NumberFormatException e) {
throw buildInvalidInputException();
}
@@ -297,17 +281,16 @@ public class StrutsJSONReader implements JSONReader {
}
protected Object string(char quote) throws JSONException {
- ParseState state = PARSE_STATE.get();
- state.buf.setLength(0);
+ this.buf.setLength(0);
- while ((state.c != quote) && (state.c != CharacterIterator.DONE)) {
- if (state.c == '\\') {
+ while ((this.c != quote) && (this.c != CharacterIterator.DONE)) {
+ if (this.c == '\\') {
this.next();
- if (state.c == 'u') {
+ if (this.c == 'u') {
this.add(this.unicode());
} else {
- Character value = escapes.get(state.c);
+ Character value = escapes.get(this.c);
if (value != null) {
this.add(value);
@@ -316,7 +299,7 @@ public class StrutsJSONReader implements JSONReader {
} else {
this.add();
}
- if (state.buf.length() > this.maxStringLength) {
+ if (this.buf.length() > this.maxStringLength) {
throw new JSONException("JSON string exceeds maximum allowed
length ("
+ this.maxStringLength + "). Use " +
JSONConstants.JSON_MAX_STRING_LENGTH + " to increase the limit.");
}
@@ -324,33 +307,32 @@ public class StrutsJSONReader implements JSONReader {
this.next();
- return state.buf.toString();
+ return this.buf.toString();
}
protected void add(char cc) {
- PARSE_STATE.get().buf.append(cc);
+ this.buf.append(cc);
this.next();
}
protected void add() {
- this.add(PARSE_STATE.get().c);
+ this.add(this.c);
}
protected void addDigits() {
- while (Character.isDigit(PARSE_STATE.get().c)) {
+ while (Character.isDigit(this.c)) {
this.add();
}
}
protected char unicode() {
- ParseState state = PARSE_STATE.get();
int value = 0;
for (int i = 0; i < 4; ++i) {
value = switch (this.next()) {
- case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' ->
(value << 4) + (state.c - '0');
- case 'a', 'b', 'c', 'd', 'e', 'f' -> (value << 4) + (state.c -
'W');
- case 'A', 'B', 'C', 'D', 'E', 'F' -> (value << 4) + (state.c -
'7');
+ case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' ->
(value << 4) + (this.c - '0');
+ case 'a', 'b', 'c', 'd', 'e', 'f' -> (value << 4) + (this.c -
'W');
+ case 'A', 'B', 'C', 'D', 'E', 'F' -> (value << 4) + (this.c -
'7');
default -> value;
};
}
diff --git
a/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java
b/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java
index 59fadd9b3..b7f540b0b 100644
--- a/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java
+++ b/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java
@@ -66,13 +66,9 @@ import java.util.regex.Pattern;
* Serializes an object into JavaScript Object Notation (JSON). If cyclic
* references are detected they will be nulled out.
* </p>
- *
* <p>
- * A single StrutsJSONWriter instance is shared across all concurrent
responses handled by a given
- * JSONResult/JSONInterceptor (it is injected once, not created per request),
so the output buffer,
- * cycle-detection stack and other per-write state are kept in a {@link
ThreadLocal}, not instance
- * fields -- otherwise two concurrent {@link #write(Object)} calls would
corrupt each other's output,
- * including returning one request's serialized data as another, unrelated
request's response.
+ * This writer keeps per-write state in instance fields and is <strong>not
thread-safe</strong>;
+ * obtain a fresh instance per write (the container serves it as a prototype
bean). See WW-5650.
* </p>
*/
public class StrutsJSONWriter implements JSONWriter {
@@ -92,22 +88,17 @@ public class StrutsJSONWriter implements JSONWriter {
BEAN_INFO_CACHE.clear();
}
- private static final class WriteState {
- private final StringBuilder buf = new StringBuilder();
- private final Deque<Object> stack = new ArrayDeque<>();
- private Object root;
- private boolean buildExpr = true;
- private String exprStack = "";
- private Collection<Pattern> excludeProperties;
- private Collection<Pattern> includeProperties;
- private boolean excludeNullProperties;
- }
-
- private static final ThreadLocal<WriteState> WRITE_STATE = new
ThreadLocal<>();
-
+ private final StringBuilder buf = new StringBuilder();
+ private final Deque<Object> stack = new ArrayDeque<>();
private boolean ignoreHierarchy = true;
+ private Object root;
+ private boolean buildExpr = true;
+ private String exprStack = "";
+ private Collection<Pattern> excludeProperties;
+ private Collection<Pattern> includeProperties;
private DateFormat dateFormat;
private boolean enumAsBean = ENUM_AS_BEAN_DEFAULT;
+ private boolean excludeNullProperties;
private boolean excludeProxyProperties;
private ProxyService proxyService;
@@ -142,20 +133,18 @@ public class StrutsJSONWriter implements JSONWriter {
@Override
public String write(Object object, Collection<Pattern> excludeProperties,
Collection<Pattern> includeProperties, boolean
excludeNullProperties) throws JSONException {
- WriteState state = new WriteState();
- state.excludeNullProperties = excludeNullProperties;
- state.root = object;
- state.buildExpr = ((excludeProperties != null) &&
!excludeProperties.isEmpty())
+ this.excludeNullProperties = excludeNullProperties;
+ this.buf.setLength(0);
+ this.stack.clear();
+ this.root = object;
+ this.exprStack = "";
+ this.buildExpr = ((excludeProperties != null) &&
!excludeProperties.isEmpty())
|| ((includeProperties != null) &&
!includeProperties.isEmpty());
- state.excludeProperties = excludeProperties;
- state.includeProperties = includeProperties;
- WRITE_STATE.set(state);
- try {
- this.value(object, null);
- return state.buf.toString();
- } finally {
- WRITE_STATE.remove();
- }
+ this.excludeProperties = excludeProperties;
+ this.includeProperties = includeProperties;
+ this.value(object, null);
+
+ return this.buf.toString();
}
/**
@@ -171,7 +160,7 @@ public class StrutsJSONWriter implements JSONWriter {
return;
}
- if (WRITE_STATE.get().stack.contains(object)) {
+ if (this.stack.contains(object)) {
Class<?> clazz = object.getClass();
// cyclic reference
@@ -196,8 +185,7 @@ public class StrutsJSONWriter implements JSONWriter {
* @throws JSONException in case of error during serialize
*/
protected void process(Object object, Method method) throws JSONException {
- WriteState state = WRITE_STATE.get();
- state.stack.push(object);
+ this.stack.push(object);
if (object instanceof Class) {
this.string(object);
@@ -229,7 +217,7 @@ public class StrutsJSONWriter implements JSONWriter {
processCustom(object, method);
}
- state.stack.pop();
+ this.stack.pop();
}
/**
@@ -252,13 +240,12 @@ public class StrutsJSONWriter implements JSONWriter {
protected void bean(Object object) throws JSONException {
this.add("{");
- WriteState state = WRITE_STATE.get();
BeanInfo info;
try {
Class<?> clazz = excludeProxyProperties ?
proxyService.ultimateTargetClass(object) : object.getClass();
- info = ((object == state.root) && this.ignoreHierarchy)
+ info = ((object == this.root) && this.ignoreHierarchy)
? getBeanInfoIgnoreHierarchy(clazz)
: getBeanInfo(clazz);
@@ -284,7 +271,7 @@ public class StrutsJSONWriter implements JSONWriter {
continue;
}
String expr = null;
- if (state.buildExpr) {
+ if (this.buildExpr) {
expr = this.expandExpr(name);
if (this.shouldExcludeProperty(expr)) {
continue;
@@ -299,7 +286,7 @@ public class StrutsJSONWriter implements JSONWriter {
boolean propertyPrinted = this.add(name, value, accessor,
hasData);
hasData = hasData || propertyPrinted;
- if (state.buildExpr) {
+ if (this.buildExpr) {
this.setExprStack(expr);
}
}
@@ -414,28 +401,25 @@ public class StrutsJSONWriter implements JSONWriter {
}
protected String expandExpr(int i) {
- return WRITE_STATE.get().exprStack + "[" + i + "]";
+ return this.exprStack + "[" + i + "]";
}
protected String expandExpr(String property) {
- String exprStack = WRITE_STATE.get().exprStack;
- if (exprStack.isEmpty()) {
+ if (this.exprStack.isEmpty()) {
return property;
}
- return exprStack + "." + property;
+ return this.exprStack + "." + property;
}
protected String setExprStack(String expr) {
- WriteState state = WRITE_STATE.get();
- String s = state.exprStack;
- state.exprStack = expr;
+ String s = this.exprStack;
+ this.exprStack = expr;
return s;
}
protected boolean shouldExcludeProperty(String expr) {
- WriteState state = WRITE_STATE.get();
- if (state.excludeProperties != null) {
- for (Pattern pattern : state.excludeProperties) {
+ if (this.excludeProperties != null) {
+ for (Pattern pattern : this.excludeProperties) {
if (pattern.matcher(expr).matches()) {
if (LOG.isDebugEnabled()) {
LOG.debug("Ignoring property because of exclude rule:
" + expr);
@@ -445,8 +429,8 @@ public class StrutsJSONWriter implements JSONWriter {
}
}
- if (state.includeProperties != null) {
- for (Pattern pattern : state.includeProperties) {
+ if (this.includeProperties != null) {
+ for (Pattern pattern : this.includeProperties) {
if (pattern.matcher(expr).matches()) {
return false;
}
@@ -463,7 +447,7 @@ public class StrutsJSONWriter implements JSONWriter {
* Add name/value pair to buffer
*/
protected boolean add(String name, Object value, Method method, boolean
hasData) throws JSONException {
- if (WRITE_STATE.get().excludeNullProperties && value == null) {
+ if (excludeNullProperties && value == null) {
return false;
}
if (hasData) {
@@ -482,25 +466,24 @@ public class StrutsJSONWriter implements JSONWriter {
protected void map(Map<?, ?> map, Method method) throws JSONException {
this.add("{");
- WriteState state = WRITE_STATE.get();
Iterator<?> it = map.entrySet().iterator();
boolean warnedNonString = false; // one report per map
boolean hasData = false;
while (it.hasNext()) {
Map.Entry<?, ?> entry = (Map.Entry<?, ?>) it.next();
- if (state.excludeNullProperties && entry.getValue() == null) {
+ if (excludeNullProperties && entry.getValue() == null) {
continue;
}
Object key = entry.getKey();
if (key == null) {
- LOG.error("Cannot build expression for null key in {}",
state.exprStack);
+ LOG.error("Cannot build expression for null key in {}",
exprStack);
continue;
}
String expr = null;
- if (state.buildExpr) {
+ if (this.buildExpr) {
expr = this.expandExpr(key.toString());
if (this.shouldExcludeProperty(expr)) {
continue;
@@ -520,7 +503,7 @@ public class StrutsJSONWriter implements JSONWriter {
this.value(key.toString(), method);
this.add(":");
this.value(entry.getValue(), method);
- if (state.buildExpr) {
+ if (this.buildExpr) {
this.setExprStack(expr);
}
}
@@ -587,11 +570,10 @@ public class StrutsJSONWriter implements JSONWriter {
protected void array(Iterator<?> it, Method method) throws JSONException {
this.add("[");
- WriteState state = WRITE_STATE.get();
boolean hasData = false;
for (int i = 0; it.hasNext(); i++) {
String expr = null;
- if (state.buildExpr) {
+ if (this.buildExpr) {
expr = this.expandExpr(i);
if (this.shouldExcludeProperty(expr)) {
it.next();
@@ -604,7 +586,7 @@ public class StrutsJSONWriter implements JSONWriter {
}
hasData = true;
this.value(it.next(), method);
- if (state.buildExpr) {
+ if (this.buildExpr) {
this.setExprStack(expr);
}
}
@@ -618,13 +600,12 @@ public class StrutsJSONWriter implements JSONWriter {
protected void array(Object object, Method method) throws JSONException {
this.add("[");
- WriteState state = WRITE_STATE.get();
int length = Array.getLength(object);
boolean hasData = false;
for (int i = 0; i < length; ++i) {
String expr = null;
- if (state.buildExpr) {
+ if (this.buildExpr) {
expr = this.expandExpr(i);
if (this.shouldExcludeProperty(expr)) {
continue;
@@ -636,7 +617,7 @@ public class StrutsJSONWriter implements JSONWriter {
}
hasData = true;
this.value(Array.get(object, i), method);
- if (state.buildExpr) {
+ if (this.buildExpr) {
this.setExprStack(expr);
}
}
@@ -692,14 +673,14 @@ public class StrutsJSONWriter implements JSONWriter {
* Add object to buffer
*/
protected void add(Object obj) {
- WRITE_STATE.get().buf.append(obj);
+ this.buf.append(obj);
}
/*
* Add char to buffer
*/
protected void add(char c) {
- WRITE_STATE.get().buf.append(c);
+ this.buf.append(c);
}
/**
diff --git
a/plugins/json/src/test/java/org/apache/struts2/json/JSONInterceptorTest.java
b/plugins/json/src/test/java/org/apache/struts2/json/JSONInterceptorTest.java
index eaf428600..4aa27563a 100644
---
a/plugins/json/src/test/java/org/apache/struts2/json/JSONInterceptorTest.java
+++
b/plugins/json/src/test/java/org/apache/struts2/json/JSONInterceptorTest.java
@@ -44,10 +44,7 @@ public class JSONInterceptorTest extends StrutsTestCase {
private JSONInterceptor createInterceptor() {
JSONInterceptor interceptor = new JSONInterceptor();
- JSONUtil jsonUtil = new JSONUtil();
- jsonUtil.setReader(new StrutsJSONReader());
- jsonUtil.setWriter(new StrutsJSONWriter());
- interceptor.setJsonUtil(jsonUtil);
+ interceptor.setContainer(container);
// Default: allow all parameters (simulates requireAnnotations=false)
interceptor.setParameterAuthorizer((parameterName, target, action) ->
true);
interceptor.setExcludedPatterns(new
org.apache.struts2.security.DefaultExcludedPatternsChecker());
@@ -567,10 +564,7 @@ public class JSONInterceptorTest extends StrutsTestCase {
this.request.addHeader("Content-Type", "application/json");
JSONInterceptor interceptor = new JSONInterceptor();
- JSONUtil jsonUtil = new JSONUtil();
- jsonUtil.setReader(new StrutsJSONReader());
- jsonUtil.setWriter(new StrutsJSONWriter());
- interceptor.setJsonUtil(jsonUtil);
+ interceptor.setContainer(container);
// Only authorize "foo", reject "bar"
interceptor.setParameterAuthorizer((parameterName, target, action) ->
"foo".equals(parameterName));
TestAction action = new TestAction();
@@ -589,10 +583,7 @@ public class JSONInterceptorTest extends StrutsTestCase {
// Simulate a custom JSON reader producing a Map with a non-String key.
// The authorizer should skip the entry rather than throw
ClassCastException.
JSONInterceptor interceptor = new JSONInterceptor();
- JSONUtil jsonUtil = new JSONUtil();
- jsonUtil.setReader(new StrutsJSONReader());
- jsonUtil.setWriter(new StrutsJSONWriter());
- interceptor.setJsonUtil(jsonUtil);
+ interceptor.setContainer(container);
interceptor.setParameterAuthorizer((parameterName, target, action) ->
true);
java.util.Map<Object, Object> mixedKeyMap = new
java.util.LinkedHashMap<>();
@@ -658,10 +649,7 @@ public class JSONInterceptorTest extends StrutsTestCase {
this.request.addHeader("Content-Type", "application/json");
JSONInterceptor interceptor = new JSONInterceptor();
- JSONUtil jsonUtil = new JSONUtil();
- jsonUtil.setReader(new StrutsJSONReader());
- jsonUtil.setWriter(new StrutsJSONWriter());
- interceptor.setJsonUtil(jsonUtil);
+ interceptor.setContainer(container);
// Authorize "bean" (top-level) and "bean.stringField" (nested) but
reject "bean.intField"
interceptor.setParameterAuthorizer((parameterName, target, action) ->
"bean".equals(parameterName) ||
"bean.stringField".equals(parameterName));
@@ -688,10 +676,7 @@ public class JSONInterceptorTest extends StrutsTestCase {
this.request.addHeader("Content-Type", "application/json");
JSONInterceptor interceptor = new JSONInterceptor();
- JSONUtil jsonUtil = new JSONUtil();
- jsonUtil.setReader(new StrutsJSONReader());
- jsonUtil.setWriter(new StrutsJSONWriter());
- interceptor.setJsonUtil(jsonUtil);
+ interceptor.setContainer(container);
interceptor.setRoot("bean");
// Reject all parameters — simulates strict requireAnnotations
interceptor.setParameterAuthorizer((parameterName, target, action) ->
false);
@@ -930,6 +915,32 @@ public class JSONInterceptorTest extends StrutsTestCase {
assertEquals("b", action.getBar());
}
+ public void testObtainsFreshJSONUtilAndReaderPerInvocation() {
+ JSONInterceptor interceptor = new JSONInterceptor();
+ interceptor.setContainer(container); // StrutsTestCase-provided
container
+
+ JSONUtil first = interceptor.getJSONUtil();
+ JSONUtil second = interceptor.getJSONUtil();
+
+ assertNotSame("interceptor must obtain a fresh JSONUtil per request",
first, second);
+ assertNotSame("each fresh JSONUtil must carry its own reader (no
shared parse state)",
+ first.getReader(), second.getReader());
+ }
+
+ /**
+ * StrutsJSONWriter keeps per-serialization state in plain instance fields
and is not
+ * thread-safe, so the response-side protection from WW-5644 now relies
entirely on the writer
+ * bean being prototype-scoped (a fresh writer per request/result). Guard
that scope directly:
+ * if it were ever switched to singleton, cross-request response state
would leak again.
+ */
+ public void testObtainsFreshWriterPerAcquisition() {
+ JSONWriter first = container.getInstance(JSONWriter.class);
+ JSONWriter second = container.getInstance(JSONWriter.class);
+
+ assertNotSame("JSONWriter bean must be prototype-scoped so serialize
state is never shared across responses",
+ first, second);
+ }
+
public void testIncludePropertiesAppliedToNestedInputWhenEnabled() throws
Exception {
this.request.setContent("{\"bean\": {\"stringField\": \"keep\",
\"intField\": 42}}".getBytes());
this.request.addHeader("Content-Type", "application/json");
diff --git
a/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONReaderTest.java
b/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONReaderTest.java
index 827711c61..d8af5e21f 100644
---
a/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONReaderTest.java
+++
b/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONReaderTest.java
@@ -22,11 +22,6 @@ import org.junit.Test;
import java.util.List;
import java.util.Map;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@@ -162,79 +157,4 @@ public class StrutsJSONReaderTest {
assertTrue(ex.getMessage().contains("maximum allowed depth"));
}
- /**
- * A single StrutsJSONReader instance is injected once into
JSONUtil/JSONInterceptor and reused
- * across every concurrent request handled by that interceptor, so read()
must be safe to call
- * concurrently from multiple threads on the same instance without one
call's parse state
- * (cursor, token buffer, depth counter) leaking into another's -- both as
a maxDepth bypass and
- * as one request's data appearing inside a different, concurrently-parsed
request's result.
- *
- * <p>Uses a higher thread count than the minimum needed to demonstrate
the bug: with only two
- * threads on a machine with many cores, the OS scheduler has no need to
preempt either thread
- * mid-call, so the race window is rarely hit and the test can pass even
against the unpatched
- * code. Sixteen threads contending for the same instance reproduces both
symptoms reliably.</p>
- */
- @Test
- public void
testConcurrentReuseDoesNotBypassMaxDepthOrLeakDataAcrossParses() throws
Exception {
- int maxDepth = 5;
- var reader = new StrutsJSONReader();
- reader.setMaxDepth(maxDepth);
-
- String overDepthPayload = nestedObject(maxDepth + 1);
- int threadCount = 16;
- int iterations = 10_000;
- AtomicInteger overDepthAccepted = new AtomicInteger();
- AtomicInteger crossThreadLeaks = new AtomicInteger();
- CountDownLatch start = new CountDownLatch(1);
- ExecutorService pool = Executors.newFixedThreadPool(threadCount);
-
- for (int t = 0; t < threadCount; t++) {
- String marker = "MARKER_" + t + "_9f8e7d6c5b4a3210";
- String ownPayload = "{\"account\":\"user" + t + "\",\"secret\":\""
+ marker + "\"}";
- pool.submit(() -> {
- await(start);
- for (int i = 0; i < iterations; i++) {
- try {
- Object result = reader.read(ownPayload);
- if (!(result instanceof Map) ||
!marker.equals(((Map<?, ?>) result).get("secret"))) {
- // this thread's own parse doesn't even contain
its own data: the shared
- // cursor/buffer was corrupted or overwritten by
another thread
- crossThreadLeaks.incrementAndGet();
- }
- } catch (JSONException ignored) {
- // a failed parse under contention is not itself the
thing measured here
- }
- try {
- reader.read(overDepthPayload);
- overDepthAccepted.incrementAndGet();
- } catch (JSONException expected) {
- // must always be rejected: depth is one more than
maxDepth
- }
- }
- });
- }
-
- start.countDown();
- pool.shutdown();
- assertTrue(pool.awaitTermination(120, TimeUnit.SECONDS));
-
- assertEquals("an over-depth payload must never be accepted, even when
the reader instance " +
- "is shared with concurrent unrelated parses", 0,
overDepthAccepted.get());
- assertEquals("a concurrently-parsed request's data must never appear
in place of this " +
- "thread's own parsed result", 0, crossThreadLeaks.get());
- }
-
- private static void await(CountDownLatch latch) {
- try {
- latch.await();
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
-
- private static String nestedObject(int depth) {
- StringBuilder sb = new StringBuilder();
-
sb.append("{\"a\":".repeat(depth)).append("1").append("}".repeat(depth));
- return sb.toString();
- }
}
diff --git
a/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONWriterTest.java
b/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONWriterTest.java
index 2239c5f71..acc52dcaa 100644
---
a/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONWriterTest.java
+++
b/plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONWriterTest.java
@@ -39,11 +39,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -320,73 +315,4 @@ public class StrutsJSONWriterTest {
assertTrue(json.contains("\"calendar\":\"2012-12-23T10:10:10\""));
}
- public static class NamedBean {
- private final String owner;
- private final String secret;
-
- public NamedBean(String owner, String secret) {
- this.owner = owner;
- this.secret = secret;
- }
-
- public String getOwner() { return owner; }
- public String getSecret() { return secret; }
- }
-
- /**
- * A single StrutsJSONWriter instance is injected once into
JSONUtil/JSONResult and reused
- * across every concurrent response handled by that result, so write()
must be safe to call
- * concurrently from multiple threads on the same instance without one
call's output buffer
- * leaking into -- or being overwritten by -- another call's output.
- *
- * <p>Uses a higher thread count than the minimum needed to demonstrate
the bug: with only two
- * threads on a machine with many cores, the OS scheduler has no need to
preempt either thread
- * mid-call, so the race window is rarely hit and the test can pass even
against the unpatched
- * code. Sixteen threads contending for the same instance reproduces the
corruption reliably.</p>
- */
- @Test
- public void testConcurrentReuseDoesNotSwapResponsesAcrossWrites() throws
Exception {
- JSONWriter sharedWriter = new StrutsJSONWriter();
- int threadCount = 16;
- int iterations = 20_000;
- AtomicInteger corrupted = new AtomicInteger();
- CountDownLatch start = new CountDownLatch(1);
- ExecutorService pool = Executors.newFixedThreadPool(threadCount);
-
- for (int t = 0; t < threadCount; t++) {
- String secret = "SECRET_" + t + "_9f8e7d6c5b4a3210";
- NamedBean own = new NamedBean("user" + t, secret);
- pool.submit(() -> {
- await(start);
- for (int i = 0; i < iterations; i++) {
- try {
- String response = sharedWriter.write(own);
- if (!response.contains(secret)) {
- // this thread's own response doesn't even contain
its own data:
- // the shared buffer was corrupted or overwritten
by another thread
- corrupted.incrementAndGet();
- }
- } catch (JSONException ignored) {
- // a failed serialize under contention is not itself
the thing measured here
- }
- }
- });
- }
-
- start.countDown();
- pool.shutdown();
- assertTrue(pool.awaitTermination(120, TimeUnit.SECONDS));
-
- assertEquals("a concurrently-written response must never overwrite or
corrupt this " +
- "thread's own response", 0, corrupted.get());
- }
-
- private static void await(CountDownLatch latch) {
- try {
- latch.await();
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
-
}