diff --git a/__tests__/with-authentication-required.test.tsx b/__tests__/with-authentication-required.test.tsx index fc8d2b95..5931f8e1 100644 --- a/__tests__/with-authentication-required.test.tsx +++ b/__tests__/with-authentication-required.test.tsx @@ -182,6 +182,36 @@ 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. + const originalUrl = window.location.href; + window.history.replaceState({}, '', 'https://www.example.com//evil.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 () => { 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