Skip to content

Commit 0021d3c

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 f0a74e8 commit 0021d3c

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 { 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,
@@ -192,11 +198,16 @@ async function* handleRoute(options: {
192198
`Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`,
193199
};
194200
} else if (typeof redirectTo === 'string') {
195-
yield {
196-
...metadata,
197-
redirectTo: resolveRedirectTo(parentRoute, redirectTo),
198-
relativeRedirect: redirectTo[0] !== '/' ? true : undefined,
199-
};
201+
const { url, error } = resolveRedirectTo(parentRoute, redirectTo);
202+
if (error !== undefined) {
203+
yield { error: invalidRedirectError(metadata.route, error) };
204+
} else {
205+
yield {
206+
...metadata,
207+
redirectTo: url,
208+
relativeRedirect: redirectTo[0] !== '/' ? true : undefined,
209+
};
210+
}
200211
} else {
201212
yield metadata;
202213
}
@@ -426,7 +437,14 @@ async function* handleSSGRoute(
426437
}
427438

428439
if (redirectTo !== undefined) {
429-
meta.redirectTo = resolveRedirectTo(parentRoute, redirectTo);
440+
const { url, error } = resolveRedirectTo(parentRoute, redirectTo);
441+
if (error !== undefined) {
442+
yield { error: invalidRedirectError(currentRoutePath, error) };
443+
444+
return;
445+
}
446+
447+
meta.redirectTo = url;
430448
meta.relativeRedirect = redirectTo[0] !== '/' ? true : undefined;
431449
}
432450

@@ -479,16 +497,41 @@ async function* handleSSGRoute(
479497
.replace(URL_PARAMETER_GLOBAL_REGEXP, replacer)
480498
.replace(CATCH_ALL_REGEXP, replacer);
481499

500+
// A parameter value is only meaningful in the context of the route it is substituted into,
501+
// so the composed path is what gets normalized and validated. This also catches values that
502+
// only become unsafe once combined with the route, such as a protocol-relative `//`.
503+
const { url: route, error: routeError } = normalizeAndValidateRoutePath(
504+
addLeadingSlash(routeWithResolvedParams),
505+
);
506+
if (routeError !== undefined) {
507+
yield {
508+
error:
509+
`The '${stripLeadingSlash(currentRoutePath)}' route produced an invalid ` +
510+
`prerender path: ${routeError}`,
511+
};
512+
513+
continue;
514+
}
515+
516+
let resolvedRedirectTo: string | undefined;
517+
if (redirectTo !== undefined) {
518+
const { url, error } = resolveRedirectTo(
519+
parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, replacer),
520+
redirectTo,
521+
);
522+
if (error !== undefined) {
523+
yield { error: invalidRedirectError(routeWithResolvedParams, error) };
524+
525+
continue;
526+
}
527+
528+
resolvedRedirectTo = url;
529+
}
530+
482531
yield {
483532
...meta,
484-
route: routeWithResolvedParams,
485-
redirectTo:
486-
redirectTo === undefined
487-
? undefined
488-
: resolveRedirectTo(
489-
parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, replacer),
490-
redirectTo,
491-
),
533+
route,
534+
redirectTo: resolvedRedirectTo,
492535
};
493536
}
494537
} catch (error) {
@@ -535,7 +578,10 @@ function handlePrerenderParamsReplacement(
535578
);
536579
}
537580

538-
return parameterName === '**' ? `/${value}` : value;
581+
// The matched placeholder includes the separator, so it has to be re-added. The documented
582+
// value of a `**` parameter is a path, which may already carry it: re-adding it unconditionally
583+
// would compose a root catch-all into a protocol-relative `//foo/bar`.
584+
return parameterName === '**' ? addLeadingSlash(value) : value;
539585
};
540586
}
541587

@@ -547,20 +593,70 @@ function handlePrerenderParamsReplacement(
547593
* it is returned as is. If it is a relative path, it is resolved based on the parent route path,
548594
* and, like in the Angular router, its query and fragment are ignored.
549595
*
596+
* The resolved target is then normalized and validated, so that every `redirectTo` reaching the
597+
* route tree is a safe HTTP(S) URL or same-origin path.
598+
*
550599
* @param parentRoute - The path of the parent route.
551600
* @param redirectTo - The target path for redirection.
552-
* @returns The resolved redirect path as a string.
601+
* @returns The normalized redirect target, or the reason it was rejected.
553602
*/
554-
function resolveRedirectTo(parentRoute: string, redirectTo: string): string {
603+
function resolveRedirectTo(parentRoute: string, redirectTo: string): NormalizedUrl {
555604
if (redirectTo[0] === '/') {
556-
// If the redirectTo path is absolute, return it as is.
557-
return redirectTo;
605+
// If the redirectTo path is absolute, use it as is.
606+
return normalizeAndValidateRedirect(redirectTo);
558607
}
559608

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

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

566662
/**
@@ -603,12 +699,45 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
603699
continue;
604700
}
605701

702+
if (metadata.headers) {
703+
const { headers, error } = normalizeLocationHeaders(metadata.headers);
704+
if (error !== undefined) {
705+
errors.push(
706+
`Invalid '${path}' route configuration: the 'headers.Location' value is invalid: ${error}`,
707+
);
708+
continue;
709+
}
710+
711+
metadata.headers = headers;
712+
}
713+
606714
serverConfigRouteTree.insert(path, metadata);
607715
}
608716

609717
return { serverConfigRouteTree, errors };
610718
}
611719

720+
/**
721+
* Builds the key used to de-duplicate an extracted route.
722+
*
723+
* `RouteTree` percent-decodes every segment when a route is inserted, so two route paths which
724+
* differ only in their encoding land on the same node, where the later one overwrites the earlier.
725+
* De-duplication therefore runs on the decoded path rather than on the path as it was written:
726+
* a prerendered path is normalized by the URL parser while the path of every other render mode is
727+
* not, so the same route can be reached in both an encoded and an unencoded form.
728+
*
729+
* @param route - The route path to build a key for.
730+
* @returns The decoded route path.
731+
*/
732+
function routeDeduplicationKey(route: string): string {
733+
try {
734+
return route.split('/').map(decodeURIComponent).join('/');
735+
} catch {
736+
// A malformed percent escape is reported when the route is inserted into the route tree.
737+
return route;
738+
}
739+
}
740+
612741
/**
613742
* Retrieves routes from the given Angular application.
614743
*
@@ -731,7 +860,7 @@ export async function getRoutesFromAngularRouterConfig(
731860

732861
// If a result already exists for the exact same route, subsequent matches should be ignored.
733862
// This aligns with Angular's app router behavior, which prioritizes the first route.
734-
const routePath = routeMetadata.route;
863+
const routePath = routeDeduplicationKey(routeMetadata.route);
735864
if (!seenRoutes.has(routePath)) {
736865
routesResults.push(routeMetadata);
737866
seenRoutes.add(routePath);

0 commit comments

Comments
 (0)