Skip to content

Commit cceb862

Browse files
committed
fix(@angular/ssr): handle X-Forwarded-Prefix and APP_BASE_HREF in redirects
This commit ensures that redirects correctly account for the X-Forwarded-Prefix header and APP_BASE_HREF, preventing incorrect redirect loops or invalid URLs when running behind a proxy or with a base href. Closes #31902 (cherry picked from commit 4dac5f2)
1 parent 25bb7e6 commit cceb862

File tree

4 files changed

+87
-23
lines changed

4 files changed

+87
-23
lines changed

packages/angular/ssr/src/app.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,15 @@ export class AngularServerApp {
175175
}
176176

177177
const { redirectTo, status, renderMode } = matchedRoute;
178+
178179
if (redirectTo !== undefined) {
179-
return createRedirectResponse(buildPathWithParams(redirectTo, url.pathname), status);
180+
return createRedirectResponse(
181+
joinUrlParts(
182+
request.headers.get('X-Forwarded-Prefix') ?? '',
183+
buildPathWithParams(redirectTo, url.pathname),
184+
),
185+
status,
186+
);
180187
}
181188

182189
if (renderMode === RenderMode.Prerender) {

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

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

9-
import { PlatformLocation } from '@angular/common';
9+
import { APP_BASE_HREF, PlatformLocation } from '@angular/common';
1010
import {
1111
ApplicationRef,
1212
type PlatformRef,
13+
REQUEST,
1314
type StaticProvider,
1415
type Type,
1516
ɵConsole,
@@ -23,7 +24,7 @@ import {
2324
} from '@angular/platform-server';
2425
import { ActivatedRoute, Router } from '@angular/router';
2526
import { Console } from '../console';
26-
import { stripIndexHtmlFromURL, stripTrailingSlash } from './url';
27+
import { addTrailingSlash, joinUrlParts, stripIndexHtmlFromURL, stripTrailingSlash } from './url';
2728

2829
/**
2930
* Represents the bootstrap mechanism for an Angular application.
@@ -110,9 +111,13 @@ export async function renderAngular(
110111
} else if (lastSuccessfulNavigation?.finalUrl) {
111112
hasNavigationError = false;
112113

114+
const requestPrefix =
115+
envInjector.get(APP_BASE_HREF, null, { optional: true }) ??
116+
envInjector.get(REQUEST, null, { optional: true })?.headers.get('X-Forwarded-Prefix');
117+
113118
const { pathname, search, hash } = envInjector.get(PlatformLocation);
114-
const finalUrl = constructDecodedUrl({ pathname, search, hash });
115-
const urlToRenderString = constructDecodedUrl(urlToRender);
119+
const finalUrl = constructDecodedUrl({ pathname, search, hash }, requestPrefix);
120+
const urlToRenderString = constructDecodedUrl(urlToRender, requestPrefix);
116121

117122
if (urlToRenderString !== finalUrl) {
118123
redirectTo = [pathname, search, hash].join('');
@@ -186,10 +191,23 @@ function asyncDestroyPlatform(platformRef: PlatformRef): Promise<void> {
186191
* - `pathname`: The path of the URL.
187192
* - `search`: The query string of the URL (including '?').
188193
* - `hash`: The hash fragment of the URL (including '#').
194+
* @param prefix - An optional prefix (e.g., `APP_BASE_HREF`) to prepend to the pathname
195+
* if it is not already present.
189196
* @returns The constructed and decoded URL string.
190197
*/
191-
function constructDecodedUrl(url: { pathname: string; search: string; hash: string }): string {
192-
const joinedUrl = [stripTrailingSlash(url.pathname), url.search, url.hash].join('');
198+
function constructDecodedUrl(
199+
url: { pathname: string; search: string; hash: string },
200+
prefix?: string | null,
201+
): string {
202+
const { pathname, hash, search } = url;
203+
const urlParts: string[] = [];
204+
if (prefix && !addTrailingSlash(pathname).startsWith(addTrailingSlash(prefix))) {
205+
urlParts.push(joinUrlParts(prefix, pathname));
206+
} else {
207+
urlParts.push(stripTrailingSlash(pathname));
208+
}
209+
210+
urlParts.push(search, hash);
193211

194-
return decodeURIComponent(joinedUrl);
212+
return decodeURIComponent(urlParts.join(''));
195213
}

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

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

packages/angular/ssr/test/app_spec.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
import '@angular/compiler';
1212
/* eslint-enable import/no-unassigned-import */
1313

14-
import { Component, inject } from '@angular/core';
14+
import { APP_BASE_HREF } from '@angular/common';
15+
import { Component, REQUEST, inject } from '@angular/core';
1516
import { CanActivateFn, Router } from '@angular/router';
1617
import { AngularServerApp } from '../src/app';
1718
import { RenderMode } from '../src/routes/route-config';
@@ -125,6 +126,14 @@ describe('AngularServerApp', () => {
125126
hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde',
126127
},
127128
},
129+
undefined,
130+
undefined,
131+
[
132+
{
133+
provide: APP_BASE_HREF,
134+
useFactory: () => inject(REQUEST)?.headers.get('X-Forwarded-Prefix'),
135+
},
136+
],
128137
);
129138

130139
app = new AngularServerApp();
@@ -310,6 +319,50 @@ describe('AngularServerApp', () => {
310319
expect(response?.headers.get('location')).toBe('/redirect-via-guard?filter=test');
311320
expect(response?.status).toBe(302);
312321
});
322+
323+
it('should work with encoded characters', async () => {
324+
const request = new Request('http://localhost/home?email=xyz%40xyz.com');
325+
const response = await app.handle(request);
326+
expect(response?.status).toBe(200);
327+
expect(await response?.text()).toContain('Home works');
328+
});
329+
330+
it('should work with decoded characters', async () => {
331+
const request = new Request('http://localhost/home?email=xyz@xyz.com');
332+
const response = await app.handle(request);
333+
expect(response?.status).toBe(200);
334+
expect(await response?.text()).toContain('Home works');
335+
});
336+
337+
describe('APP_BASE_HREF / X-Forwarded-Prefix', () => {
338+
const headers = new Headers({ 'X-Forwarded-Prefix': '/base/' });
339+
340+
it('should return a rendered page for known paths', async () => {
341+
const request = new Request('https://example.com/home', { headers });
342+
const response = await app.handle(request);
343+
expect(await response?.text()).toContain('Home works');
344+
});
345+
346+
it('returns a 302 status and redirects to the correct location when `redirectTo` is a function', async () => {
347+
const response = await app.handle(
348+
new Request('http://localhost/redirect-to-function', {
349+
headers,
350+
}),
351+
);
352+
expect(response?.headers.get('location')).toBe('/base/home');
353+
expect(response?.status).toBe(302);
354+
});
355+
356+
it('returns a 302 status and redirects to the correct location when `redirectTo` is a string', async () => {
357+
const response = await app.handle(
358+
new Request('http://localhost/redirect', {
359+
headers,
360+
}),
361+
);
362+
expect(response?.headers.get('location')).toBe('/base/home');
363+
expect(response?.status).toBe(302);
364+
});
365+
});
313366
});
314367
});
315368
});

0 commit comments

Comments
 (0)