Skip to content

Commit fe26c6b

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 0fd4404 commit fe26c6b

7 files changed

Lines changed: 1041 additions & 28 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: 152 additions & 23 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 { 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, joinUrlParts, stripLeadingSlash } from '../utils/url';
3541
import {
3642
PrerenderFallback,
3743
RenderMode,
@@ -200,11 +206,16 @@ async function* handleRoute(options: {
200206
`Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`,
201207
};
202208
} else if (typeof redirectTo === 'string') {
203-
parentRoutes.set(metadata.route, {
204-
...metadata,
205-
redirectTo: resolveRedirectTo(parentRoute, redirectTo),
206-
relativeRedirect: redirectTo[0] !== '/' ? true : undefined,
207-
});
209+
const { url, error } = resolveRedirectTo(parentRoute, redirectTo);
210+
if (error !== undefined) {
211+
yield { error: invalidRedirectError(metadata.route, error) };
212+
} else {
213+
parentRoutes.set(metadata.route, {
214+
...metadata,
215+
redirectTo: url,
216+
relativeRedirect: redirectTo[0] !== '/' ? true : undefined,
217+
});
218+
}
208219
} else {
209220
parentRoutes.set(metadata.route, metadata);
210221
}
@@ -452,7 +463,14 @@ async function* handleSSGRoute(
452463
}
453464

454465
if (redirectTo !== undefined) {
455-
meta.redirectTo = resolveRedirectTo(parentRoute, redirectTo);
466+
const { url, error } = resolveRedirectTo(parentRoute, redirectTo);
467+
if (error !== undefined) {
468+
yield { error: invalidRedirectError(currentRoutePath, error) };
469+
470+
return;
471+
}
472+
473+
meta.redirectTo = url;
456474
meta.relativeRedirect = redirectTo[0] !== '/' ? true : undefined;
457475
}
458476

@@ -505,16 +523,41 @@ async function* handleSSGRoute(
505523
.replace(URL_PARAMETER_GLOBAL_REGEXP, replacer)
506524
.replace(CATCH_ALL_REGEXP, replacer);
507525

526+
// A parameter value is only meaningful in the context of the route it is substituted into,
527+
// so the composed path is what gets normalized and validated. This also catches values that
528+
// only become unsafe once combined with the route, such as a protocol-relative `//`.
529+
const { url: route, error: routeError } = normalizeAndValidateRoutePath(
530+
addLeadingSlash(routeWithResolvedParams),
531+
);
532+
if (routeError !== undefined) {
533+
yield {
534+
error:
535+
`The '${stripLeadingSlash(currentRoutePath)}' route produced an invalid ` +
536+
`prerender path: ${routeError}`,
537+
};
538+
539+
continue;
540+
}
541+
542+
let resolvedRedirectTo: string | undefined;
543+
if (redirectTo !== undefined) {
544+
const { url, error } = resolveRedirectTo(
545+
parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, replacer),
546+
redirectTo,
547+
);
548+
if (error !== undefined) {
549+
yield { error: invalidRedirectError(routeWithResolvedParams, error) };
550+
551+
continue;
552+
}
553+
554+
resolvedRedirectTo = url;
555+
}
556+
508557
yield {
509558
...meta,
510-
route: routeWithResolvedParams,
511-
redirectTo:
512-
redirectTo === undefined
513-
? undefined
514-
: resolveRedirectTo(
515-
parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, replacer),
516-
redirectTo,
517-
),
559+
route,
560+
redirectTo: resolvedRedirectTo,
518561
};
519562
}
520563
} catch (error) {
@@ -561,7 +604,10 @@ function handlePrerenderParamsReplacement(
561604
);
562605
}
563606

564-
return parameterName === '**' ? `/${value}` : value;
607+
// The matched placeholder includes the separator, so it has to be re-added. The documented
608+
// value of a `**` parameter is a path, which may already carry it: re-adding it unconditionally
609+
// would compose a root catch-all into a protocol-relative `//foo/bar`.
610+
return parameterName === '**' ? addLeadingSlash(value) : value;
565611
};
566612
}
567613

