Files
windmill/frontend/src/lib/logoutRedirect.test.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

99 lines
3.6 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { isValidLogoutRedirect, toSameOriginRelativePath } from './logoutRedirect'
describe('isValidLogoutRedirect', () => {
beforeEach(() => {
vi.stubGlobal('window', { location: { origin: 'http://localhost:3000' } })
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('accepts same-origin absolute URLs', () => {
expect(isValidLogoutRedirect('http://localhost:3000/')).toBe(true)
expect(isValidLogoutRedirect('http://localhost:3000/runs?workspace=foo')).toBe(true)
})
it('accepts root-relative paths', () => {
expect(isValidLogoutRedirect('/foo')).toBe(true)
})
it('accepts windmill.dev hosts', () => {
expect(isValidLogoutRedirect('https://app.windmill.dev/foo')).toBe(true)
})
it('rejects cross-origin and protocol-relative', () => {
expect(isValidLogoutRedirect('https://evil.com/')).toBe(false)
expect(isValidLogoutRedirect('//evil.com')).toBe(false)
})
})
describe('toSameOriginRelativePath', () => {
beforeEach(() => {
// The function reads `window.location.origin` to resolve relative URLs
// and detect cross-origin targets. The default test env is `node`, so
// stub a minimal `window` with the origin we want to anchor against.
vi.stubGlobal('window', { location: { origin: 'http://localhost:3000' } })
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns same-origin relative paths as-is', () => {
expect(toSameOriginRelativePath('/a/nucleus/chat')).toBe('/a/nucleus/chat')
expect(toSameOriginRelativePath('/runs?workspace=foo')).toBe('/runs?workspace=foo')
expect(toSameOriginRelativePath('/path#fragment')).toBe('/path#fragment')
})
it('reduces same-origin full URLs to relative paths', () => {
expect(toSameOriginRelativePath('http://localhost:3000/a/project-template/test')).toBe(
'/a/project-template/test'
)
expect(
toSameOriginRelativePath('http://localhost:3000/a/project-template/test?foo=bar#baz')
).toBe('/a/project-template/test?foo=bar#baz')
})
it('rejects cross-origin URLs', () => {
expect(toSameOriginRelativePath('https://evil.com/path')).toBeNull()
expect(toSameOriginRelativePath('http://other.windmill.dev/path')).toBeNull()
})
it('rejects protocol-relative and back-slash tricks', () => {
expect(toSameOriginRelativePath('//evil.com')).toBeNull()
expect(toSameOriginRelativePath('/\\evil.com')).toBeNull()
})
it('rejects empty and null inputs', () => {
expect(toSameOriginRelativePath('')).toBeNull()
expect(toSameOriginRelativePath(null)).toBeNull()
expect(toSameOriginRelativePath(undefined)).toBeNull()
})
it('rejects values containing control characters', () => {
expect(toSameOriginRelativePath('/path\r\nSet-Cookie: x=y')).toBeNull()
expect(toSameOriginRelativePath('/path\x00null')).toBeNull()
})
it('resolves rootless inputs against the current origin', () => {
// Relative-style inputs without a leading slash are not what callers
// pass us in practice, but resolving them against the current origin
// is a safe outcome (still same-origin) — better than silently
// dropping them.
expect(toSameOriginRelativePath('relative/no/slash')).toBe('/relative/no/slash')
})
it('rejects values that exceed the length cap', () => {
expect(toSameOriginRelativePath('/' + 'a'.repeat(2048))).toBeNull()
})
it('handles the SP BASE_URL with no path as same-origin root', () => {
// When SAML SP libraries send the bare BASE_URL as RelayState default,
// reducing it to the origin's root path is still safe and same-origin.
expect(toSameOriginRelativePath('http://localhost:3000')).toBe('/')
expect(toSameOriginRelativePath('http://localhost:3000/')).toBe('/')
})
})