Skip to content

WW-5644 fix(json): confine StrutsJSONWriter write state to the writing thread#1776

Open
g0w6y wants to merge 2 commits into
apache:mainfrom
g0w6y:fix/json-writer-concurrent-write-state
Open

WW-5644 fix(json): confine StrutsJSONWriter write state to the writing thread#1776
g0w6y wants to merge 2 commits into
apache:mainfrom
g0w6y:fix/json-writer-concurrent-write-state

Conversation

@g0w6y

@g0w6y g0w6y commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Fixes WW-5644

Summary

JSONUtil obtains its JSONWriter once via @Inject and reuses that same instance across every concurrent response handled by that JSONResult/JSONInterceptor configuration -- this is how every Struts interceptor's injected dependencies work, not something specific to this plugin. StrutsJSONWriter kept its output buffer, cyclic-reference-detection stack, root object, and expression-path state (buf, stack, root, buildExpr, exprStack, excludeProperties, includeProperties, excludeNullProperties) as plain instance fields, all reset in place at the start of write():

this.buf.setLength(0);
this.stack.clear();
this.root = object;
...
this.value(object, null);
return this.buf.toString();

Root cause

Two concurrent write() calls on the same writer instance race on that reset. buf.setLength(0) at the top of one call can wipe out another, unrelated, concurrently-in-progress call's already-appended output; both calls then keep appending their own serialized fragments into the same, shared StringBuilder. Because each call finishes by reading back whatever the buffer currently contains (this.buf.toString()), one request's write() call can return a different, concurrently-served request's fully serialized response body -- not just a corrupted mix, but the other request's complete JSON, verbatim. This is the same class of defect as #1775 (StrutsJSONReader sharing per-parse state across concurrent request bodies), but on the response side, which is exercised by the default json result type on every request with no opt-in configuration required.

Verified with a live, quantified reproduction before writing the fix: two threads sharing one StrutsJSONWriter instance, one repeatedly serializing a "victim" object containing a unique secret marker, the other repeatedly serializing an unrelated "attacker" object and inspecting its own returned string. Out of 50,000 attempts, the attacker's own write() call returned the victim's complete secret value 180 times, plus a partial fragment of it 612 more times.

Fix

Move buf/stack/root/buildExpr/exprStack/excludeProperties/includeProperties/excludeNullProperties into a WriteState object confined to a ThreadLocal, created fresh in write(...) and cleared in a finally block. All method signatures and control flow are otherwise unchanged, so existing StrutsJSONWriter subclasses (bean()/map()/array()/string()/add()/etc. are all protected extension points) continue to work unmodified -- the per-write-state fields were already private, so no external code could have been touching them directly. ignoreHierarchy/dateFormat/enumAsBean/excludeProxyProperties stay as ordinary instance fields, since they're set to the same value on every call for a given writer configuration and are safe to share across threads.

Test plan

  • New regression test testConcurrentReuseDoesNotSwapResponsesAcrossWrites in StrutsJSONWriterTest: two threads share one writer instance, a "victim" thread repeatedly serializes an object containing a unique secret marker while an "attacker" thread concurrently serializes an unrelated object and inspects its own returned string. Asserts the attacker's response never contains the victim's secret.
  • Full existing StrutsJSONWriterTest suite passes unchanged -- confirms no behavioral regression from the refactor.
  • Full struts2-json-plugin module test suite passes (126/126).

g0w6y added 2 commits July 9, 2026 21:28
JSONUtil obtains its JSONWriter once via @Inject and reuses that same
instance across every concurrent response handled by that JSONResult/
JSONInterceptor configuration. StrutsJSONWriter kept its output buffer,
cyclic-reference stack, root object, and expression-path state
(buf/stack/root/buildExpr/exprStack/excludeProperties/
includeProperties/excludeNullProperties) as plain instance fields, all
reset in place at the start of write().

Two concurrent write() calls on the same instance therefore race on
that reset: one call's in-progress buffer can be wiped and overwritten
by a second, unrelated concurrent call before the first call reads it
back via buf.toString(), so one request's serialized JSON can be
returned as a completely different, concurrently-served request's
response body.

Move buf/stack/root/buildExpr/exprStack/excludeProperties/
includeProperties/excludeNullProperties into a WriteState confined to
a ThreadLocal, scoped to a single write() call. Method signatures and
behavior are otherwise unchanged so existing StrutsJSONWriter
subclasses keep working; ignoreHierarchy/dateFormat/enumAsBean/
excludeProxyProperties stay as plain instance fields since they are
set to the same value on every call for a given writer configuration
and are safe to share.
…repro

Verified independently that the 2-thread version can miss the race on
machines with more cores than contending threads (with no CPU
contention, the OS scheduler has no need to preempt either thread
mid-call, so the corruption window is rarely hit): 0 reproductions in
8 reruns against unpatched code on a 10-core machine. Sixteen threads
reproduced it reliably (44,646/320,000 corrupted responses against
unpatched StrutsJSONWriter), and confirmed zero corruption against the
fix under the same load.
@g0w6y g0w6y force-pushed the fix/json-writer-concurrent-write-state branch from 9b7801b to eb5a584 Compare July 9, 2026 17:39
@lukaszlenart

Copy link
Copy Markdown
Member

Thank you for the PR but please create a JIRA ticket to cover this work 🙏 Also bear in mind that I work on changes in the JSON plugin as well #1773

@g0w6y g0w6y changed the title fix(json): confine StrutsJSONWriter write state to the writing thread WW-5644 fix(json): confine StrutsJSONWriter write state to the writing thread Jul 10, 2026
@g0w6y

g0w6y commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Created WW-5644 to track this: https://issues.apache.org/jira/browse/WW-5644. Ready for review/merge whenever you get a chance — thanks!

@g0w6y

g0w6y commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Given this fixes a cross request response leak between concurrent users, should we handle this as a security advisory instead of a normal fix? Happy to follow whichever process you prefer, just let me know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants