From 8a23d7e64960d789b64d4bbc48c753a7dcad72c0 Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Fri, 31 Jul 2026 10:16:30 +0530 Subject: [PATCH 1/2] fix: prevent open redirect via protocol-relative returnTo --- .../with-authentication-required.test.tsx | 27 +++++++++++++++++++ src/with-authentication-required.tsx | 10 +++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/__tests__/with-authentication-required.test.tsx b/__tests__/with-authentication-required.test.tsx index fc8d2b95..443f859f 100644 --- a/__tests__/with-authentication-required.test.tsx +++ b/__tests__/with-authentication-required.test.tsx @@ -182,6 +182,33 @@ describe('withAuthenticationRequired', () => { ); }); + it('should sanitize protocol-relative returnTo paths to prevent open redirect', async () => { + // Simulate a URL like https://app.example.com//evil.com whose pathname is //evil.com. + // Routers (react-router, next.js, gatsby) treat //evil.com as a protocol-relative URL + // and redirect the user to http://evil.com. + window.history.replaceState({}, '', 'https://www.example.com//evil.com'); + + mockClient.getUser.mockResolvedValue(undefined); + const MyComponent = () => <>Private; + const WrappedComponent = withAuthenticationRequired(MyComponent); + render( + + + + ); + await waitFor(() => + expect(mockClient.loginWithRedirect).toHaveBeenCalledWith( + expect.objectContaining({ + appState: expect.objectContaining({ + returnTo: '/evil.com', + }), + }) + ) + ); + + window.history.replaceState({}, '', 'https://www.example.com/'); + }); + it('should call loginWithRedirect only once even if parent state changes', async () => { mockClient.getUser.mockResolvedValue(undefined); const MyComponent = () => <>Private; diff --git a/src/with-authentication-required.tsx b/src/with-authentication-required.tsx index 3908b09d..41bc8bed 100644 --- a/src/with-authentication-required.tsx +++ b/src/with-authentication-required.tsx @@ -18,8 +18,14 @@ const defaultOnBeforeAuthentication = async (): Promise => {/* noop */ }; /** * @ignore */ -const defaultReturnTo = (): string => - `${window.location.pathname}${window.location.search}`; +const defaultReturnTo = (): string => { + // Normalize the pathname to prevent protocol-relative open redirects. + // A URL like https://app.example.com//evil.com produces a pathname of + // //evil.com, which routers (react-router, next.js, gatsby) interpret as a + // protocol-relative URL and redirect the user to an external host. + const pathname = window.location.pathname.replace(/^\/\/+/, '/'); + return `${pathname}${window.location.search}`; +}; /** * Options for the withAuthenticationRequired Higher Order Component From ff1c451a304bc462a17a6258efe10b3963b825ec Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Fri, 31 Jul 2026 10:37:11 +0530 Subject: [PATCH 2/2] test: restore url in finally block after open redirect test --- .../with-authentication-required.test.tsx | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/__tests__/with-authentication-required.test.tsx b/__tests__/with-authentication-required.test.tsx index 443f859f..5931f8e1 100644 --- a/__tests__/with-authentication-required.test.tsx +++ b/__tests__/with-authentication-required.test.tsx @@ -186,27 +186,30 @@ describe('withAuthenticationRequired', () => { // Simulate a URL like https://app.example.com//evil.com whose pathname is //evil.com. // Routers (react-router, next.js, gatsby) treat //evil.com as a protocol-relative URL // and redirect the user to http://evil.com. + const originalUrl = window.location.href; window.history.replaceState({}, '', 'https://www.example.com//evil.com'); - mockClient.getUser.mockResolvedValue(undefined); - const MyComponent = () => <>Private; - const WrappedComponent = withAuthenticationRequired(MyComponent); - render( - - - - ); - await waitFor(() => - expect(mockClient.loginWithRedirect).toHaveBeenCalledWith( - expect.objectContaining({ - appState: expect.objectContaining({ - returnTo: '/evil.com', - }), - }) - ) - ); - - window.history.replaceState({}, '', 'https://www.example.com/'); + try { + mockClient.getUser.mockResolvedValue(undefined); + const MyComponent = () => <>Private; + const WrappedComponent = withAuthenticationRequired(MyComponent); + render( + + + + ); + await waitFor(() => + expect(mockClient.loginWithRedirect).toHaveBeenCalledWith( + expect.objectContaining({ + appState: expect.objectContaining({ + returnTo: '/evil.com', + }), + }) + ) + ); + } finally { + window.history.replaceState({}, '', originalUrl); + } }); it('should call loginWithRedirect only once even if parent state changes', async () => {