Fix cross-user identity binding race in PROXY auth mode websocket setup - #1426
Fix cross-user identity binding race in PROXY auth mode websocket setup#1426herdiyana256 wants to merge 2 commits into
Conversation
…cket setup server.js captured the sockjs-session auth token / user id (used to make backend API calls under PROXY authentication mode) in a single shared authToken/userid variable, set inside the raw http 'upgrade' listener and read back inside sockjs's 'connection' handler. Because that state was shared across every in-flight session rather than scoped per session, a second, concurrently-connecting user's request could clobber it before an earlier session's 'connection' handler read it back -- binding that earlier session to the wrong user's identity. Sessions established over a non-websocket sockjs transport (xhr-streaming, xhr-polling, eventsource -- sockjs's normal fallback whenever a websocket upgrade isn't available) never fire 'upgrade' at all, so they always read back whatever the last unrelated websocket connection had left behind. Verified with the real sockjs/ws packages: 50 concurrent websocket connections against the old logic produced 50/50 cross-bound identities; an xhr-streaming session deterministically inherited a previous, unrelated websocket session's auth token and user id. Extracted the capture/lookup into server/socket-identity.mjs, keyed by sockjs session id (parsed from the request URL) instead of one shared variable, and wired it into both the 'upgrade' and 'request' listeners so every transport is covered. Also moved the origin check ahead of identity capture in the 'upgrade' listener, since the old ordering let a rejected-origin request still populate the shared state before being denied.
There was a problem hiding this comment.
Code Review
This pull request introduces a SocketIdentityStore to manage sockjs session identities by session ID instead of a single shared variable, preventing identity leakage across concurrent connections—especially for HTTP fallback transports. The reviewer feedback highlights critical issues: first, same-origin fallback requests lacking an Origin header will fail validation, so isAllowedOrigin should support an optional flag to allow missing origins; second, the store's timer management can leak timeouts or cause premature deletion races when multiple requests occur for the same session, which can be resolved by tracking and clearing active timeouts.
| function isAllowedOrigin(req) { | ||
| return allowedOrigin.indexOf(req.headers.origin) !== -1; | ||
| } |
There was a problem hiding this comment.
In same-origin HTTP requests (such as fallback transports like xhr-streaming or eventsource), browsers typically do not send an Origin header. Under the current implementation, isAllowedOrigin will return false when req.headers.origin is undefined, causing the request listener to skip capturing the session identity. This completely breaks fallback transports in PROXY mode.
We should update isAllowedOrigin to accept an optional allowMissing parameter (defaulting to false to preserve strict origin validation for WebSocket upgrades) and return true if the Origin header is missing and allowMissing is enabled.
function isAllowedOrigin(req, allowMissing = false) {
const origin = req.headers.origin;
if (!origin) {
return allowMissing;
}
return allowedOrigin.indexOf(origin) !== -1;
}There was a problem hiding this comment.
Fixed in 622b70b. isAllowedOrigin now takes an allowMissing flag, and the request listener passes true since these fallback transports don't reliably send Origin on same-origin requests. Upgrade path stays strict.
| server.addListener('request', function(req) { | ||
| if (!isAllowedOrigin(req)) { | ||
| return; | ||
| } | ||
| socketIdentities.capture(req); | ||
| }); |
There was a problem hiding this comment.
Update the request listener to call isAllowedOrigin(req, true) so that same-origin fallback transport requests without an Origin header are allowed to have their identities captured.
| server.addListener('request', function(req) { | |
| if (!isAllowedOrigin(req)) { | |
| return; | |
| } | |
| socketIdentities.capture(req); | |
| }); | |
| server.addListener('request', function(req) { | |
| if (!isAllowedOrigin(req, true)) { | |
| return; | |
| } | |
| socketIdentities.capture(req); | |
| }); |
There was a problem hiding this comment.
Done in 622b70b, request listener now calls isAllowedOrigin(req, true).
| export function createSocketIdentityStore({ prefix, cdapConfig, getAuthHeaderFromRawCookies, ttlMs = 60000 }) { | ||
| const pending = new Map(); | ||
|
|
||
| /** Call for every request that might be starting/continuing a sockjs session. */ | ||
| function capture(req) { | ||
| const sessionId = getSockjsSessionId(prefix, req.url); | ||
| if (!sessionId) { | ||
| return; | ||
| } | ||
| req.headers.authorization = getAuthHeaderFromRawCookies(req); | ||
| const userIdProperty = cdapConfig['security.authentication.proxy.user.identity.header']; | ||
| pending.set(sessionId, { | ||
| authToken: req.headers.authorization, | ||
| userid: req.headers[userIdProperty], | ||
| }); | ||
| const timer = setTimeout(() => pending.delete(sessionId), ttlMs); | ||
| if (typeof timer.unref === 'function') { | ||
| timer.unref(); | ||
| } | ||
| } | ||
|
|
||
| /** Call once a sockjs 'connection' fires, with that connection's own url. */ | ||
| function consume(url) { | ||
| const sessionId = getSockjsSessionId(prefix, url); | ||
| if (!sessionId) { | ||
| return {}; | ||
| } | ||
| const identity = pending.get(sessionId) || {}; | ||
| pending.delete(sessionId); | ||
| return identity; | ||
| } | ||
|
|
||
| return { capture, consume }; | ||
| } |
There was a problem hiding this comment.
There are two issues with the current timer management in createSocketIdentityStore:
- Dangling/Leaked Timeouts: Every call to
capture(which happens on every polling request for fallback transports) schedules a newsetTimeoutthat is never cleared, even after the identity is successfully consumed or overwritten. This leads to unnecessary CPU overhead and memory churn. - Premature Deletion Race: If multiple handshake or transport requests for the same session ID occur before the connection is established, multiple timeouts are scheduled. When the first timeout fires, it will delete the session identity from
pending, even if a subsequent request refreshed it.
Additionally, we should add defensive checks for cdapConfig to prevent runtime errors if it is null or undefined.
We can resolve these issues by storing the timer reference in the pending map, clearing any existing timeout when overwriting or consuming the session, and adding defensive checks.
export function createSocketIdentityStore({ prefix, cdapConfig, getAuthHeaderFromRawCookies, ttlMs = 60000 }) {
const pending = new Map();
/** Call for every request that might be starting/continuing a sockjs session. */
function capture(req) {
const sessionId = getSockjsSessionId(prefix, req.url);
if (!sessionId) {
return;
}
// Clear any existing timeout for this session to avoid premature deletion
const existing = pending.get(sessionId);
if (existing && existing.timer) {
clearTimeout(existing.timer);
}
req.headers.authorization = getAuthHeaderFromRawCookies(req);
const userIdProperty = cdapConfig && cdapConfig['security.authentication.proxy.user.identity.header'];
const timer = setTimeout(() => pending.delete(sessionId), ttlMs);
if (typeof timer.unref === 'function') {
timer.unref();
}
pending.set(sessionId, {
authToken: req.headers.authorization,
userid: userIdProperty ? req.headers[userIdProperty] : undefined,
timer,
});
}
/** Call once a sockjs 'connection' fires, with that connection's own url. */
function consume(url) {
const sessionId = getSockjsSessionId(prefix, url);
if (!sessionId) {
return {};
}
const identity = pending.get(sessionId);
if (!identity) {
return {};
}
if (identity.timer) {
clearTimeout(identity.timer);
}
pending.delete(sessionId);
return {
authToken: identity.authToken,
userid: identity.userid,
};
}
return { capture, consume };
}There was a problem hiding this comment.
Fair point, polling transports call capture() once per poll for the same session so the old timers were piling up and one could delete a fresher entry before it got consumed. Fixed in 622b70b: the timer is now stored on the entry and cleared on overwrite/consume, plus added a fake-timers test for it.
… fix timer leak/race isAllowedOrigin now takes an allowMissing flag. Browsers don't reliably send Origin on same-origin requests, and the new 'request' listener had no equivalent check before this PR at all, so treating a missing Origin as same-origin there (matches the fallback transports' actual behavior) avoids breaking PROXY-mode xhr-streaming/xhr-polling sessions outright. The websocket upgrade path keeps the strict, no-missing-origin check it already had. createSocketIdentityStore now clears the previous entry's timer before scheduling a new one. Polling transports call capture() once per poll for the same session id; without this, an earlier call's timer could fire and delete a later, still-unconsumed entry.
|
We appreciate the time and effort that went into this fix. For this reason, I am putting this PR on hold for a couple days. I will get back regarding this fix once we have verified the feasibility of removing websockets entirely. |
|
Thanks, that makes sense. If the resource-polling websocket channel can be removed outright with a small refactor, that retires this whole identity-capture surface and is a cleaner outcome than hardening it, so I am happy to hold. Whichever way the evaluation lands, I can help close it out: if websockets go away entirely I will close this PR, and if any of that setup has to stay I will rebase this onto whatever remains. Just let me know. |
|
Hi @GnsP, checking in on this one. It has been about a week since the hold, so I wanted to ask how the evaluation went. Did removing the websocket setup entirely turn out to be feasible? No rush either way, and my offer from before still stands: if websockets are going away I will close this PR, and if any of that setup survives the refactor I will rebase this onto whatever remains. Just let me know which direction it lands. |
Summary
In
security.authentication.mode: PROXYdeployments,server.jscaptures the auth token / user id used to make backend API calls over the websocket resource-polling channel (server/aggregator.js) in two module-level variables,authTokenanduserid:These variables are shared across every in-flight sockjs session on the process, not scoped per session. That has two consequences:
upgradeevent can overwriteauthToken/useridbefore an earlier session'sconnectionhandler reads them back. The earlier session ends up bound to the other user's identity for the lifetime of that websocket connection -- every subsequent backend API call made through it (viaAggregator,server/aggregator.js) executes as the wrong user.xhr-streaming,xhr-polling,eventsource) whenever a websocket upgrade isn't available (some corporate proxies, older browsers). Those transports never fire Node'supgradeevent at all, so every session opened this way is bound to whateverauthToken/useridsome unrelated, earlier websocket connection last left behind (orundefinedif none has occurred yet in the process's life).Verified both with the real
sockjs/wspackages, not a synthetic re-implementation:upgradefired last, not its own).xhr-streamingrequest, sent with its own distinctAuthorization/identity headers, deterministically inherited an unrelated, earlier websocket session's identity instead of its own.This is a real design gap, not a documented tradeoff -- the existing comment block above this code explicitly describes the intent as "we take those values and add to the connection object" (i.e. meant to be per-connection), but the implementation used a variable shared across all connections instead.
Fix
Extracted the capture/lookup into
server/socket-identity.mjs, keyed by sockjs session id (parsed from the request URL, e.g./_sock/000/<session>/<transport>) instead of a single shared variable:capture(req)records the identity for a specific session id, called from both theupgradelistener (websocket transport) and a newrequestlistener (covers the polling/streaming fallback transports, which never fireupgrade).consume(url)looks up and removes a specific session's identity, called once from theconnectionhandler using that connection's ownurl.Also moved the
Origincheck ahead of identity capture in theupgradelistener -- previously a rejected-origin request still populated the shared state before being denied, which no longer matters for a different reason now (state is scoped per session) but was tightened anyway since it's the same code region.Test plan
node -e "require('@babel/core').parse(...)"(via the project's actual@babel/core): bothserver.jsandserver/socket-identity.mjsparse cleanly.server/socket-identity.test.js(Jest,describe/test/expect) unit-testsgetSockjsSessionIdandcreateSocketIdentityStoredirectly, including a regression test asserting that capturing two different sessions' identities back-to-back (simulating the interleaving that used to clobber the shared variable) does not leak one into the other.yarn installdid not complete in the environment I used for this (same large/older dependency tree issue noted on Fix arbitrary code execution and predictable session tokens in /updateTheme #1425) --node_modules/pretty-formatitself is missing internal files, so I couldn't run this through the project's actual Jest config. I instead: (a) installed a fresh, isolatedjest/babeltoolchain and confirmed all 6 tests inserver/socket-identity.test.jspass against the unmodified file; (b) installed the realsockjs/wspackages standalone and ran the exact pre-fix and post-fix logic (copied verbatim, not reimplemented) against real HTTP/websocket traffic to produce the before/after numbers above. Happy to re-verify against the real Jest run if that's easier for a reviewer with the deps already installed.Related to the auth wiring discussed in #1425, but a distinct root cause in a different file (
server.js/server/aggregator.js, PROXY-mode websocket setup) untouched by that PR.