From 49f904546867df67f146eaa866ab5b282eb98ae3 Mon Sep 17 00:00:00 2001 From: faygade Date: Sun, 1 Feb 2026 18:03:34 +0200 Subject: [PATCH 1/3] fix-app-logout-issues --- src/modules/auth.ts | 9 +++-- tests/unit/auth.test.js | 81 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 50f0e0e..a38e8cf 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -48,10 +48,13 @@ export function createAuthModule( ? new URL(nextUrl, window.location.origin).toString() : window.location.href; + // Sanitize appBaseUrl: remove trailing slashes and /login suffix to prevent URL stacking + const sanitizedBaseUrl = (options.appBaseUrl ?? "") + .replace(/\/+$/, "") + .replace(/\/login$/, ""); + // Build the login URL - const loginUrl = `${ - options.appBaseUrl ?? "" - }/login?from_url=${encodeURIComponent(redirectUrl)}`; + const loginUrl = `${sanitizedBaseUrl}/login?from_url=${encodeURIComponent(redirectUrl)}`; // Redirect to the login page window.location.href = loginUrl; diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index 9ed354e..80ff301 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -222,6 +222,87 @@ describe('Auth Module', () => { // Restore window global.window = originalWindow; }); + + describe('appBaseUrl sanitization', () => { + 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 sanitize appBaseUrl with trailing slash', () => { + testClient = createClient({ + serverUrl, + appId, + appBaseUrl: 'https://custom-app.example.com/', + }); + + // Mock window.location for the test + const mockLocation = { href: '' }; + global.window = { + location: mockLocation + }; + + const nextUrl = 'https://example.com/dashboard'; + testClient.auth.redirectToLogin(nextUrl); + + // Should produce: https://custom-app.example.com/login (not //login) + expect(mockLocation.href).toBe( + `https://custom-app.example.com/login?from_url=${encodeURIComponent(nextUrl)}` + ); + }); + + test('should sanitize appBaseUrl that already ends with /login', () => { + testClient = createClient({ + serverUrl, + appId, + appBaseUrl: 'https://custom-app.example.com/login', + }); + + // Mock window.location for the test + const mockLocation = { href: '' }; + global.window = { + location: mockLocation + }; + + const nextUrl = 'https://example.com/dashboard'; + testClient.auth.redirectToLogin(nextUrl); + + // Should produce: https://custom-app.example.com/login (not /login/login) + expect(mockLocation.href).toBe( + `https://custom-app.example.com/login?from_url=${encodeURIComponent(nextUrl)}` + ); + }); + + test('should sanitize appBaseUrl with trailing slash and /login', () => { + testClient = createClient({ + serverUrl, + appId, + appBaseUrl: 'https://custom-app.example.com/login/', + }); + + // Mock window.location for the test + const mockLocation = { href: '' }; + global.window = { + location: mockLocation + }; + + const nextUrl = 'https://example.com/dashboard'; + testClient.auth.redirectToLogin(nextUrl); + + // Should produce: https://custom-app.example.com/login + expect(mockLocation.href).toBe( + `https://custom-app.example.com/login?from_url=${encodeURIComponent(nextUrl)}` + ); + }); + }); }); describe('logout()', () => { From 912326a60c21987383f21fa01f811f39f2f14477 Mon Sep 17 00:00:00 2001 From: faygade Date: Mon, 2 Feb 2026 18:50:41 +0200 Subject: [PATCH 2/3] fix redirect loop --- src/modules/auth.ts | 23 +++++++++++----- tests/unit/auth.test.js | 60 +++++++---------------------------------- 2 files changed, 26 insertions(+), 57 deletions(-) diff --git a/src/modules/auth.ts b/src/modules/auth.ts index a38e8cf..46595fb 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -44,17 +44,28 @@ 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; - // Sanitize appBaseUrl: remove trailing slashes and /login suffix to prevent URL stacking - const sanitizedBaseUrl = (options.appBaseUrl ?? "") - .replace(/\/+$/, "") - .replace(/\/login$/, ""); + // Prevent redirect loops: if redirectUrl is already a login URL, extract the original from_url + try { + const parsedUrl = new URL(redirectUrl); + if (parsedUrl.pathname.endsWith("/login")) { + const originalFromUrl = parsedUrl.searchParams.get("from_url"); + if (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 = `${sanitizedBaseUrl}/login?from_url=${encodeURIComponent(redirectUrl)}`; + const loginUrl = `${ + options.appBaseUrl ?? "" + }/login?from_url=${encodeURIComponent(redirectUrl)}`; // Redirect to the login page window.location.href = loginUrl; diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index 80ff301..623267a 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -223,7 +223,7 @@ describe('Auth Module', () => { global.window = originalWindow; }); - describe('appBaseUrl sanitization', () => { + describe('redirect loop prevention', () => { let testClient; let originalWindow; @@ -237,11 +237,11 @@ describe('Auth Module', () => { global.window = originalWindow; }); - test('should sanitize appBaseUrl with trailing slash', () => { + test('should prevent redirect loops when nextUrl is already a login URL', () => { testClient = createClient({ serverUrl, appId, - appBaseUrl: 'https://custom-app.example.com/', + appBaseUrl: 'https://custom-app.example.com', }); // Mock window.location for the test @@ -250,56 +250,14 @@ describe('Auth Module', () => { location: mockLocation }; - const nextUrl = 'https://example.com/dashboard'; - testClient.auth.redirectToLogin(nextUrl); + // nextUrl is already a login URL with from_url - should extract original destination + const originalDestination = 'https://example.com/dashboard'; + const nestedLoginUrl = `https://custom-app.example.com/login?from_url=${encodeURIComponent(originalDestination)}`; + testClient.auth.redirectToLogin(nestedLoginUrl); - // Should produce: https://custom-app.example.com/login (not //login) + // Should use the original destination, not nest login URLs expect(mockLocation.href).toBe( - `https://custom-app.example.com/login?from_url=${encodeURIComponent(nextUrl)}` - ); - }); - - test('should sanitize appBaseUrl that already ends with /login', () => { - testClient = createClient({ - serverUrl, - appId, - appBaseUrl: 'https://custom-app.example.com/login', - }); - - // Mock window.location for the test - const mockLocation = { href: '' }; - global.window = { - location: mockLocation - }; - - const nextUrl = 'https://example.com/dashboard'; - testClient.auth.redirectToLogin(nextUrl); - - // Should produce: https://custom-app.example.com/login (not /login/login) - expect(mockLocation.href).toBe( - `https://custom-app.example.com/login?from_url=${encodeURIComponent(nextUrl)}` - ); - }); - - test('should sanitize appBaseUrl with trailing slash and /login', () => { - testClient = createClient({ - serverUrl, - appId, - appBaseUrl: 'https://custom-app.example.com/login/', - }); - - // Mock window.location for the test - const mockLocation = { href: '' }; - global.window = { - location: mockLocation - }; - - const nextUrl = 'https://example.com/dashboard'; - testClient.auth.redirectToLogin(nextUrl); - - // Should produce: https://custom-app.example.com/login - expect(mockLocation.href).toBe( - `https://custom-app.example.com/login?from_url=${encodeURIComponent(nextUrl)}` + `https://custom-app.example.com/login?from_url=${encodeURIComponent(originalDestination)}` ); }); }); From 34fe4d8c3f21a38de469e0bac7698d99bad04d1b Mon Sep 17 00:00:00 2001 From: faygade Date: Mon, 2 Feb 2026 18:57:01 +0200 Subject: [PATCH 3/3] pr review comments --- src/modules/auth.ts | 33 ++++++++++++- tests/unit/auth.test.js | 100 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 126 insertions(+), 7 deletions(-) diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 46595fb..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. * @@ -51,9 +78,11 @@ export function createAuthModule( // Prevent redirect loops: if redirectUrl is already a login URL, extract the original from_url try { const parsedUrl = new URL(redirectUrl); - if (parsedUrl.pathname.endsWith("/login")) { + 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) { + if (originalFromUrl && isValidRedirectUrl(originalFromUrl)) { // Use the original destination instead of nesting login URLs redirectUrl = originalFromUrl; } diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index 623267a..a27fe12 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -237,21 +237,21 @@ describe('Auth Module', () => { global.window = originalWindow; }); - test('should prevent redirect loops when nextUrl is already a login URL', () => { + 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 - const mockLocation = { href: '' }; + // 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 from_url - should extract original destination - const originalDestination = 'https://example.com/dashboard'; + // 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); @@ -260,6 +260,96 @@ describe('Auth Module', () => { `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)}` + ); + }); }); });