Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion webui/backend/app/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions webui/backend/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 9 additions & 4 deletions webui/frontend/app/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ export function readFailure(
// fresh, so mutations + refetches still see up-to-date data).
const inflightGets = new Map<string, Promise<unknown>>()

let readRequestCookie: (() => string) | null = null

export function registerRequestCookieReader(reader: () => string): void {
readRequestCookie = reader
}

async function json<T>(
input: RequestInfo,
init?: RequestInit,
Expand All @@ -192,9 +198,8 @@ async function json<T>(
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), {
Expand Down Expand Up @@ -277,7 +282,7 @@ const fp = (path: string) =>
* useXChat (see app/lib/agentProvider.ts), not this module.
*/
export const api = {
getAuthStatus: () => json<AuthStatus>('/api/auth/status'),
getAuthStatus: (opts?: JsonOpts) => json<AuthStatus>('/api/auth/status', undefined, opts),
login: (username: string, password: string) => json<null>('/api/auth/login', {
method: 'POST', body: JSON.stringify({ username, password })
}, { silent: true }),
Expand Down
10 changes: 6 additions & 4 deletions webui/frontend/app/lib/auth.server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { redirect, redirectDocument, type MiddlewareFunction } from 'react-router'
import { api, ApiError } from './api'
import { AUTH_DISABLED, loginDestination, type AuthStatus } from './auth'
import { api, ApiError, registerRequestCookieReader } from './api'
import { AUTH_DISABLED, isLoginPath, loginDestination, loginUrl, type AuthStatus } from './auth'

interface RequestAuth {
cookie: string
Expand All @@ -16,6 +16,8 @@ export function requestCookie() {
return requests.getStore()?.cookie ?? ''
}

registerRequestCookieReader(requestCookie)

export function requestAuth() {
return requests.getStore()?.status ?? AUTH_DISABLED
}
Expand All @@ -33,9 +35,9 @@ export const authMiddleware: MiddlewareFunction<Response> = 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')))
Expand Down
16 changes: 14 additions & 2 deletions webui/frontend/app/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
22 changes: 22 additions & 0 deletions webui/frontend/app/lib/serverEvents.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()
}
}, [])
Expand Down
94 changes: 93 additions & 1 deletion webui/frontend/scripts/checkAuthRedirects.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -29,4 +31,94 @@ 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<void>((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
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, 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<Response>, href = 'http://localhost/') => authMiddleware({
request: new Request(href, { headers: { Cookie: cookie } }),
url: new URL(href),
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')
})

// 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
}
console.log('[webui] Auth redirects, request-cookie isolation, browser headers and FormData passed')