diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 50f0e0e..984ba4e 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -7,6 +7,33 @@ import { ResetPasswordParams, } from "./auth.types"; +/** + * Validates that a redirect URL is safe to use (prevents open redirect attacks). + * Only allows same-origin URLs or relative paths. + * + * @param url - The URL to validate + * @param currentOrigin - The current window origin for comparison + * @returns true if the URL is safe to redirect to + */ +function isValidRedirectUrl(url: string): boolean { + // Relative URLs starting with / are always safe + if (url.startsWith("/") && !url.startsWith("//")) { + return true; + } + + // For absolute URLs, verify same origin + try { + const parsed = new URL(url); + return ( + typeof window !== "undefined" && + parsed.origin === window.location.origin + ); + } catch { + // If URL parsing fails, reject it for safety + return false; + } +} + /** * Creates the auth module for the Base44 SDK. * @@ -44,10 +71,26 @@ export function createAuthModule( } // If nextUrl is not provided, use the current URL - const redirectUrl = nextUrl + let redirectUrl = nextUrl ? new URL(nextUrl, window.location.origin).toString() : window.location.href; + // Prevent redirect loops: if redirectUrl is already a login URL, extract the original from_url + try { + const parsedUrl = new URL(redirectUrl); + const pathname = parsedUrl.pathname; + // Check for login path with or without trailing slash + if (pathname.endsWith("/login") || pathname.endsWith("/login/")) { + const originalFromUrl = parsedUrl.searchParams.get("from_url"); + if (originalFromUrl && isValidRedirectUrl(originalFromUrl)) { + // Use the original destination instead of nesting login URLs + redirectUrl = originalFromUrl; + } + } + } catch { + // If URL parsing fails, continue with the original redirectUrl + } + // Build the login URL const loginUrl = `${ options.appBaseUrl ?? "" diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index 9ed354e..a27fe12 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -222,6 +222,135 @@ describe('Auth Module', () => { // Restore window global.window = originalWindow; }); + + describe('redirect loop prevention', () => { + let testClient; + let originalWindow; + + beforeEach(() => { + // Save and clear window state before each test + originalWindow = global.window; + }); + + afterEach(() => { + // Restore original window state after each test + global.window = originalWindow; + }); + + test('should prevent redirect loops when nextUrl is already a login URL with same-origin from_url', () => { + testClient = createClient({ + serverUrl, + appId, + appBaseUrl: 'https://custom-app.example.com', + }); + + // Mock window.location for the test - must match the from_url origin for validation + const mockLocation = { href: '', origin: 'https://custom-app.example.com' }; + global.window = { + location: mockLocation + }; + + // nextUrl is already a login URL with same-origin from_url - should extract original destination + const originalDestination = 'https://custom-app.example.com/dashboard'; + const nestedLoginUrl = `https://custom-app.example.com/login?from_url=${encodeURIComponent(originalDestination)}`; + testClient.auth.redirectToLogin(nestedLoginUrl); + + // Should use the original destination, not nest login URLs + expect(mockLocation.href).toBe( + `https://custom-app.example.com/login?from_url=${encodeURIComponent(originalDestination)}` + ); + }); + + test('should prevent redirect loops when login URL has trailing slash', () => { + testClient = createClient({ + serverUrl, + appId, + appBaseUrl: 'https://custom-app.example.com', + }); + + const mockLocation = { href: '', origin: 'https://custom-app.example.com' }; + global.window = { + location: mockLocation + }; + + // Login URL with trailing slash + const originalDestination = 'https://custom-app.example.com/dashboard'; + const nestedLoginUrl = `https://custom-app.example.com/login/?from_url=${encodeURIComponent(originalDestination)}`; + testClient.auth.redirectToLogin(nestedLoginUrl); + + expect(mockLocation.href).toBe( + `https://custom-app.example.com/login?from_url=${encodeURIComponent(originalDestination)}` + ); + }); + + test('should allow relative URLs in from_url parameter', () => { + testClient = createClient({ + serverUrl, + appId, + appBaseUrl: 'https://custom-app.example.com', + }); + + const mockLocation = { href: '', origin: 'https://custom-app.example.com' }; + global.window = { + location: mockLocation + }; + + // from_url is a relative path + const originalDestination = '/dashboard'; + const nestedLoginUrl = `https://custom-app.example.com/login?from_url=${encodeURIComponent(originalDestination)}`; + testClient.auth.redirectToLogin(nestedLoginUrl); + + expect(mockLocation.href).toBe( + `https://custom-app.example.com/login?from_url=${encodeURIComponent(originalDestination)}` + ); + }); + + test('should reject cross-origin from_url to prevent open redirect attacks', () => { + testClient = createClient({ + serverUrl, + appId, + appBaseUrl: 'https://custom-app.example.com', + }); + + const mockLocation = { href: '', origin: 'https://custom-app.example.com' }; + global.window = { + location: mockLocation + }; + + // Malicious cross-origin from_url - should NOT be extracted + const maliciousDestination = 'https://evil.com/phishing'; + const nestedLoginUrl = `https://custom-app.example.com/login?from_url=${encodeURIComponent(maliciousDestination)}`; + testClient.auth.redirectToLogin(nestedLoginUrl); + + // Should keep the nested login URL since the from_url is cross-origin (rejected) + expect(mockLocation.href).toBe( + `https://custom-app.example.com/login?from_url=${encodeURIComponent(nestedLoginUrl)}` + ); + }); + + test('should reject protocol-relative URLs to prevent open redirect attacks', () => { + testClient = createClient({ + serverUrl, + appId, + appBaseUrl: 'https://custom-app.example.com', + }); + + const mockLocation = { href: '', origin: 'https://custom-app.example.com' }; + global.window = { + location: mockLocation + }; + + // Protocol-relative URL attempting open redirect - should be rejected + const maliciousDestination = '//evil.com/phishing'; + const nestedLoginUrl = `https://custom-app.example.com/login?from_url=${encodeURIComponent(maliciousDestination)}`; + testClient.auth.redirectToLogin(nestedLoginUrl); + + // Should keep the nested login URL since the from_url is malicious (rejected) + expect(mockLocation.href).toBe( + `https://custom-app.example.com/login?from_url=${encodeURIComponent(nestedLoginUrl)}` + ); + }); + }); }); describe('logout()', () => {