Skip to content

Commit 3ea2fbf

Browse files
committed
fix(@angular/build): escape prerender redirect URLs
Escape redirect targets before embedding them in generated meta refresh pages and fallback links. Centralize WHATWG URL normalization in @angular/ssr for composed prerender paths, configured redirects, Location headers, and runtime redirects. Preserve documented catch-all parameter values, reject unsafe schemes and path forms, and cover validation failures with focused unit tests.
1 parent 55583c4 commit 3ea2fbf

7 files changed

Lines changed: 1035 additions & 24 deletions

File tree

packages/angular/build/src/utils/server-rendering/utils.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,53 @@ export function isSsrRequestHandler(
2222
return typeof value === 'function' && '__ng_request_handler__' in value;
2323
}
2424

25+
/**
26+
* A mapping of the characters which have to be escaped to be interpolated into HTML,
27+
* to their entity equivalents.
28+
*/
29+
const HTML_ESCAPE_CHARACTER_MAP: Record<string, string> = {
30+
'&': '&amp;',
31+
'<': '&lt;',
32+
'>': '&gt;',
33+
'"': '&quot;',
34+
"'": '&#39;',
35+
};
36+
37+
/**
38+
* Escapes the characters of a value which is interpolated into HTML text or into a quoted
39+
* attribute value.
40+
*
41+
* @param text - The value to escape.
42+
* @returns The escaped value.
43+
*/
44+
function escapeHtml(text: string): string {
45+
return text.replace(/[&<>"']/g, (character) => HTML_ESCAPE_CHARACTER_MAP[character]);
46+
}
47+
2548
/**
2649
* Generates a static HTML page with a meta refresh tag to redirect the user to a specified URL.
2750
*
2851
* This function creates a simple HTML page that performs a redirect using a meta tag.
2952
* It includes a fallback link in case the meta-refresh doesn't work.
3053
*
54+
* The provided URL is HTML-escaped before being interpolated.
55+
*
3156
* @param url - The URL to which the page should redirect.
3257
* @returns The HTML content of the static redirect page.
3358
*/
3459
export function generateRedirectStaticPage(url: string): string {
60+
const escapedUrl = escapeHtml(url);
61+
3562
return `
3663
<!DOCTYPE html>
3764
<html>
3865
<head>
3966
<meta charset="utf-8">
4067
<title>Redirecting</title>
41-
<meta http-equiv="refresh" content="0; url=${url}">
68+
<meta http-equiv="refresh" content="0; url=${escapedUrl}">
4269
</head>
4370
<body>
44-
<pre>Redirecting to <a href="${url}">${url}</a></pre>
71+
<pre>Redirecting to <a href="${escapedUrl}">${escapedUrl}</a></pre>
4572
</body>
4673
</html>
4774
`.trim();
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { generateRedirectStaticPage } from './utils';
10+
11+
describe('generateRedirectStaticPage', () => {
12+
it('escapes the ampersands of a redirect target', () => {
13+
const page = generateRedirectStaticPage('https://example.com/docs?from=ssg&next=/ssg');
14+
15+
expect(page).toContain(
16+
'<meta http-equiv="refresh" content="0; url=https://example.com/docs?from=ssg&amp;next=/ssg">',
17+
);
18+
expect(page).toContain(
19+
'<a href="https://example.com/docs?from=ssg&amp;next=/ssg">' +
20+
'https://example.com/docs?from=ssg&amp;next=/ssg</a>',
21+
);
22+
});
23+
24+
it('escapes characters which would break out of the attribute or the tag', () => {
25+
const page = generateRedirectStaticPage(`/"><script>alert('1')</script>`);
26+
27+
expect(page).not.toContain('<script>');
28+
expect(page).toContain(
29+
'<meta http-equiv="refresh" content="0; url=' +
30+
'/&quot;&gt;&lt;script&gt;alert(&#39;1&#39;)&lt;/script&gt;">',
31+
);
32+
expect(page).toContain('<a href="/&quot;&gt;&lt;script&gt;alert(&#39;1&#39;)&lt;/script&gt;">');
33+
});
34+
});

packages/angular/ssr/src/routes/ng-routes.ts

Lines changed: 146 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,14 @@ import { Console } from '../console';
3030
import { AngularAppManifest, getAngularAppManifest } from '../manifest';
3131
import { AngularBootstrap, isNgModule } from '../utils/ng';
3232
import { promiseWithAbort } from '../utils/promise';
33-
import { VALID_REDIRECT_RESPONSE_CODES, isValidRedirectResponseCode } from '../utils/redirect';
34-
import { addTrailingSlash, joinUrlParts, stripLeadingSlash } from '../utils/url';
33+
import {
34+
NormalizedUrl,
35+
VALID_REDIRECT_RESPONSE_CODES,
36+
isValidRedirectResponseCode,
37+
normalizeAndValidateRedirect,
38+
normalizeAndValidateRoutePath,
39+
} from '../utils/redirect';
40+
import { addLeadingSlash, addTrailingSlash, joinUrlParts, stripLeadingSlash } from '../utils/url';
3541
import {
3642
PrerenderFallback,
3743
RenderMode,
@@ -189,10 +195,15 @@ async function* handleRoute(options: {
189195
`Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`,
190196
};
191197
} else if (typeof redirectTo === 'string') {
192-
yield {
193-
...metadata,
194-
redirectTo: resolveRedirectTo(metadata.route, redirectTo),
195-
};
198+
const { url, error } = resolveRedirectTo(metadata.route, redirectTo);
199+
if (error !== undefined) {
200+
yield { error: invalidRedirectError(metadata.route, error) };
201+
} else {
202+
yield {
203+
...metadata,
204+
redirectTo: url,
205+
};
206+
}
196207
} else {
197208
yield metadata;
198209
}
@@ -420,7 +431,14 @@ async function* handleSSGRoute(
420431
}
421432

422433
if (redirectTo !== undefined) {
423-
meta.redirectTo = resolveRedirectTo(currentRoutePath, redirectTo);
434+
const { url, error } = resolveRedirectTo(currentRoutePath, redirectTo);
435+
if (error !== undefined) {
436+
yield { error: invalidRedirectError(currentRoutePath, error) };
437+
438+
return;
439+
}
440+
441+
meta.redirectTo = url;
424442
}
425443

426444
const isCatchAllRoute = CATCH_ALL_REGEXP.test(currentRoutePath);
@@ -472,13 +490,38 @@ async function* handleSSGRoute(
472490
.replace(URL_PARAMETER_GLOBAL_REGEXP, replacer)
473491
.replace(CATCH_ALL_REGEXP, replacer);
474492

493+
// A parameter value is only meaningful in the context of the route it is substituted into,
494+
// so the composed path is what gets normalized and validated. This also catches values that
495+
// only become unsafe once combined with the route, such as a protocol-relative `//`.
496+
const { url: route, error: routeError } = normalizeAndValidateRoutePath(
497+
addLeadingSlash(routeWithResolvedParams),
498+
);
499+
if (routeError !== undefined) {
500+
yield {
501+
error:
502+
`The '${stripLeadingSlash(currentRoutePath)}' route produced an invalid ` +
503+
`prerender path: ${routeError}`,
504+
};
505+
506+
continue;
507+
}
508+
509+
let resolvedRedirectTo: string | undefined;
510+
if (redirectTo !== undefined) {
511+
const { url, error } = resolveRedirectTo(routeWithResolvedParams, redirectTo);
512+
if (error !== undefined) {
513+
yield { error: invalidRedirectError(routeWithResolvedParams, error) };
514+
515+
continue;
516+
}
517+
518+
resolvedRedirectTo = url;
519+
}
520+
475521
yield {
476522
...meta,
477-
route: routeWithResolvedParams,
478-
redirectTo:
479-
redirectTo === undefined
480-
? undefined
481-
: resolveRedirectTo(routeWithResolvedParams, redirectTo),
523+
route,
524+
redirectTo: resolvedRedirectTo,
482525
};
483526
}
484527
} catch (error) {
@@ -525,7 +568,10 @@ function handlePrerenderParamsReplacement(
525568
);
526569
}
527570

528-
return parameterName === '**' ? `/${value}` : value;
571+
// The matched placeholder includes the separator, so it has to be re-added. The documented
572+
// value of a `**` parameter is a path, which may already carry it: re-adding it unconditionally
573+
// would compose a root catch-all into a protocol-relative `//foo/bar`.
574+
return parameterName === '**' ? addLeadingSlash(value) : value;
529575
};
530576
}
531577

@@ -536,21 +582,69 @@ function handlePrerenderParamsReplacement(
536582
* resolves relative to the current route path. If `redirectTo` is an absolute path,
537583
* it is returned as is. If it is a relative path, it is resolved based on the current route path.
538584
*
585+
* The resolved target is then normalized and validated, so that every `redirectTo` reaching the
586+
* route tree is a safe HTTP(S) URL or same-origin path.
587+
*
539588
* @param routePath - The current route path.
540589
* @param redirectTo - The target path for redirection.
541-
* @returns The resolved redirect path as a string.
590+
* @returns The normalized redirect target, or the reason it was rejected.
542591
*/
543-
function resolveRedirectTo(routePath: string, redirectTo: string): string {
592+
function resolveRedirectTo(routePath: string, redirectTo: string): NormalizedUrl {
544593
if (redirectTo[0] === '/') {
545-
// If the redirectTo path is absolute, return it as is.
546-
return redirectTo;
594+
// If the redirectTo path is absolute, use it as is.
595+
return normalizeAndValidateRedirect(redirectTo);
547596
}
548597

549598
// Resolve relative redirectTo based on the current route path.
550599
const segments = routePath.replace(URL_PARAMETER_GLOBAL_REGEXP, '*').split('/');
551600
segments.pop(); // Remove the last segment to make it relative.
552601

553-
return joinUrlParts(...segments, redirectTo);
602+
return normalizeAndValidateRedirect(joinUrlParts(...segments, redirectTo));
603+
}
604+
605+
/**
606+
* Builds the diagnostic emitted when a route's `redirectTo` cannot be normalized.
607+
*
608+
* @param routePath - The route the `redirectTo` is defined on.
609+
* @param reason - The reason reported by the redirect normalization.
610+
* @returns The error message to report.
611+
*/
612+
function invalidRedirectError(routePath: string, reason: string): string {
613+
return `The 'redirectTo' value for the '${stripLeadingSlash(routePath)}' route is invalid: ${reason}`;
614+
}
615+
616+
/**
617+
* Validates and normalizes the `Location` header of a server route configuration.
618+
*
619+
* A configured `Location` header turns the route into a redirect, both at runtime and in the static
620+
* redirect page generated when prerendering, so its value is validated as a redirect target. HTTP
621+
* header names are case-insensitive, so every entry which names the `Location` header is checked.
622+
*
623+
* @param headers - The headers configured for the route.
624+
* @returns The headers with each `Location` value normalized, or the reason one was rejected.
625+
*/
626+
function normalizeLocationHeaders(
627+
headers: Record<string, string>,
628+
): { headers: Record<string, string>; error?: undefined } | { headers?: undefined; error: string } {
629+
let normalized: Record<string, string> | undefined;
630+
631+
for (const [name, value] of Object.entries(headers)) {
632+
if (name.toLowerCase() !== 'location') {
633+
continue;
634+
}
635+
636+
const { url, error } = normalizeAndValidateRedirect(value);
637+
if (error !== undefined) {
638+
return { error };
639+
}
640+
641+
// Store the normalized value so that the header sent at runtime and the generated static
642+
// redirect page are the value which was validated.
643+
normalized ??= { ...headers };
644+
normalized[name] = url;
645+
}
646+
647+
return { headers: normalized ?? headers };
554648
}
555649

556650
/**
@@ -593,12 +687,45 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
593687
continue;
594688
}
595689

690+
if (metadata.headers) {
691+
const { headers, error } = normalizeLocationHeaders(metadata.headers);
692+
if (error !== undefined) {
693+
errors.push(
694+
`Invalid '${path}' route configuration: the 'headers.Location' value is invalid: ${error}`,
695+
);
696+
continue;
697+
}
698+
699+
metadata.headers = headers;
700+
}
701+
596702
serverConfigRouteTree.insert(path, metadata);
597703
}
598704

599705
return { serverConfigRouteTree, errors };
600706
}
601707

708+
/**
709+
* Builds the key used to de-duplicate an extracted route.
710+
*
711+
* `RouteTree` percent-decodes every segment when a route is inserted, so two route paths which
712+
* differ only in their encoding land on the same node, where the later one overwrites the earlier.
713+
* De-duplication therefore runs on the decoded path rather than on the path as it was written:
714+
* a prerendered path is normalized by the URL parser while the path of every other render mode is
715+
* not, so the same route can be reached in both an encoded and an unencoded form.
716+
*
717+
* @param route - The route path to build a key for.
718+
* @returns The decoded route path.
719+
*/
720+
function routeDeduplicationKey(route: string): string {
721+
try {
722+
return route.split('/').map(decodeURIComponent).join('/');
723+
} catch {
724+
// A malformed percent escape is reported when the route is inserted into the route tree.
725+
return route;
726+
}
727+
}
728+
602729
/**
603730
* Retrieves routes from the given Angular application.
604731
*
@@ -721,7 +848,7 @@ export async function getRoutesFromAngularRouterConfig(
721848

722849
// If a result already exists for the exact same route, subsequent matches should be ignored.
723850
// This aligns with Angular's app router behavior, which prioritizes the first route.
724-
const routePath = routeMetadata.route;
851+
const routePath = routeDeduplicationKey(routeMetadata.route);
725852
if (!seenRoutes.has(routePath)) {
726853
routesResults.push(routeMetadata);
727854
seenRoutes.add(routePath);

0 commit comments

Comments
 (0)