Skip to content

Commit 223891d

Browse files
SkyZeroZxalan-agius4
authored andcommitted
fix(@angular/build): escape prerender redirect URLs
Escape redirect targets before embedding them in the meta refresh tag and the fallback link of generated static redirect pages.
1 parent 902b564 commit 223891d

3 files changed

Lines changed: 75 additions & 2 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
@@ -20,26 +20,53 @@ export function isSsrRequestHandler(
2020
return typeof value === 'function' && '__ng_request_handler__' in value;
2121
}
2222

23+
/**
24+
* A mapping of the characters which have to be escaped to be interpolated into HTML,
25+
* to their entity equivalents.
26+
*/
27+
const HTML_ESCAPE_CHARACTER_MAP: Record<string, string> = {
28+
'&': '&amp;',
29+
'<': '&lt;',
30+
'>': '&gt;',
31+
'"': '&quot;',
32+
"'": '&#39;',
33+
};
34+
35+
/**
36+
* Escapes the characters of a value which is interpolated into HTML text or into a quoted
37+
* attribute value.
38+
*
39+
* @param text - The value to escape.
40+
* @returns The escaped value.
41+
*/
42+
function escapeHtml(text: string): string {
43+
return text.replace(/[&<>"']/g, (character) => HTML_ESCAPE_CHARACTER_MAP[character]);
44+
}
45+
2346
/**
2447
* Generates a static HTML page with a meta refresh tag to redirect the user to a specified URL.
2548
*
2649
* This function creates a simple HTML page that performs a redirect using a meta tag.
2750
* It includes a fallback link in case the meta-refresh doesn't work.
2851
*
52+
* The provided URL is HTML-escaped before being interpolated.
53+
*
2954
* @param url - The URL to which the page should redirect.
3055
* @returns The HTML content of the static redirect page.
3156
*/
3257
export function generateRedirectStaticPage(url: string): string {
58+
const escapedUrl = escapeHtml(url);
59+
3360
return `
3461
<!DOCTYPE html>
3562
<html>
3663
<head>
3764
<meta charset="utf-8">
3865
<title>Redirecting</title>
39-
<meta http-equiv="refresh" content="0; url=${url}">
66+
<meta http-equiv="refresh" content="0; url=${escapedUrl}">
4067
</head>
4168
<body>
42-
<pre>Redirecting to <a href="${url}">${url}</a></pre>
69+
<pre>Redirecting to <a href="${escapedUrl}">${escapedUrl}</a></pre>
4370
</body>
4471
</html>
4572
`.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+
});

‎tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ export default async function () {
4848
path: 'ssg-redirect',
4949
redirectTo: 'ssg'
5050
},
51+
{
52+
path: 'ssg-redirect-external',
53+
component: Ssg,
54+
},
5155
{
5256
path: 'ssg-redirect-via-guard',
5357
canActivate: [() => {
@@ -73,6 +77,11 @@ export default async function () {
7377
import { RenderMode, ServerRoute } from '@angular/ssr';
7478
7579
export const serverRoutes: ServerRoute[] = [
80+
{
81+
path: 'ssg-redirect-external',
82+
renderMode: RenderMode.Prerender,
83+
headers: { Location: 'https://example.com/docs?from=ssg&next=/ssg' },
84+
},
7685
{
7786
path: 'ssg/:id',
7887
renderMode: RenderMode.Prerender,
@@ -115,6 +124,9 @@ export default async function () {
115124
'ssg/two/index.html': /ng-server-context="ssg".+ssg-with-params works!/,
116125
// When static redirects are generated as meta tags.
117126
'ssg-redirect/index.html': '<meta http-equiv="refresh" content="0; url=/ssg">',
127+
// The target of a 'Location' header is HTML escaped before it is written to the page.
128+
'ssg-redirect-external/index.html':
129+
'<meta http-equiv="refresh" content="0; url=https://example.com/docs?from=ssg&amp;next=/ssg">',
118130
'ssg-redirect-via-guard/index.html':
119131
'<meta http-equiv="refresh" content="0; url=/ssg?foo=bar">',
120132
};

0 commit comments

Comments
 (0)