From 6a1a763cdcd7ae152d0fbe97bceeb95e60e1ae9f Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Sat, 25 Apr 2026 08:41:04 +0200 Subject: [PATCH] Cancel background traffic before login/register so session cookie isn't clobbered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When handle_user_login invalidates the previous anonymous session and a concurrent request using the old cookie is still in flight, the server creates a *new* anonymous session for it and responds with a fresh `Set-Cookie: galaxysession=`. If that response lands between the login POST and the full-page navigation, the browser navigates with the anonymous cookie and the new page loads logged out. Fix: synchronously stop the polling watchers and rotate a shared AbortController before sending the login/register POST. The shared signal is wired through both axios (via a request interceptor) and the GalaxyApi/openapi-fetch client (via a request middleware) so a single rotation cancels every in-flight request, regardless of transport. With no in-flight anonymous-cookie request, the server can't emit the clobbering Set-Cookie, and the authenticated cookie survives until navigation. Backport of the relevant pieces of #22513 (sse-notifications) for release_26.0 — the same race exists outside SSE because it is the in-flight polling/REST traffic, not the SSE stream itself, that carries the stale cookie. --- client/src/api/client/index.ts | 4 ++ .../api/client/pendingRequestsMiddleware.ts | 24 ++++++++ client/src/api/pendingRequests.ts | 58 +++++++++++++++++++ client/src/components/Login/LoginForm.vue | 23 ++++++-- .../src/components/Register/RegisterForm.vue | 27 ++++++--- client/src/composables/useAuthNavigation.ts | 25 ++++++++ client/src/entry/analysis/index.ts | 7 +++ client/src/stores/entryPointStore.ts | 10 ++-- client/src/stores/historyStore.ts | 16 ++--- client/src/stores/notificationsStore.ts | 10 ++-- 10 files changed, 175 insertions(+), 29 deletions(-) create mode 100644 client/src/api/client/pendingRequestsMiddleware.ts create mode 100644 client/src/api/pendingRequests.ts create mode 100644 client/src/composables/useAuthNavigation.ts diff --git a/client/src/api/client/index.ts b/client/src/api/client/index.ts index 5e1d4d09c854..98a359387778 100644 --- a/client/src/api/client/index.ts +++ b/client/src/api/client/index.ts @@ -1,5 +1,6 @@ import createClient from "openapi-fetch"; +import { pendingRequestsMiddleware } from "@/api/client/pendingRequestsMiddleware"; import { createRateLimiterMiddleware } from "@/api/client/rateLimiter"; import type { GalaxyApiPaths } from "@/api/schema"; import { getAppRoot } from "@/onload/loadConfig"; @@ -12,6 +13,9 @@ function getBaseUrl() { function apiClientFactory() { const client = createClient({ baseUrl: getBaseUrl() }); + // Registered first so aborted requests bypass the rate-limiter queue. + client.use(pendingRequestsMiddleware); + // TODO: Adjust based on server limits (maybe this goes in Galaxy config?) client.use( createRateLimiterMiddleware({ diff --git a/client/src/api/client/pendingRequestsMiddleware.ts b/client/src/api/client/pendingRequestsMiddleware.ts new file mode 100644 index 000000000000..04eb4b2c11aa --- /dev/null +++ b/client/src/api/client/pendingRequestsMiddleware.ts @@ -0,0 +1,24 @@ +import type { Middleware } from "openapi-fetch"; + +import { getPendingAbortSignal, SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests"; + +/** + * Attaches the shared pending-requests signal to every ``GalaxyApi`` request. + * The ``openapi-fetch`` client uses native ``fetch()`` so the axios + * interceptor does not apply; without this middleware, login/register + * navigations cannot cancel in-flight ``/api/...`` calls and a late + * anonymous-cookie response can clobber the authenticated ``galaxysession`` + * cookie. See ``client/src/api/pendingRequests.ts`` for the race. + */ +export const pendingRequestsMiddleware: Middleware = { + async onRequest({ request }) { + if (request.headers.has(SKIP_PENDING_REQUESTS_HEADER)) { + const headers = new Headers(request.headers); + headers.delete(SKIP_PENDING_REQUESTS_HEADER); + return new Request(request, { headers }); + } + const shared = getPendingAbortSignal(); + const signal = typeof AbortSignal.any === "function" ? AbortSignal.any([request.signal, shared]) : shared; + return new Request(request, { signal }); + }, +}; diff --git a/client/src/api/pendingRequests.ts b/client/src/api/pendingRequests.ts new file mode 100644 index 000000000000..cbae404bd408 --- /dev/null +++ b/client/src/api/pendingRequests.ts @@ -0,0 +1,58 @@ +/** + * Shared ``AbortController`` used by both the axios interceptor and the + * ``openapi-fetch`` middleware so we can cancel every in-flight request in + * one shot right before a login/register navigation. + * + * Why this exists: when the server processes a request that carries the old + * anonymous ``galaxysession`` cookie *after* ``handle_user_login`` has marked + * that session ``is_valid=False``, it creates a fresh anonymous session and + * responds with ``Set-Cookie: galaxysession=``. Delivered into the cookie + * jar after the login response but before the new page loads, it overwrites + * the just-issued authenticated cookie — so the new page loads anonymous and + * ``wait_for_logged_in`` times out in selenium. Aborting the TCP connection + * before the response headers are parsed prevents that ``Set-Cookie`` from + * ever applying. + */ +import axios, { type InternalAxiosRequestConfig } from "axios"; + +let activeController = new AbortController(); + +/** Explicit opt-out header that the login/register POST itself sets. */ +export const SKIP_PENDING_REQUESTS_HEADER = "x-galaxy-skip-pending-abort"; + +/** + * The signal every request should ride on by default. Read lazily so the + * ``openapi-fetch`` middleware picks up the fresh signal after each + * ``cancelPendingRequests()`` rotation. + */ +export function getPendingAbortSignal(): AbortSignal { + return activeController.signal; +} + +/** + * Install a request interceptor that attaches the shared signal to every + * outgoing axios request that didn't set one itself. Call once at app boot. + */ +export function installPendingRequestsInterceptor() { + axios.interceptors.request.use((config: InternalAxiosRequestConfig) => { + if (config.signal !== undefined) { + return config; + } + if (config.headers?.[SKIP_PENDING_REQUESTS_HEADER]) { + delete config.headers[SKIP_PENDING_REQUESTS_HEADER]; + return config; + } + config.signal = activeController.signal; + return config; + }); +} + +/** + * Abort every request that is using the shared signal (both axios via the + * interceptor and ``openapi-fetch`` via its middleware) and install a fresh + * controller so subsequent requests can still go out. + */ +export function cancelPendingRequests() { + activeController.abort(); + activeController = new AbortController(); +} diff --git a/client/src/components/Login/LoginForm.vue b/client/src/components/Login/LoginForm.vue index 7ec8b232cd11..a59328ad69c1 100644 --- a/client/src/components/Login/LoginForm.vue +++ b/client/src/components/Login/LoginForm.vue @@ -15,6 +15,8 @@ import { import { computed, ref } from "vue"; import { useRouter } from "vue-router/composables"; +import { SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests"; +import { discardActiveConnectionsBeforeAuthNavigation } from "@/composables/useAuthNavigation"; import localize from "@/utils/localization"; import { withPrefix } from "@/utils/redirect"; import { errorMessageAsString } from "@/utils/simple-error"; @@ -86,13 +88,22 @@ async function submitLogin() { redirect = props.redirect ?? null; } + // Stop polling and abort in-flight axios/GalaxyApi before sending the + // login POST — otherwise a late anonymous-cookie response can overwrite + // the authenticated cookie we're about to receive. + discardActiveConnectionsBeforeAuthNavigation(); + try { - const response = await axios.post(withPrefix("/user/login"), { - login: login.value, - password: password.value, - redirect: redirect, - session_csrf_token: props.sessionCsrfToken, - }); + const response = await axios.post( + withPrefix("/user/login"), + { + login: login.value, + password: password.value, + redirect: redirect, + session_csrf_token: props.sessionCsrfToken, + }, + { headers: { [SKIP_PENDING_REQUESTS_HEADER]: "1" } }, + ); if (response.data.message && response.data.status) { alert(response.data.message); diff --git a/client/src/components/Register/RegisterForm.vue b/client/src/components/Register/RegisterForm.vue index 839f498c7bd9..46502c5361c3 100644 --- a/client/src/components/Register/RegisterForm.vue +++ b/client/src/components/Register/RegisterForm.vue @@ -15,8 +15,10 @@ import { } from "bootstrap-vue"; import { computed, type Ref, ref } from "vue"; +import { SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests"; import { getOIDCIdpsWithRegistration, type OIDCConfig } from "@/components/User/ExternalIdentities/ExternalIDHelper"; import { Toast } from "@/composables/toast"; +import { discardActiveConnectionsBeforeAuthNavigation } from "@/composables/useAuthNavigation"; import localize from "@/utils/localization"; import { withPrefix } from "@/utils/redirect"; import { errorMessageAsString } from "@/utils/simple-error"; @@ -70,15 +72,24 @@ const registerColumnDisplay = computed(() => Boolean(props.termsUrl)); async function submit() { disableCreate.value = true; + // Stop polling and abort in-flight axios/GalaxyApi before sending the + // register POST — otherwise a late anonymous-cookie response can overwrite + // the authenticated cookie we're about to receive. + discardActiveConnectionsBeforeAuthNavigation(); + try { - const response = await axios.post(withPrefix("/user/create"), { - email: email.value, - username: username.value, - password: password.value, - confirm: confirm.value, - subscribe: subscribe.value, - session_csrf_token: props.sessionCsrfToken, - }); + const response = await axios.post( + withPrefix("/user/create"), + { + email: email.value, + username: username.value, + password: password.value, + confirm: confirm.value, + subscribe: subscribe.value, + session_csrf_token: props.sessionCsrfToken, + }, + { headers: { [SKIP_PENDING_REQUESTS_HEADER]: "1" } }, + ); if (response.data.message && response.data.status) { Toast.info(response.data.message); diff --git a/client/src/composables/useAuthNavigation.ts b/client/src/composables/useAuthNavigation.ts new file mode 100644 index 000000000000..244d5a0012ef --- /dev/null +++ b/client/src/composables/useAuthNavigation.ts @@ -0,0 +1,25 @@ +import { cancelPendingRequests } from "@/api/pendingRequests"; +import { useEntryPointStore } from "@/stores/entryPointStore"; +import { useHistoryStore } from "@/stores/historyStore"; +import { useNotificationsStore } from "@/stores/notificationsStore"; + +/** + * Tear down every long-lived connection and cancel every in-flight request + * (both axios and ``openapi-fetch``/GalaxyApi) so that nothing issued under + * the old anonymous ``galaxysession`` cookie can land on the server after + * ``handle_user_login`` has invalidated it. See + * ``client/src/api/pendingRequests.ts`` for the race this guards against. + * + * Call this synchronously as the first step of a login or registration + * submit, before the authenticating POST goes out. The shared abort + * controller is rotated, so the login/register POST (issued right after) + * will use a fresh signal and is not affected. + */ +export function discardActiveConnectionsBeforeAuthNavigation() { + // Stop polling watchers first so they can't kick off new fetches, then + // abort any requests still in flight via the shared AbortController. + useHistoryStore().stopWatchingHistory(); + useEntryPointStore().stopWatchingEntryPoints(); + useNotificationsStore().stopWatchingNotifications(); + cancelPendingRequests(); +} diff --git a/client/src/entry/analysis/index.ts b/client/src/entry/analysis/index.ts index a5bf9dab06c4..c03042fc2e94 100644 --- a/client/src/entry/analysis/index.ts +++ b/client/src/entry/analysis/index.ts @@ -2,6 +2,7 @@ import { createPinia, PiniaVuePlugin } from "pinia"; import Vue from "vue"; +import { installPendingRequestsInterceptor } from "@/api/pendingRequests"; import { initGalaxyInstance } from "@/app"; import { initSentry } from "@/app/addons/sentry"; import { initWebhooks } from "@/app/addons/webhooks"; @@ -13,6 +14,12 @@ import App from "./App.vue"; Vue.use(PiniaVuePlugin); const pinia = createPinia(); +// Attach the shared AbortController signal to every outgoing axios request +// so we can cancel in-flight anonymous-cookie requests before login/register +// navigates — otherwise their late ``Set-Cookie: galaxysession=`` can +// clobber the authenticated cookie. +installPendingRequestsInterceptor(); + window.addEventListener("load", async () => { // Create Galaxy object const Galaxy = await initGalaxyInstance(); diff --git a/client/src/stores/entryPointStore.ts b/client/src/stores/entryPointStore.ts index e59b6fefeeae..805124a713d5 100644 --- a/client/src/stores/entryPointStore.ts +++ b/client/src/stores/entryPointStore.ts @@ -23,10 +23,11 @@ interface EntryPoint { } export const useEntryPointStore = defineStore("entryPointStore", () => { - const { startWatchingResource: startWatchingEntryPoints } = useResourceWatcher(fetchEntryPoints, { - shortPollingInterval: ACTIVE_POLLING_INTERVAL, - enableBackgroundPolling: false, // No need to poll in the background - }); + const { startWatchingResource: startWatchingEntryPoints, stopWatchingResource: stopWatchingEntryPoints } = + useResourceWatcher(fetchEntryPoints, { + shortPollingInterval: ACTIVE_POLLING_INTERVAL, + enableBackgroundPolling: false, // No need to poll in the background + }); const entryPoints = ref([]); @@ -92,5 +93,6 @@ export const useEntryPointStore = defineStore("entryPointStore", () => { updateEntryPoints, removeEntryPoint, startWatchingEntryPoints, + stopWatchingEntryPoints, }; }); diff --git a/client/src/stores/historyStore.ts b/client/src/stores/historyStore.ts index b8688ff68ced..f5998d35dcab 100644 --- a/client/src/stores/historyStore.ts +++ b/client/src/stores/historyStore.ts @@ -379,13 +379,14 @@ export const useHistoryStore = defineStore("historyStore", () => { return watchHistorySuppliedApp(app); } - const { startWatchingResource: startWatchingHistory, isWatchingResource: isWatchingHistory } = useResourceWatcher( - watchHistory, - { - shortPollingInterval: ACTIVE_POLLING_INTERVAL, - longPollingInterval: INACTIVE_POLLING_INTERVAL, - }, - ); + const { + startWatchingResource: startWatchingHistory, + stopWatchingResource: stopWatchingHistory, + isWatchingResource: isWatchingHistory, + } = useResourceWatcher(watchHistory, { + shortPollingInterval: ACTIVE_POLLING_INTERVAL, + longPollingInterval: INACTIVE_POLLING_INTERVAL, + }); async function loadHistoryById(historyId: string): Promise { if (!isLoadingHistory.has(historyId)) { @@ -508,6 +509,7 @@ export const useHistoryStore = defineStore("historyStore", () => { restoreHistories, handleTotalCountChange, startWatchingHistory, + stopWatchingHistory, isWatchingHistory, loadCurrentHistory, loadCurrentHistoryId, diff --git a/client/src/stores/notificationsStore.ts b/client/src/stores/notificationsStore.ts index 48f8cc8652dd..2a154885d999 100644 --- a/client/src/stores/notificationsStore.ts +++ b/client/src/stores/notificationsStore.ts @@ -13,10 +13,11 @@ const ACTIVE_POLLING_INTERVAL = 30000; // 30 seconds const INACTIVE_POLLING_INTERVAL = ACTIVE_POLLING_INTERVAL * 20; // 10 minutes export const useNotificationsStore = defineStore("notificationsStore", () => { - const { startWatchingResource: startWatchingNotifications } = useResourceWatcher(getNotificationStatus, { - shortPollingInterval: ACTIVE_POLLING_INTERVAL, - longPollingInterval: INACTIVE_POLLING_INTERVAL, - }); + const { startWatchingResource: startWatchingNotifications, stopWatchingResource: stopWatchingNotifications } = + useResourceWatcher(getNotificationStatus, { + shortPollingInterval: ACTIVE_POLLING_INTERVAL, + longPollingInterval: INACTIVE_POLLING_INTERVAL, + }); const broadcastsStore = useBroadcastsStore(); const totalUnreadCount = ref(0); @@ -106,5 +107,6 @@ export const useNotificationsStore = defineStore("notificationsStore", () => { updateNotification, updateBatchNotification, startWatchingNotifications, + stopWatchingNotifications, }; });