Skip to content

Commit db6e7d8

Browse files
committed
fix(@angular/build): escape prerender redirect URLs
Escape prerender redirect targets before embedding them in generated static redirect pages. This prevents attacker-controlled redirect URLs from breaking out of the meta refresh, anchor href, or fallback text contexts and injecting HTML that could lead to XSS.
1 parent 60481e9 commit db6e7d8

2 files changed

Lines changed: 36 additions & 5 deletions

File tree

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

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

25+
const htmlEscapeCharacters: Record<string, string> = {
26+
'&': '&amp;',
27+
'<': '&lt;',
28+
'>': '&gt;',
29+
'"': '&quot;',
30+
"'": '&#39;',
31+
};
32+
33+
function escapeHtml(text: string): string {
34+
return text.replace(/[&<>"']/g, (character) => htmlEscapeCharacters[character]);
35+
}
36+
2537
/**
2638
* Generates a static HTML page with a meta refresh tag to redirect the user to a specified URL.
2739
*
@@ -32,16 +44,18 @@ export function isSsrRequestHandler(
3244
* @returns The HTML content of the static redirect page.
3345
*/
3446
export function generateRedirectStaticPage(url: string): string {
47+
const escapedUrl = escapeHtml(url);
48+
3549
return `
3650
<!DOCTYPE html>
3751
<html>
3852
<head>
3953
<meta charset="utf-8">
4054
<title>Redirecting</title>
41-
<meta http-equiv="refresh" content="0; url=${url}">
55+
<meta http-equiv="refresh" content="0; url=${escapedUrl}">
4256
</head>
4357
<body>
44-
<pre>Redirecting to <a href="${url}">${url}</a></pre>
58+
<pre>Redirecting to <a href="${escapedUrl}">${escapedUrl}</a></pre>
4559
</body>
4660
</html>
4761
`.trim();

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import assert from 'node:assert';
44
import {
55
expectFileNotToExist,
66
expectFileToMatch,
7+
readFile,
78
replaceInFile,
89
writeFile,
910
} from '../../../utils/fs';
@@ -48,6 +49,10 @@ export default async function () {
4849
path: 'ssg-redirect',
4950
redirectTo: 'ssg'
5051
},
52+
{
53+
path: 'ssg-redirect-xss',
54+
redirectTo: '/ssg"><script>alert(1)</script>&q=x'
55+
},
5156
{
5257
path: 'ssg-redirect-via-guard',
5358
canActivate: [() => {
@@ -108,21 +113,33 @@ export default async function () {
108113
await replaceInFile('src/app/app.routes.server.ts', 'RenderMode.Server', 'RenderMode.Prerender');
109114
await noSilentNg('build', '--output-mode=static');
110115

111-
const expects: Record<string, RegExp | string> = {
116+
const escapedXssRedirectUrl = '/ssg&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;&amp;q=x';
117+
const expects: Record<string, RegExp | string | (RegExp | string)[]> = {
112118
'index.html': /ng-server-context="ssg".+home works!/,
113119
'ssg/index.html': /ng-server-context="ssg".+ssg works!/,
114120
'ssg/one/index.html': /ng-server-context="ssg".+ssg-with-params works!/,
115121
'ssg/two/index.html': /ng-server-context="ssg".+ssg-with-params works!/,
116122
// When static redirects are generated as meta tags.
117123
'ssg-redirect/index.html': '<meta http-equiv="refresh" content="0; url=/ssg">',
124+
'ssg-redirect-xss/index.html': [
125+
`<meta http-equiv="refresh" content="0; url=${escapedXssRedirectUrl}">`,
126+
`<a href="${escapedXssRedirectUrl}">${escapedXssRedirectUrl}</a>`,
127+
],
118128
'ssg-redirect-via-guard/index.html':
119129
'<meta http-equiv="refresh" content="0; url=/ssg?foo=bar">',
120130
};
121131

122-
for (const [filePath, fileMatch] of Object.entries(expects)) {
123-
await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch);
132+
for (const [filePath, fileMatches] of Object.entries(expects)) {
133+
for (const fileMatch of Array.isArray(fileMatches) ? fileMatches : [fileMatches]) {
134+
await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch);
135+
}
124136
}
125137

138+
const xssRedirectHtml = await readFile(
139+
join('dist/test-project/browser', 'ssg-redirect-xss/index.html'),
140+
);
141+
assert.doesNotMatch(xssRedirectHtml, /<script>alert\(1\)<\/script>/);
142+
126143
// Check that server directory does not exist
127144
assert(
128145
!existsSync('dist/test-project/server'),

0 commit comments

Comments
 (0)