WW-5644 fix(json): confine StrutsJSONWriter write state to the writing thread#1776
Open
g0w6y wants to merge 2 commits into
Open
WW-5644 fix(json): confine StrutsJSONWriter write state to the writing thread#1776g0w6y wants to merge 2 commits into
g0w6y wants to merge 2 commits into
Conversation
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.
9b7801b to
eb5a584
Compare
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 |
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! |
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes WW-5644
Summary
JSONUtilobtains itsJSONWriteronce via@Injectand reuses that same instance across every concurrent response handled by thatJSONResult/JSONInterceptorconfiguration -- this is how every Struts interceptor's injected dependencies work, not something specific to this plugin.StrutsJSONWriterkept 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 ofwrite():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, sharedStringBuilder. Because each call finishes by reading back whatever the buffer currently contains (this.buf.toString()), one request'swrite()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 (StrutsJSONReadersharing per-parse state across concurrent request bodies), but on the response side, which is exercised by the defaultjsonresult type on every request with no opt-in configuration required.Verified with a live, quantified reproduction before writing the fix: two threads sharing one
StrutsJSONWriterinstance, 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 ownwrite()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/excludeNullPropertiesinto aWriteStateobject confined to aThreadLocal, created fresh inwrite(...)and cleared in afinallyblock. All method signatures and control flow are otherwise unchanged, so existingStrutsJSONWritersubclasses (bean()/map()/array()/string()/add()/etc. are allprotectedextension points) continue to work unmodified -- the per-write-state fields were alreadyprivate, so no external code could have been touching them directly.ignoreHierarchy/dateFormat/enumAsBean/excludeProxyPropertiesstay 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
testConcurrentReuseDoesNotSwapResponsesAcrossWritesinStrutsJSONWriterTest: 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.StrutsJSONWriterTestsuite passes unchanged -- confirms no behavioral regression from the refactor.struts2-json-pluginmodule test suite passes (126/126).