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