Files
windmill/frontend/src/lib/logoutRedirect.ts
T
GuilhemandClaude Fable 5 8500435e82 fix(frontend): allow same-origin redirects in isValidLogoutRedirect (#9568)
Self-hosted instances that embed their own UI (Windmill as backend, custom
app at the instance root) can't be redirected back to their app after OAuth
login: isValidLogoutRedirect rejects same-origin absolute URLs, so the login
callback falls back to a client-side goto('/') into Windmill's own dashboard.
Same-origin redirects are never open redirects (and toSameOriginRelativePath
already treats them as safe), so accept them.

Also make the test-setup window global configurable so vitest's stubGlobal
can redefine it across the window-stubbing test suites.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 10:25:11 +02:00

71 lines
2.3 KiB
TypeScript

import { get } from 'svelte/store'
import { hubBaseUrlStore } from './stores'
export function isValidLogoutRedirect(url: string): boolean {
if (url.startsWith('/') && !url.startsWith('//')) {
return true
}
try {
const parsed = new URL(url)
// Same-origin absolute URLs are always safe: the user can only be sent
// back to the site they're already on, which is never an open redirect.
// Mirrors toSameOriginRelativePath()'s same-origin handling, and lets a
// self-hosted instance redirect back to its own app after OAuth login.
if (typeof window !== 'undefined' && parsed.origin === window.location.origin) {
return true
}
const host = parsed.hostname
if (host === 'windmill.dev' || host.endsWith('.windmill.dev')) {
return true
}
const hubBaseUrl = get(hubBaseUrlStore)
try {
const hubHost = new URL(hubBaseUrl).hostname
if (host === hubHost) {
return true
}
} catch {}
} catch {}
return false
}
/**
* Reduces a redirect target to a safe same-origin relative path.
*
* Returns the path (`/foo?bar#baz`) when the input is either:
* - already an absolute-but-relative path (`/foo`) that isn't protocol-relative
* (`//evil.com`) or a back-slash trick (`/\\evil.com`), or
* - a full URL whose origin matches the current page's origin.
*
* Returns `null` for anything else (cross-origin URLs, protocol-relative paths,
* malformed input). Used to sanitize values flowing into `RelayState`,
* `localStorage.rd`, and post-login redirects so we don't either lose a valid
* same-origin deep link or open-redirect into a foreign origin.
*/
export function toSameOriginRelativePath(rd: string | null | undefined): string | null {
if (!rd) return null
if (rd.length > 2048) return null
if (hasControlChar(rd)) return null
if (rd.startsWith('/')) {
if (rd.startsWith('//') || rd.startsWith('/\\')) return null
return rd
}
if (typeof window === 'undefined') return null
try {
const url = new URL(rd, window.location.origin)
if (url.origin !== window.location.origin) return null
const path = url.pathname + url.search + url.hash
return path || '/'
} catch {
return null
}
}
function hasControlChar(s: string): boolean {
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i)
if (c < 0x20 || c === 0x7f) return true
}
return false
}