Context propagation primitives for async task execution - #342
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
| if (task == null || task instanceof ContextPropagatingRunnable) { | ||
| return task; |
There was a problem hiding this comment.
🟡 Medium - Re-wrapping a retained task keeps the first request's context
ContextPropagation.wrap(...) returns an existing ContextPropagatingRunnable/Callable unchanged instead of rebinding it to the current request. If an application keeps a task instance and calls wrap() before submitting it for later requests, the wrapper continues to install the original ContextObject, so later async work inherits stale route, user, IP, and forcedProtectionOff state. That can misattribute attack reports to the wrong request and, when the first request had protection forced off, skip vulnerability scanning for subsequent requests.
Show fix
Do not treat already-wrapped tasks as safe to reuse across requests. Either always create a fresh wrapper for each wrap() call, or unwrap and rebind the delegate when the current ContextObject differs from the one captured previously so each submission propagates the caller's current request context.
More info - Reply on this comment to give feedback or ignore the issue.
c0d0a2a to
75593a3
Compare
0f414fa to
6f88295
Compare
Introduce ContextPropagatingRunnable/Callable and the ContextPropagation factory that captures the current request Context at wrap time and restores it around task execution, restoring the worker's previous context afterwards. Each task gets its own copy of the context (ContextObject.copyForPropagation) so parallel workers sharing one request never race on its mutable state. Wrapping is idempotent and passes through null / already-wrapped / no-context tasks. Unit-tested in isolation without agent weaving.
6f88295 to
4282015
Compare
| // context never race on its mutable working state (cache, redirect nodes). | ||
| public ContextObject copyForPropagation() { | ||
| try { | ||
| ContextObject copy = (ContextObject) super.clone(); |
There was a problem hiding this comment.
super.clone() shallow-copies mutable fields such as headers, query, cookies, params, body, and user into contexts used by parallel tasks. Deep-copy mutable state or make it immutable before propagation.
Show fix
| ContextObject copy = (ContextObject) super.clone(); | |
| ContextObject copy = (ContextObject) super.clone(); | |
| if (this.headers != null) { | |
| copy.headers = new HashMap<>(); | |
| for (Map.Entry<String, List<String>> entry : this.headers.entrySet()) { | |
| copy.headers.put(entry.getKey(), new ArrayList<>(entry.getValue())); | |
| } | |
| } | |
| if (this.query != null) { | |
| copy.query = new HashMap<>(); | |
| for (Map.Entry<String, List<String>> entry : this.query.entrySet()) { | |
| copy.query.put(entry.getKey(), new ArrayList<>(entry.getValue())); | |
| } | |
| } | |
| if (this.cookies != null) { | |
| copy.cookies = new HashMap<>(); | |
| for (Map.Entry<String, List<String>> entry : this.cookies.entrySet()) { | |
| copy.cookies.put(entry.getKey(), new ArrayList<>(entry.getValue())); | |
| } | |
| } |
Details
✨ AI Reasoning
The propagation wrappers create a per-task context and therefore make the copied object's state available on worker threads. The clone operation is shallow for fields other than cache and redirectStartNodes, so headers, query, cookies, and potentially mutable params, body, and user objects remain shared with the original context. Existing getters expose several collection references directly, allowing concurrent task code to mutate the same underlying objects without synchronization. Deep-copy mutable fields or make them immutable before publishing the context to worker threads.
Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
First of three stacked PRs adding request-context propagation across async boundaries. This one is just the primitives — no instrumentation yet — so it's small and easy to review on its own.
When a request is handled, its context lives in a ThreadLocal. The moment work is handed to another thread that's gone, so any detector (SQLi, SSRF, …) running there sees no context. The building block for fixing that is a small wrapper that snapshots the current context when a task is created and restores it around the task's run.
ContextPropagation.wrap(...)takes aRunnable/Callableand returns a wrapper that:Contextat wrap time,It stays a no-op where it should:
null, already-wrapped, and no-active-context tasks are returned untouched, so wrapping twice is harmless.Each wrapped task gets its own copy of the context (
ContextObject.copyForPropagation), not a shared reference. So when one request fans out several tasks in parallel, the workers don't race on the request's mutable working state (the extracted-input cache, redirect-tracking nodes). This mirrors how OpenTelemetry keeps its context immutable across the async boundary — same safety, done as a per-task snapshot since our context is mutable.No executor is touched here — that's the next PR. Covered by
ContextPropagationTest(capture, restore, restore-previous, clear-when-none, exception safety, idempotency, delegate execution, and snapshot isolation).