Skip to content

Commit 4dac5f2

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
1 parent 59319b8 commit 4dac5f2

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
@@ -269,19 +269,5 @@ 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-
});
286272
});
287273
});

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';
@@ -124,6 +125,14 @@ describe('AngularServerApp', () => {
124125
hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde',
125126
},
126127
},
128+
undefined,
129+
undefined,
130+
[
131+
{
132+
provide: APP_BASE_HREF,
133+
useFactory: () => inject(REQUEST)?.headers.get('X-Forwarded-Prefix'),
134+
},
135+
],
127136
);
128137

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

0 commit comments

Comments
 (0)