@@ -573,20 +619,70 @@ function handlePrerenderParamsReplacement(
573619
* it is returned as is. If it is a relative path, it is resolved based on the parent route path,
574620
* and, like in the Angular router, its query and fragment are ignored.
575621
*
622+
* The resolved target is then normalized and validated, so that every `redirectTo` reaching the
623+
* route tree is a safe HTTP(S) URL or same-origin path.
624+
*
576625
* @param parentRoute - The path of the parent route.
577626
* @param redirectTo - The target path for redirection.
578-
* @returns The resolved redirect path as a string.
627+
* @returns The normalized redirect target, or the reason it was rejected.
579628
*/
580-
function resolveRedirectTo(parentRoute: string, redirectTo: string): string {
629+
function resolveRedirectTo(parentRoute: string, redirectTo: string): NormalizedUrl {
581630
if (redirectTo[0] === '/') {
582-
// If the redirectTo path is absolute, return it as is.
583-
return redirectTo;
631+
// If the redirectTo path is absolute, use it as is.
632+
return normalizeAndValidateRedirect(redirectTo);
584633
}
585634

586635
// Match Angular router behavior: replace all segments matched by the route.
587636
const [path] = redirectTo.split(/[?#]/, 1);
588637

589-
return joinUrlParts(parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, '*'), path);
638+
return normalizeAndValidateRedirect(
639+
joinUrlParts(parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, '*'), path),
640+
);
641+
}
642+
643+
/**
644+
* Builds the diagnostic emitted when a route's `redirectTo` cannot be normalized.
645+
*
646+
* @param routePath - The route the `redirectTo` is defined on.
647+
* @param reason - The reason reported by the redirect normalization.
648+
* @returns The error message to report.
649+
*/
650+
function invalidRedirectError(routePath: string, reason: string): string {
651+
return `The 'redirectTo' value for the '${stripLeadingSlash(routePath)}' route is invalid: ${reason}`;
652+
}
653+
654+
/**
655+
* Validates and normalizes the `Location` header of a server route configuration.
656+
*
657+
* A configured `Location` header turns the route into a redirect, both at runtime and in the static
658+
* redirect page generated when prerendering, so its value is validated as a redirect target. HTTP
659+
* header names are case-insensitive, so every entry which names the `Location` header is checked.
660+
*
661+
* @param headers - The headers configured for the route.
662+
* @returns The headers with each `Location` value normalized, or the reason one was rejected.
663+
*/
664+
function normalizeLocationHeaders(
665+
headers: Record<string, string>,
666+
): { headers: Record<string, string>; error?: undefined } | { headers?: undefined; error: string } {
667+
let normalized: Record<string, string> | undefined;
668+
669+
for (const [name, value] of Object.entries(headers)) {
670+
if (name.toLowerCase() !== 'location') {
671+
continue;
672+
}
673+
674+
const { url, error } = normalizeAndValidateRedirect(value);
675+
if (error !== undefined) {
676+
return { error };
677+
}
678+
679+
// Store the normalized value so that the header sent at runtime and the generated static
680+
// redirect page are the value which was validated.
681+
normalized ??= { ...headers };
682+
normalized[name] = url;
683+
}
684+
685+
return { headers: normalized ?? headers };
590686
}
591687

592688
/**
@@ -629,12 +725,45 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
629725
continue;
630726
}
631727

728+
if (metadata.headers) {
729+
const { headers, error } = normalizeLocationHeaders(metadata.headers);
730+
if (error !== undefined) {
731+
errors.push(
732+
`Invalid '${path}' route configuration: the 'headers.Location' value is invalid: ${error}`,
733+
);
734+
continue;
735+
}
736+
737+
metadata.headers = headers;
738+
}
739+
632740
serverConfigRouteTree.insert(path, metadata);
633741
}
634742

635743
return { serverConfigRouteTree, errors };
636744
}
637745

746+
/**
747+
* Builds the key used to de-duplicate an extracted route.
748+
*
749+
* `RouteTree` percent-decodes every segment when a route is inserted, so two route paths which
750+
* differ only in their encoding land on the same node, where the later one overwrites the earlier.
751+
* De-duplication therefore runs on the decoded path rather than on the path as it was written:
752+
* a prerendered path is normalized by the URL parser while the path of every other render mode is
753+
* not, so the same route can be reached in both an encoded and an unencoded form.
754+
*
755+
* @param route - The route path to build a key for.
756+
* @returns The decoded route path.
757+
*/
758+
function routeDeduplicationKey(route: string): string {
759+
try {
760+
return route.split('/').map(decodeURIComponent).join('/');
761+
} catch {
762+
// A malformed percent escape is reported when the route is inserted into the route tree.
763+
return route;
764+
}
765+
}
766+
638767
/**
639768
* Retrieves routes from the given Angular application.
640769
*
@@ -757,7 +886,7 @@ export async function getRoutesFromAngularRouterConfig(
757886

758887
// If a result already exists for the exact same route, subsequent matches should be ignored.
759888
// This aligns with Angular's app router behavior, which prioritizes the first route.
760-
const routePath = routeMetadata.route;
889+
const routePath = routeDeduplicationKey(routeMetadata.route);
761890
if (!seenRoutes.has(routePath)) {
762891
routesResults.push(routeMetadata);
763892
seenRoutes.add(routePath);

0 commit comments

Comments
 (0)