Skip to content

Commit 21c6f37

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 21c6f37

3 files changed

Lines changed: 147 additions & 6 deletions

File tree

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,17 @@ async function renderPages(
253253
const outPath = stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html'));
254254

255255
if (typeof redirectTo === 'string') {
256-
output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false };
256+
try {
257+
output[outPath] = {
258+
content: generateRedirectStaticPage(redirectTo),
259+
appShellRoute: false,
260+
};
261+
} catch (err) {
262+
assertIsError(err);
263+
errors.push(
264+
`An error occurred while prerendering route '${route}'.\n\n${err.stack ?? err.message ?? err.code ?? err}`,
265+
);
266+
}
257267

258268
continue;
259269
}

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

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,56 @@ 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+
const urlSchemeRegex = /^(?:[a-zA-Z][a-zA-Z0-9+\-.]*:)/;
33+
const invalidRedirectUrlCharacters = /[\u0000-\u0020\u007F\\]/;
34+
const allowedRedirectProtocols = new Set(['http:', 'https:']);
35+
36+
function escapeHtml(text: string): string {
37+
return text.replace(/[&<>"']/g, (character) => htmlEscapeCharacters[character]);
38+
}
39+
40+
function validateRedirectUrl(url: string): string {
41+
if (invalidRedirectUrlCharacters.test(url)) {
42+
throw new Error(
43+
'Invalid redirect URL. Static redirects only support HTTP(S) URLs and same-origin absolute paths.',
44+
);
45+
}
46+
47+
if (url.startsWith('/') && !url.startsWith('//')) {
48+
return url;
49+
}
50+
51+
if (!urlSchemeRegex.test(url)) {
52+
throw new Error(
53+
'Invalid redirect URL. Static redirects only support HTTP(S) URLs and same-origin absolute paths.',
54+
);
55+
}
56+
57+
let parsedUrl: URL;
58+
try {
59+
parsedUrl = new URL(url);
60+
} catch {
61+
throw new Error(
62+
'Invalid redirect URL. Static redirects only support HTTP(S) URLs and same-origin absolute paths.',
63+
);
64+
}
65+
66+
if (!allowedRedirectProtocols.has(parsedUrl.protocol)) {
67+
throw new Error(
68+
`Unsupported redirect URL protocol "${parsedUrl.protocol}". Static redirects only support HTTP(S) URLs and same-origin absolute paths.`,
69+
);
70+
}
71+
72+
return url;
73+
}
74+
2575
/**
2676
* Generates a static HTML page with a meta refresh tag to redirect the user to a specified URL.
2777
*
@@ -32,16 +82,19 @@ export function isSsrRequestHandler(
3282
* @returns The HTML content of the static redirect page.
3383
*/
3484
export function generateRedirectStaticPage(url: string): string {
85+
const safeUrl = validateRedirectUrl(url);
86+
const escapedUrl = escapeHtml(safeUrl);
87+
3588
return `
3689
<!DOCTYPE html>
3790
<html>
3891
<head>
3992
<meta charset="utf-8">
4093
<title>Redirecting</title>
41-
<meta http-equiv="refresh" content="0; url=${url}">
94+
<meta http-equiv="refresh" content="0; url=${escapedUrl}">
4295
</head>
4396
<body>
44-
<pre>Redirecting to <a href="${url}">${url}</a></pre>
97+
<pre>Redirecting to <a href="${escapedUrl}">${escapedUrl}</a></pre>
4598
</body>
4699
</html>
47100
`.trim();

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

Lines changed: 81 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,18 @@ 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+
},
56+
{
57+
path: 'ssg-redirect-external',
58+
component: Ssg,
59+
},
60+
{
61+
path: 'ssg-redirect-unsafe-url',
62+
component: Ssg,
63+
},
5164
{
5265
path: 'ssg-redirect-via-guard',
5366
canActivate: [() => {
@@ -73,6 +86,11 @@ export default async function () {
7386
import { RenderMode, ServerRoute } from '@angular/ssr';
7487
7588
export const serverRoutes: ServerRoute[] = [
89+
{
90+
path: 'ssg-redirect-external',
91+
renderMode: RenderMode.Prerender,
92+
headers: { Location: 'https://example.com/docs?from=ssg' },
93+
},
7694
{
7795
path: 'ssg/:id',
7896
renderMode: RenderMode.Prerender,
@@ -108,21 +126,81 @@ export default async function () {
108126
await replaceInFile('src/app/app.routes.server.ts', 'RenderMode.Server', 'RenderMode.Prerender');
109127
await noSilentNg('build', '--output-mode=static');
110128

111-
const expects: Record<string, RegExp | string> = {
129+
const escapedXssRedirectUrl = '/ssg&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;&amp;q=x';
130+
const expects: Record<string, RegExp | string | (RegExp | string)[]> = {
112131
'index.html': /ng-server-context="ssg".+home works!/,
113132
'ssg/index.html': /ng-server-context="ssg".+ssg works!/,
114133
'ssg/one/index.html': /ng-server-context="ssg".+ssg-with-params works!/,
115134
'ssg/two/index.html': /ng-server-context="ssg".+ssg-with-params works!/,
116135
// When static redirects are generated as meta tags.
117136
'ssg-redirect/index.html': '<meta http-equiv="refresh" content="0; url=/ssg">',
137+
'ssg-redirect-xss/index.html': [
138+
`<meta http-equiv="refresh" content="0; url=${escapedXssRedirectUrl}">`,
139+
`<a href="${escapedXssRedirectUrl}">${escapedXssRedirectUrl}</a>`,
140+
],
141+
'ssg-redirect-external/index.html': [
142+
'<meta http-equiv="refresh" content="0; url=https://example.com/docs?from=ssg">',
143+
'<a href="https://example.com/docs?from=ssg">https://example.com/docs?from=ssg</a>',
144+
],
118145
'ssg-redirect-via-guard/index.html':
119146
'<meta http-equiv="refresh" content="0; url=/ssg?foo=bar">',
120147
};
121148

122-
for (const [filePath, fileMatch] of Object.entries(expects)) {
123-
await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch);
149+
for (const [filePath, fileMatches] of Object.entries(expects)) {
150+
for (const fileMatch of Array.isArray(fileMatches) ? fileMatches : [fileMatches]) {
151+
await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch);
152+
}
124153
}
125154

155+
const xssRedirectHtml = await readFile(
156+
join('dist/test-project/browser', 'ssg-redirect-xss/index.html'),
157+
);
158+
assert.doesNotMatch(xssRedirectHtml, /<script>alert\(1\)<\/script>/);
159+
160+
await replaceInFile(
161+
'src/app/app.routes.server.ts',
162+
`{
163+
path: '**',
164+
renderMode: RenderMode.Prerender,
165+
},`,
166+
`{
167+
path: 'ssg-redirect-unsafe-url',
168+
renderMode: RenderMode.Prerender,
169+
headers: { Location: 'javascript:alert(1)' },
170+
},
171+
{
172+
path: '**',
173+
renderMode: RenderMode.Prerender,
174+
},`,
175+
);
176+
177+
const { message: unsafeProtocolErrorMessage } = await expectToFail(() =>
178+
noSilentNg('build', '--output-mode=static'),
179+
);
180+
assert.match(
181+
unsafeProtocolErrorMessage,
182+
/An error occurred while prerendering route '\/ssg-redirect-unsafe-url'/,
183+
);
184+
assert.match(unsafeProtocolErrorMessage, /Unsupported redirect URL protocol "javascript:"/);
185+
186+
await replaceInFile(
187+
'src/app/app.routes.server.ts',
188+
`headers: { Location: 'javascript:alert(1)' },`,
189+
`headers: { Location: '/\\\\evil.com' },`,
190+
);
191+
192+
const { message: backslashRedirectErrorMessage } = await expectToFail(() =>
193+
noSilentNg('build', '--output-mode=static'),
194+
);
195+
assert.match(
196+
backslashRedirectErrorMessage,
197+
/An error occurred while prerendering route '\/ssg-redirect-unsafe-url'/,
198+
);
199+
assert.match(
200+
backslashRedirectErrorMessage,
201+
/Invalid redirect URL\. Static redirects only support HTTP\(S\) URLs and same-origin absolute paths\./,
202+
);
203+
126204
// Check that server directory does not exist
127205
assert(
128206
!existsSync('dist/test-project/server'),

0 commit comments

Comments
 (0)