From 5ec775afb5b1e3b5abce0caac2e812c365d0f32c Mon Sep 17 00:00:00 2001 From: Col0ring <1561999073@qq.com> Date: Thu, 29 Oct 2026 11:03:36 +0800 Subject: [PATCH 1/6] fix(webui): read the request cookie through a registered server reader - register the request-cookie reader from auth.server instead of importing it from the API client - detect the server by the absence of `window` so non-bundled runs attach the cookie too --- webui/frontend/app/lib/api.ts | 11 ++++++++--- webui/frontend/app/lib/auth.server.ts | 4 +++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/webui/frontend/app/lib/api.ts b/webui/frontend/app/lib/api.ts index 8e4ae80fd..5e93b0007 100644 --- a/webui/frontend/app/lib/api.ts +++ b/webui/frontend/app/lib/api.ts @@ -171,6 +171,12 @@ export function readFailure( // fresh, so mutations + refetches still see up-to-date data). const inflightGets = new Map>() +let readRequestCookie: (() => string) | null = null + +export function registerRequestCookieReader(reader: () => string): void { + readRequestCookie = reader +} + async function json( input: RequestInfo, init?: RequestInit, @@ -192,9 +198,8 @@ async function json( try { // An explicit empty header object lets FormData supply its own boundary. const headers = new Headers(init?.headers ?? { 'Content-Type': 'application/json' }) - if (import.meta.env.SSR) { - const { requestCookie } = await import('./auth.server') - const cookie = requestCookie() + if (typeof window === 'undefined') { + const cookie = readRequestCookie?.() if (cookie) headers.set('Cookie', cookie) } res = await fetch(resolve(input), { diff --git a/webui/frontend/app/lib/auth.server.ts b/webui/frontend/app/lib/auth.server.ts index de255f380..39b218938 100644 --- a/webui/frontend/app/lib/auth.server.ts +++ b/webui/frontend/app/lib/auth.server.ts @@ -1,6 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { redirect, redirectDocument, type MiddlewareFunction } from 'react-router' -import { api, ApiError } from './api' +import { api, ApiError, registerRequestCookieReader } from './api' import { AUTH_DISABLED, loginDestination, type AuthStatus } from './auth' interface RequestAuth { @@ -16,6 +16,8 @@ export function requestCookie() { return requests.getStore()?.cookie ?? '' } +registerRequestCookieReader(requestCookie) + export function requestAuth() { return requests.getStore()?.status ?? AUTH_DISABLED } From 247391ad0aba794acccae146b0ad9eddf9caa63a Mon Sep 17 00:00:00 2001 From: Col0ring <1561999073@qq.com> Date: Thu, 29 Oct 2026 11:03:37 +0800 Subject: [PATCH 2/6] test(webui): cover request-cookie isolation and browser request headers - hold two concurrent auth-status requests to check each request keeps its own cookie across awaits - assert JSON requests send a content type, uploads keep their FormData body and browser requests send no cookie --- webui/frontend/scripts/checkAuthRedirects.ts | 80 +++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/webui/frontend/scripts/checkAuthRedirects.ts b/webui/frontend/scripts/checkAuthRedirects.ts index fe5bcb2a4..ae21af891 100644 --- a/webui/frontend/scripts/checkAuthRedirects.ts +++ b/webui/frontend/scripts/checkAuthRedirects.ts @@ -1,4 +1,6 @@ import assert from 'node:assert/strict' +import { RouterContextProvider } from 'react-router' +import { api } from '../app/lib/api' import { loginDestination, redirectIfSignedOut } from '../app/lib/auth' // `next` is attacker-controlled. URL normalization must not turn it into an @@ -29,4 +31,80 @@ try { } finally { Reflect.deleteProperty(globalThis, 'window') } -console.log('[webui] Auth redirects and platform 401 handling passed') + +const originalFetch = globalThis.fetch +const calls: { path: string; cookie: string | null; contentType: string | null; body: BodyInit | null | undefined }[] = [] +let releaseAuth!: () => void +const authGate = new Promise((resolve) => { releaseAuth = resolve }) +let authCalls = 0 + +globalThis.fetch = async (input, init) => { + const path = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url).pathname + const headers = new Headers(init?.headers) + const cookie = headers.get('Cookie') + calls.push({ path, cookie, contentType: headers.get('Content-Type'), body: init?.body }) + if (path === '/api/auth/status') { + if (++authCalls === 2) releaseAuth() + await authGate + } + return Response.json({ + code: 0, + message: 'success', + data: path === '/api/auth/status' + ? { enabled: true, authenticated: true, username: cookie } + : null + }) +} + +try { + await api.getRecoveryStatus() + assert.equal(calls.at(-1)?.cookie, null) + const { authMiddleware, requestAuth, requestCookie } = await import('../app/lib/auth.server') + const runRequest = (cookie: string, next: () => Promise) => authMiddleware({ + request: new Request('http://localhost/', { headers: { Cookie: cookie } }), + url: new URL('http://localhost/'), + pattern: '/', + params: {}, + context: new RouterContextProvider() + }, next) + + const responses = await Promise.all(['visitor=A', 'visitor=B'].map(cookie => runRequest(cookie, async () => { + assert.equal(requestCookie(), cookie) + assert.equal(requestAuth().username, cookie) + await api.getProject(cookie) + await Promise.resolve() + assert.equal(requestCookie(), cookie) + await api.uploadWorkspaceFile(cookie, new File(['test'], 'test.txt')) + return new Response('ok') + }))) + assert.equal(authCalls, 2) + for (const response of responses) assert.equal(response?.headers.get('Cache-Control'), 'no-store') + for (const cookie of ['visitor=A', 'visitor=B']) { + const projectCalls = calls.filter(call => call.path.startsWith(`/api/projects/${encodeURIComponent(cookie)}`)) + assert.equal(projectCalls.length, 2) + assert.ok(projectCalls.every(call => call.cookie === cookie)) + assert.equal(projectCalls[0].contentType, 'application/json') + assert.equal(projectCalls[1].contentType, null) + assert.ok(projectCalls[1].body instanceof FormData) + assert.equal((projectCalls[1].body.get('file') as File).name, 'test.txt') + } + + assert.equal(requestCookie(), '') + await api.getRecoveryStatus() + assert.equal(calls.at(-1)?.cookie, null) + + await runRequest('visitor=server-only', async () => { + Object.defineProperty(globalThis, 'window', { configurable: true, value: {} }) + try { + assert.equal(requestCookie(), 'visitor=server-only') + await api.getRecoveryStatus() + assert.equal(calls.at(-1)?.cookie, null) + } finally { + Reflect.deleteProperty(globalThis, 'window') + } + return new Response('ok') + }) +} finally { + globalThis.fetch = originalFetch +} +console.log('[webui] Auth redirects, request-cookie isolation, browser headers and FormData passed') From 41e872d7a52875132818a808a802e91f40bb7e49 Mon Sep 17 00:00:00 2001 From: Col0ring <1561999073@qq.com> Date: Thu, 29 Oct 2026 11:58:22 +0800 Subject: [PATCH 3/6] fix(webui): allow only same-origin and direct requests without an Origin - treat a missing Origin as an allowlist of `same-origin` and `none` fetch sites so a sibling subdomain cannot drive a write - cover the same-site, same-origin and none fetch sites in the auth tests --- webui/backend/app/core/auth.py | 8 +++++++- webui/backend/tests/test_auth.py | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/webui/backend/app/core/auth.py b/webui/backend/app/core/auth.py index 12794cee7..41802fd69 100644 --- a/webui/backend/app/core/auth.py +++ b/webui/backend/app/core/auth.py @@ -89,10 +89,16 @@ def trusted_origin(request: Request, allowed_origins: list[str]) -> bool: The proxy preserves the browser's Host. Compare it to Origin (including the port) rather than the internal loopback API address. Explicit CORS origins also support the documented development setup. + + Without an Origin the decision is an allowlist, not a denylist: `same-site` + is a DIFFERENT origin under the same registrable domain, and the session + cookie is `SameSite=lax`, so a hostile sibling subdomain would otherwise be + able to drive a write. Non-browser clients and direct navigation send no + `Sec-Fetch-Site` at all, or `none`, and stay allowed. """ origin = request.headers.get("origin") if origin is None: - return request.headers.get("sec-fetch-site") != "cross-site" + return request.headers.get("sec-fetch-site") in {"same-origin", "none", None} if origin in allowed_origins: return True try: diff --git a/webui/backend/tests/test_auth.py b/webui/backend/tests/test_auth.py index 0601ec04a..a1dd513e4 100644 --- a/webui/backend/tests/test_auth.py +++ b/webui/backend/tests/test_auth.py @@ -107,6 +107,10 @@ def test_browser_origins_and_tls_cookie(client): assert login(client, headers={"Origin": "https://untrusted.example"}).status_code == 403 assert login(client, headers={"Origin": "null"}).status_code == 403 assert login(client, headers={"Sec-Fetch-Site": "cross-site"}).status_code == 403 + # A sibling subdomain is a different origin, and the cookie is SameSite=lax. + assert login(client, headers={"Sec-Fetch-Site": "same-site"}).status_code == 403 + assert login(client, headers={"Sec-Fetch-Site": "same-origin"}).status_code == 200 + assert login(client, headers={"Sec-Fetch-Site": "none"}).status_code == 200 assert login(client, headers={"Origin": "http://localhost:5173"}).status_code == 200 assert client.post("/api/auth/logout", headers={"Origin": "https://untrusted.example"}).status_code == 403 # TLS can terminate at the public proxy while the backend uses loopback HTTP. From 97a749434850e22a5d29982b770c4db3fe17c4ba Mon Sep 17 00:00:00 2001 From: Col0ring <1561999073@qq.com> Date: Thu, 29 Oct 2026 11:58:23 +0800 Subject: [PATCH 4/6] refactor(webui): share the login path and URL builders - move the login path test and the login URL builder into the shared auth module and use them from the middleware and the sign-in link --- webui/frontend/app/lib/auth.server.ts | 6 +++--- webui/frontend/app/lib/auth.ts | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/webui/frontend/app/lib/auth.server.ts b/webui/frontend/app/lib/auth.server.ts index 39b218938..e8c2c4af1 100644 --- a/webui/frontend/app/lib/auth.server.ts +++ b/webui/frontend/app/lib/auth.server.ts @@ -1,7 +1,7 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { redirect, redirectDocument, type MiddlewareFunction } from 'react-router' import { api, ApiError, registerRequestCookieReader } from './api' -import { AUTH_DISABLED, loginDestination, type AuthStatus } from './auth' +import { AUTH_DISABLED, isLoginPath, loginDestination, loginUrl, type AuthStatus } from './auth' interface RequestAuth { cookie: string @@ -35,9 +35,9 @@ export const authMiddleware: MiddlewareFunction = async ({ request }, } requests.getStore()!.status = status const url = new URL(request.url) - const isLogin = /^\/login(?:\.data)?\/?$/.test(url.pathname) + const isLogin = isLoginPath(url.pathname) if (status.enabled && !status.authenticated && !isLogin) { - throw redirectDocument('/login?next=' + encodeURIComponent(url.pathname.replace(/\.data$/, '') + url.search)) + throw redirectDocument(loginUrl(url.pathname.replace(/\.data$/, '') + url.search)) } if (isLogin && (!status.enabled || status.authenticated)) { throw redirect(loginDestination(url.searchParams.get('next'))) diff --git a/webui/frontend/app/lib/auth.ts b/webui/frontend/app/lib/auth.ts index cded0f745..9a6d06866 100644 --- a/webui/frontend/app/lib/auth.ts +++ b/webui/frontend/app/lib/auth.ts @@ -17,16 +17,28 @@ export function useAuth() { return root?.auth ?? AUTH_DISABLED } +/** `/login`, including its `.data` single-fetch request and a trailing slash. */ +const LOGIN_PATH = /^\/login(?:\.data)?\/?$/ + +export function isLoginPath(pathname: string): boolean { + return LOGIN_PATH.test(pathname) +} + +/** The login page with the in-app path to return to after signing in. */ +export function loginUrl(next: string): string { + return '/login?next=' + encodeURIComponent(next) +} + /** Only return to a path within this app, never a user-supplied external URL. */ export function loginDestination(value: string | null): string { if (!value?.startsWith('/') || value.startsWith('//') || /[\\\r\n\t]/.test(value)) return '/' const path = new URL(value, 'http://localhost').pathname - return /^\/login(?:\.data)?\/?$/.test(path) ? '/' : value + return isLoginPath(path) ? '/' : value } export function signInUrl() { - return '/login?next=' + encodeURIComponent(window.location.pathname + window.location.search) + return loginUrl(window.location.pathname + window.location.search) } export function redirectIfSignedOut(response: Response) { From cba283d1da62673f0e5a3ea723e6bde6c14ed8a7 Mon Sep 17 00:00:00 2001 From: Col0ring <1561999073@qq.com> Date: Thu, 29 Oct 2026 11:58:24 +0800 Subject: [PATCH 5/6] fix(webui): sign out when the event stream closes on an expired session - ask the auth status once the event stream fails for good and redirect to the login page when the session is no longer authenticated - let the auth status request opt into silent failures --- webui/frontend/app/lib/api.ts | 2 +- webui/frontend/app/lib/serverEvents.tsx | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/webui/frontend/app/lib/api.ts b/webui/frontend/app/lib/api.ts index 5e93b0007..099508bcb 100644 --- a/webui/frontend/app/lib/api.ts +++ b/webui/frontend/app/lib/api.ts @@ -282,7 +282,7 @@ const fp = (path: string) => * useXChat (see app/lib/agentProvider.ts), not this module. */ export const api = { - getAuthStatus: () => json('/api/auth/status'), + getAuthStatus: (opts?: JsonOpts) => json('/api/auth/status', undefined, opts), login: (username: string, password: string) => json('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }, { silent: true }), diff --git a/webui/frontend/app/lib/serverEvents.tsx b/webui/frontend/app/lib/serverEvents.tsx index 4d2e6657c..4977bbf4d 100644 --- a/webui/frontend/app/lib/serverEvents.tsx +++ b/webui/frontend/app/lib/serverEvents.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef } from 'react' import { useRevalidator } from 'react-router' +import { api } from '~/lib/api' +import { signInUrl } from '~/lib/auth' import { dispatchMcpSkillChanged, dispatchModelsChanged, @@ -85,9 +87,29 @@ export function ServerEventsBridge() { const es = new EventSource('/api/events') es.addEventListener('change', onChange) + // An expired session answers this stream with a JSON 401, and a response + // that is not `text/event-stream` fails it permanently rather than being + // retried. EventSource cannot read that response, so the `json()` sign-out + // redirect never runs: ask the API instead. Settings routes have no other + // signal — the presence heartbeat is mounted on the app layout. + const onError = () => { + if (es.readyState !== EventSource.CLOSED) return + api + .getAuthStatus({ silent: true }) + .then((auth) => { + if (auth.enabled && !auth.authenticated) + window.location.replace(signInUrl()) + }) + .catch(() => { + // Backend unreachable: a reload will reconnect. + }) + } + es.addEventListener('error', onError) + return () => { window.clearTimeout(timer) es.removeEventListener('change', onChange) + es.removeEventListener('error', onError) es.close() } }, []) From 8268f998831aa31d16d50137eddd4d977c52fc38 Mon Sep 17 00:00:00 2001 From: Col0ring <1561999073@qq.com> Date: Thu, 29 Oct 2026 11:58:25 +0800 Subject: [PATCH 6/6] test(webui): cover the signed-out redirect to the login page - assert an expired session never reaches the loader and returns to its in-app path without the `.data` suffix - assert the login page itself stays reachable while signed out --- webui/frontend/scripts/checkAuthRedirects.ts | 22 ++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/webui/frontend/scripts/checkAuthRedirects.ts b/webui/frontend/scripts/checkAuthRedirects.ts index ae21af891..101a04728 100644 --- a/webui/frontend/scripts/checkAuthRedirects.ts +++ b/webui/frontend/scripts/checkAuthRedirects.ts @@ -37,6 +37,7 @@ const calls: { path: string; cookie: string | null; contentType: string | null; let releaseAuth!: () => void const authGate = new Promise((resolve) => { releaseAuth = resolve }) let authCalls = 0 +let authenticated = true globalThis.fetch = async (input, init) => { const path = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url).pathname @@ -51,7 +52,7 @@ globalThis.fetch = async (input, init) => { code: 0, message: 'success', data: path === '/api/auth/status' - ? { enabled: true, authenticated: true, username: cookie } + ? { enabled: true, authenticated, username: cookie } : null }) } @@ -60,9 +61,9 @@ try { await api.getRecoveryStatus() assert.equal(calls.at(-1)?.cookie, null) const { authMiddleware, requestAuth, requestCookie } = await import('../app/lib/auth.server') - const runRequest = (cookie: string, next: () => Promise) => authMiddleware({ - request: new Request('http://localhost/', { headers: { Cookie: cookie } }), - url: new URL('http://localhost/'), + const runRequest = (cookie: string, next: () => Promise, href = 'http://localhost/') => authMiddleware({ + request: new Request(href, { headers: { Cookie: cookie } }), + url: new URL(href), pattern: '/', params: {}, context: new RouterContextProvider() @@ -104,6 +105,19 @@ try { } return new Response('ok') }) + + // An expired session never reaches the loader: it is sent to the login page + // with the in-app path to come back to, stripped of its `.data` suffix. + authenticated = false + const unreachable = async () => { throw new Error('loader ran while signed out') } + const expired = await Promise.resolve(runRequest('visitor=expired', unreachable, + 'http://localhost/settings/models.data?tab=providers')).catch((error: unknown) => error) + assert.ok(expired instanceof Response) + assert.equal(expired.headers.get('Location'), '/login?next=%2Fsettings%2Fmodels%3Ftab%3Dproviders') + const onLogin = await runRequest('visitor=expired', async () => new Response('ok'), + 'http://localhost/login?next=%2Fsettings') + assert.ok(onLogin instanceof Response) + assert.equal(onLogin.status, 200) } finally { globalThis.fetch = originalFetch }