Skip to content

Commit 875d13d

Browse files
committed
fix(@angular/ssr): prevent redirect loop with encoded query parameters
Previously, encoded query parameters caused a mismatch between the requested URL and the reconstructed URL, leading to a redirect loop. This change ensures both URLs are decoded before comparison. Closes #31881
1 parent 2671945 commit 875d13d

File tree

3 files changed

+57
-6
lines changed

3 files changed

+57
-6
lines changed

packages/angular/ssr/src/utils/ng.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { PlatformLocation } from '@angular/common';
9+
import { LocationStrategy, PlatformLocation } from '@angular/common';
1010
import {
1111
ApplicationRef,
1212
type PlatformRef,
@@ -21,7 +21,7 @@ import {
2121
platformServer,
2222
ɵrenderInternal as renderInternal,
2323
} from '@angular/platform-server';
24-
import { ActivatedRoute, Router } from '@angular/router';
24+
import { ActivatedRoute, Router, UrlSerializer } from '@angular/router';
2525
import { Console } from '../console';
2626
import { stripIndexHtmlFromURL, stripTrailingSlash } from './url';
2727

@@ -107,13 +107,15 @@ export async function renderAngular(
107107

108108
if (!routerIsProvided) {
109109
hasNavigationError = false;
110-
} else if (lastSuccessfulNavigation) {
110+
} else if (lastSuccessfulNavigation?.finalUrl) {
111111
hasNavigationError = false;
112+
112113
const { pathname, search, hash } = envInjector.get(PlatformLocation);
113-
const finalUrl = [stripTrailingSlash(pathname), search, hash].join('');
114+
const finalUrl = constructDecodedUrl({ pathname, search, hash });
115+
const urlToRenderString = constructDecodedUrl(urlToRender);
114116

115-
if (urlToRender.href !== new URL(finalUrl, urlToRender.origin).href) {
116-
redirectTo = finalUrl;
117+
if (urlToRenderString !== finalUrl) {
118+
redirectTo = [pathname, search, hash].join('');
117119
}
118120
}
119121

@@ -171,3 +173,23 @@ function asyncDestroyPlatform(platformRef: PlatformRef): Promise<void> {
171173
}, 0);
172174
});
173175
}
176+
177+
/**
178+
* Constructs a decoded URL string from its components, ensuring consistency for comparison.
179+
*
180+
* This function takes a URL-like object (containing `pathname`, `search`, and `hash`),
181+
* strips the trailing slash from the pathname, joins the components, and then decodes
182+
* the entire string. This normalization is crucial for accurately comparing URLs
183+
* that might differ only in encoding or trailing slashes.
184+
*
185+
* @param url - An object containing the URL components:
186+
* - `pathname`: The path of the URL.
187+
* - `search`: The query string of the URL (including '?').
188+
* - `hash`: The hash fragment of the URL (including '#').
189+
* @returns The constructed and decoded URL string.
190+
*/
191+
function constructDecodedUrl(url: { pathname: string; search: string; hash: string }): string {
192+
const joinedUrl = [stripTrailingSlash(url.pathname), url.search, url.hash].join('');
193+
194+
return decodeURIComponent(joinedUrl);
195+
}

packages/angular/ssr/src/utils/url.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,18 @@ export function stripMatrixParams(pathname: string): string {
220220
// This regex finds all occurrences of a semicolon followed by any characters
221221
return pathname.includes(';') ? pathname.replace(MATRIX_PARAMS_REGEX, '') : pathname;
222222
}
223+
224+
/**
225+
* Constructs a decoded URL string from its components.
226+
*
227+
* This function joins the pathname (with trailing slash removed), search, and hash,
228+
* and then decodes the result.
229+
*
230+
* @param pathname - The path of the URL.
231+
* @param search - The query string of the URL (including '?').
232+
* @param hash - The hash fragment of the URL (including '#').
233+
* @returns The constructed and decoded URL string.
234+
*/
235+
export function constructUrl(pathname: string, search: string, hash: string): string {
236+
return decodeURIComponent([stripTrailingSlash(pathname), search, hash].join(''));
237+
}

packages/angular/ssr/test/app-engine_spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,5 +269,19 @@ describe('AngularAppEngine', () => {
269269
const response = await appEngine.handle(request);
270270
expect(await response?.text()).toContain('Home works');
271271
});
272+
273+
it('should work with encoded characters', async () => {
274+
const request = new Request('https://example.com/home?email=xyz%40xyz.com');
275+
const response = await appEngine.handle(request);
276+
expect(response?.status).toBe(200);
277+
expect(await response?.text()).toContain('Home works');
278+
});
279+
280+
it('should work with decoded characters', async () => {
281+
const request = new Request('https://example.com/home?email=xyz@xyz.com');
282+
const response = await appEngine.handle(request);
283+
expect(response?.status).toBe(200);
284+
expect(await response?.text()).toContain('Home works');
285+
});
272286
});
273287
});

0 commit comments

Comments
 (0)