Title: SystemAuthenticator.begin()/end() mutate the shared SecurityContext: concurrent threads of the same HTTP session run as system, and a session can remain system permanently
Environment
Jmix version: 2.8.2 (code unchanged in 2.8.5, 3.0.2 and master)
Bug Description
SystemAuthenticatorImpl.begin() and end() (and therefore withSystem, runWithSystem, withUser, runWithUser) change the authentication through SecurityContextHelper.setAuthentication():
SecurityContextHolder.getContext().setAuthentication(authentication);
This replaces the authentication inside the existing SecurityContext instance instead of setting a new context on the current thread. This SecurityContext instance is not local to the thread. The same object is shared by:
- every request of the same HTTP session, because
HttpSessionSecurityContextRepositoryreturns the object stored in the session attribute, not a copy; - background tasks started from the UI:
UiAsyncTaskswraps them inDelegatingSecurityRunnable/DelegatingSecuritySupplierbuilt withSecurityContextHolder.getContext(); - push requests (
SecurityContextHolderAtmosphereInterceptor).
As a result, a withSystem { } block running on one thread changes the authentication seen by all other threads of the same session while it runs.
Spring Security explicitly warns against this pattern (Servlet Authentication Architecture): create a new SecurityContext instead of calling SecurityContextHolder.getContext().setAuthentication(authentication) “to avoid race conditions across multiple threads”.
There are two consequences.
-
Temporary privilege escalation. While a
withSystemblock runs in a background task, UI requests of the same session run with thesystemauthentication: full permissions, row-level policies bypassed,createdBy/lastModifiedByand audit recorded assystem, anddetailslost (for exampleClientDetails.sessionId). -
Permanent privilege escalation. When two
begin()/end()pairs overlap on two threads of the same session, the per-thread stack inSystemAuthenticatorSupportrestores the wrong value:Step Thread Stack of the thread Shared context after the step 1 T1 begin()pushes adminsystem2 T2 begin()pushes system(what it currently sees)system3 T1 end()pops adminadmin4 T2 end()pops systemsystemFrom then on, every request of this session runs as
systemuntil logout.
Other sessions are not affected: each session has its own SecurityContext instance.
Steps To Reproduce
In any view of a project with a logged-in user admin:
@Autowired private UiAsyncTasks uiAsyncTasks;
@Autowired private SystemAuthenticator systemAuthenticator;
@Autowired private CurrentAuthentication currentAuthentication;
@Autowired private Notifications notifications;
// Scenario 1: temporary escalation
@Subscribe(id = "longSystemTaskBtn", subject = "clickListener")
public void onLongSystemTaskBtnClick(ClickEvent<JmixButton> event) {
uiAsyncTasks.runnableConfigurer(() ->
systemAuthenticator.runWithSystem(() -> sleep(10_000)))
.runAsync();
}
@Subscribe(id = "whoAmIBtn", subject = "clickListener")
public void onWhoAmIBtnClick(ClickEvent<JmixButton> event) {
notifications.show(currentAuthentication.getAuthentication().getClass().getSimpleName()
+ " / " + currentAuthentication.getUser().getUsername());
}
// Scenario 2: permanent escalation
@Subscribe(id = "overlappingTasksBtn", subject = "clickListener")
public void onOverlappingTasksBtnClick(ClickEvent<JmixButton> event) {
uiAsyncTasks.runnableConfigurer(() ->
systemAuthenticator.runWithSystem(() -> sleep(3_000))) // T1: ends first
.runAsync();
uiAsyncTasks.runnableConfigurer(() -> {
sleep(1_000); // T2: begins after T1
systemAuthenticator.runWithSystem(() -> sleep(5_000)); // and ends after T1
})
.runAsync();
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
- Log in as
adminand click whoAmI. It showsUsernamePasswordAuthenticationToken / admin. - Click longSystemTask, then click whoAmI within 10 seconds.
- Wait 10 seconds and click whoAmI again.
- Click overlappingTasks, wait 10 seconds, then click whoAmI and open any view that
adminis not allowed to open.
Current Behavior
- Step 2 shows
SystemAuthenticationToken / system: the UI request runs assystem. - Step 3 shows
adminagain. - Step 4 still shows
SystemAuthenticationToken / systemafter both tasks have finished. The session stayssystem(with all its permissions) until logout.
Expected Behavior
begin()/end() affect only the current thread. Other threads of the same session always see the user’s own authentication, and overlapping begin()/end() pairs never leave the session with another authentication.
Suggested Fix
In begin(), save the current SecurityContext instance and set a new context on the thread:
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
In end(), restore the saved instance with SecurityContextHolder.setContext(previous) (or clearContext() when there was none) instead of calling setAuthentication() on the current context. SystemAuthenticatorSupport would then keep a stack of SecurityContext instead of Authentication.
Note that `setContext()` alone is not enough in a Flow UI application. `FlowuiVaadinWebSecurity` extends `VaadinWebSecurity`, which imports `VaadinAwareSecurityContextHolderStrategyConfiguration`. The resulting `VaadinAwareSecurityContextHolderStrategy.getContext()` returns the `SecurityContext` stored in the HTTP session whenever`VaadinSession.getCurrent()` is set, and ignores what the thread set with `setContext()`. On UI threads, `withSystem` would then have no effect at all. The context set by `begin()` must take precedence over the session context, for example with a strategy that wraps the Vaadin one and lets `begin()`/`end()` push and pop a thread-local context that `getContext()` returns first.
SecurityContextHelper.setAuthentication() is also used this way in BackgroundWorkerImpl, so it may need the same review.
Related older report with the same kind of symptom (“Access denied” on every view after begin()/end()): jmix-projects/jmix-security#69.
Sample Project
Not attached: the snippet above is the whole reproducer.