diff --git a/apps/iframe-app/src/lib/__tests__/authHandler.test.ts b/apps/iframe-app/src/lib/__tests__/authHandler.test.ts index 68e033315a0..c89da760683 100644 --- a/apps/iframe-app/src/lib/__tests__/authHandler.test.ts +++ b/apps/iframe-app/src/lib/__tests__/authHandler.test.ts @@ -334,6 +334,89 @@ describe('authHandler', () => { expect(onFailed).toHaveBeenCalled(); }); + it('should allow login popup for a trusted Logic Apps domain on a different origin', () => { + const mockPopup = { closed: false, close: vi.fn(), location: { href: '' } }; + mockWindowOpen.mockReturnValueOnce(mockPopup); + + openLoginPopup({ + baseUrl: 'https://contoso.logic.azure.com', + signInEndpoint: '/.auth/login/aad', + }); + + expect(mockWindowOpen).toHaveBeenCalledWith( + 'https://contoso.logic.azure.com/.auth/login/aad', + 'auth-login', + 'width=600,height=700,popup=true' + ); + }); + + it('should block login popup that redirects to an untrusted origin', () => { + const onFailed = vi.fn(); + + openLoginPopup({ + baseUrl: 'https://evil.example.net', + signInEndpoint: '/.auth/login/aad', + onFailed, + }); + + expect(mockWindowOpen).not.toHaveBeenCalled(); + expect(onFailed).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('should block a sign-in endpoint that escapes the base origin via userinfo', () => { + const onFailed = vi.fn(); + + // `${baseUrl}${signInEndpoint}` => https://example.com@evil.example.net/... (origin becomes evil.example.net) + openLoginPopup({ + baseUrl: 'https://example.com', + signInEndpoint: '@evil.example.net/.auth/login/aad', + onFailed, + }); + + expect(mockWindowOpen).not.toHaveBeenCalled(); + expect(onFailed).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('should block a login popup URL that embeds userinfo even on a trusted host', () => { + const onFailed = vi.fn(); + + // Trusted host, but the embedded userinfo is a spoofing vector and must be rejected. + openLoginPopup({ + baseUrl: 'https://user:pass@contoso.logic.azure.com', + signInEndpoint: '/.auth/login/aad', + onFailed, + }); + + expect(mockWindowOpen).not.toHaveBeenCalled(); + expect(onFailed).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('should block a non-https login popup URL on a non-localhost host', () => { + const onFailed = vi.fn(); + + openLoginPopup({ + baseUrl: 'http://contoso.logic.azure.com', + signInEndpoint: '/.auth/login/aad', + onFailed, + }); + + expect(mockWindowOpen).not.toHaveBeenCalled(); + expect(onFailed).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('should block a malformed login popup URL', () => { + const onFailed = vi.fn(); + + openLoginPopup({ + baseUrl: 'not-a-valid-url', + signInEndpoint: '/.auth/login/aad', + onFailed, + }); + + expect(mockWindowOpen).not.toHaveBeenCalled(); + expect(onFailed).toHaveBeenCalledWith(expect.any(Error)); + }); + it('should call onSuccess when login succeeds and popup closes', async () => { vi.useFakeTimers(); diff --git a/apps/iframe-app/src/lib/authHandler.ts b/apps/iframe-app/src/lib/authHandler.ts index 751a1c46543..15ef5e23c0c 100644 --- a/apps/iframe-app/src/lib/authHandler.ts +++ b/apps/iframe-app/src/lib/authHandler.ts @@ -3,6 +3,8 @@ * Handles token refresh, login popup, and logout scenarios when 401 errors occur */ +import { ALLOWED_LOGIC_APPS_DOMAINS } from './utils/trusted-domains'; + // ============================================================================ // Types & Interfaces // ============================================================================ @@ -41,8 +43,8 @@ export interface AuthHandlerConfig { export interface LoginPopupOptions { /** Base URL of the App Service */ baseUrl: string; - /** Path to the sign-in endpoint */ - signInEndpoint?: string; + /** Path to the sign-in endpoint (e.g. `/.auth/login/aad`) */ + signInEndpoint: string; /** URL to redirect to after successful login (relative to baseUrl) */ postLoginRedirectUri?: string; /** Callback when login completes successfully, receives auth information including username */ @@ -202,6 +204,54 @@ export async function checkAuthStatus(baseUrl: string): Promise // Public API - Login Popup // ============================================================================ +/** + * Normalizes and validates a login popup URL to prevent client-side open-redirect + * (a.k.a. `window.open` hijacking). The URL — which is built from the potentially + * user-influenced `baseUrl`/`signInEndpoint` values — is parsed into a `URL` object + * and then strictly matched against the current origin or a trusted domain allowlist. + * + * @returns the canonicalized, safe URL string, or `null` if the URL is untrusted/invalid. + */ +function getValidatedLoginUrl(loginUrl: string): string | null { + let parsed: URL; + try { + parsed = new URL(loginUrl); + } catch { + return null; + } + + // Reject URLs that embed userinfo (https://user:pass@host/...). Even when the + // host is trusted, this pattern is a common URL-spoofing vector and is never + // needed for EasyAuth login popups. + if (parsed.username !== '' || parsed.password !== '') { + return null; + } + + const hostname = parsed.hostname.toLowerCase(); + const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1'; + + // Only https is allowed, except http on localhost during local development. + if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLocalhostHost)) { + return null; + } + + // Same origin as the hosting page is always trusted — EasyAuth lives on the app's own origin. + if (parsed.origin === window.location.origin) { + return parsed.href; + } + + // localhost is only trusted when the app itself is running locally. + if (isLocalhostHost) { + const isLocalDevelopment = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + return isLocalDevelopment ? parsed.href : null; + } + + // Otherwise the host must belong to a trusted Microsoft Logic Apps domain. + const isTrustedDomain = ALLOWED_LOGIC_APPS_DOMAINS.some((domain) => hostname === domain.slice(1) || hostname.endsWith(domain)); + + return isTrustedDomain ? parsed.href : null; +} + /** * Opens login popup for Azure App Service EasyAuth and monitors for completion. * Supports dynamic identity providers via the signInEndpoint parameter. @@ -216,7 +266,15 @@ export function openLoginPopup(options: LoginPopupOptions): void { loginUrl += `?post_login_redirect_uri=${encodeURIComponent(postLoginRedirectUri)}`; } - const popup = window.open(loginUrl, 'auth-login', 'width=600,height=700,popup=true'); + // Validate the (potentially user-influenced) URL before navigating to guard against + // client-side open-redirect via crafted base URL or sign-in endpoint values. + const validatedLoginUrl = getValidatedLoginUrl(loginUrl); + if (!validatedLoginUrl) { + onFailed?.(new Error('Blocked login popup with an untrusted or invalid URL.')); + return; + } + + const popup = window.open(validatedLoginUrl, 'auth-login', 'width=600,height=700,popup=true'); if (!popup) { onFailed?.(new Error('Failed to open login popup')); diff --git a/apps/iframe-app/src/lib/utils/config-parser.ts b/apps/iframe-app/src/lib/utils/config-parser.ts index 0706e73190a..52c9c0d3415 100644 --- a/apps/iframe-app/src/lib/utils/config-parser.ts +++ b/apps/iframe-app/src/lib/utils/config-parser.ts @@ -1,5 +1,6 @@ import type { ChatWidgetProps, ChatTheme, IdentityProvider } from '@microsoft/logic-apps-chat'; import { THEME_PRESETS } from './theme-presets'; +import { ALLOWED_LOGIC_APPS_DOMAINS } from './trusted-domains'; export interface IframeConfig { props: ChatWidgetProps; @@ -49,7 +50,8 @@ function validatePortalSecurity(params: URLSearchParams): PortalValidationResult return { trustedParentOrigin: trustedAuthority }; } -const ALLOWED_AGENT_CARD_DOMAINS = ['.logic.azure.com', '.logic-apps.azure.com']; +// Shared trusted Logic Apps domain allowlist (see ./trusted-domains). +const ALLOWED_AGENT_CARD_DOMAINS = ALLOWED_LOGIC_APPS_DOMAINS; /** * Validates that an agent card URL uses HTTPS and points to a trusted Microsoft domain. diff --git a/apps/iframe-app/src/lib/utils/trusted-domains.ts b/apps/iframe-app/src/lib/utils/trusted-domains.ts new file mode 100644 index 00000000000..cbe4bce95bf --- /dev/null +++ b/apps/iframe-app/src/lib/utils/trusted-domains.ts @@ -0,0 +1,12 @@ +/** + * Trusted Microsoft Logic Apps domain suffixes shared across the iframe app. + * + * This is the single source of truth for the domain allowlist used by the + * security-sensitive checks in this app: + * - Agent card URL validation (`config-parser.ts`) + * - EasyAuth login popup URL validation (`authHandler.ts`) + * + * Keep both consumers pointed at this constant so the allowlists can never + * drift apart (which could reintroduce a security gap or break login). + */ +export const ALLOWED_LOGIC_APPS_DOMAINS = ['.logic.azure.com', '.logic-apps.azure.com'];