Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion packages/angular/build/src/utils/server-rendering/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
WritableSerializableRouteTreeNode,
} from './models';
import type { RenderWorkerData } from './render-worker';
import { generateRedirectStaticPage } from './utils';
import { generateRedirectStaticPage, validateStaticRedirectUrl } from './utils';

type PrerenderOptions = NormalizedApplicationBuildOptions['prerenderOptions'];
type AppShellOptions = NormalizedApplicationBuildOptions['appShellOptions'];
Expand Down Expand Up @@ -131,6 +131,14 @@ export async function prerenderPages(

const serializableRouteTreeNodeForPrerender: WritableSerializableRouteTreeNode = [];
for (const metadata of serializableRouteTreeNode) {
if (outputMode === OutputMode.Static) {
const invalidStaticRedirect = validateExtractedStaticRedirect(metadata);
if (invalidStaticRedirect) {
errors.push(invalidStaticRedirect);
continue;
}
}

if (outputMode !== OutputMode.Static && metadata.redirectTo) {
// Skip redirects if output mode is not static.
continue;
Expand Down Expand Up @@ -290,6 +298,37 @@ async function renderPages(
};
}

function validateExtractedStaticRedirect({
route,
redirectTo,
headers,
}: SerializableRouteTreeNode[number]): string | undefined {
if (redirectTo !== undefined) {
const invalidRedirectReason = validateStaticRedirectUrl(redirectTo);
if (invalidRedirectReason) {
return (
`Invalid 'redirectTo' for route '${stripLeadingSlash(route)}': ${invalidRedirectReason} ` +
`Such values would be embedded verbatim in the generated static redirect page, ` +
`which can lead to HTML injection. Percent-encode the value or sanitize the source.`
);
}
}

const location = headers?.['Location'] ?? headers?.['location'];
if (location !== undefined) {
const invalidLocationReason = validateStaticRedirectUrl(location);
if (invalidLocationReason) {
return (
`Invalid 'headers.Location' for route '${stripLeadingSlash(route)}': ${invalidLocationReason} ` +
`Such values would be embedded verbatim in the generated static redirect page, ` +
`which can lead to HTML injection. Percent-encode the value or sanitize the source.`
);
}
}

return undefined;
}

async function getAllRoutes(
workspaceRoot: string,
baseHref: string,
Expand Down
68 changes: 66 additions & 2 deletions packages/angular/build/src/utils/server-rendering/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,90 @@ export function isSsrRequestHandler(
return typeof value === 'function' && '__ng_request_handler__' in value;
}

const htmlEscapeCharacters: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};

function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (character) => htmlEscapeCharacters[character]);
}

const unsafeStaticRedirectCharacters = /[\\<>"'`]/;
const allowedStaticRedirectProtocols = new Set(['http:', 'https:']);

function hasUnsafeStaticRedirectCharacters(value: string): boolean {
for (let index = 0; index < value.length; index++) {
const characterCode = value.charCodeAt(index);
if (characterCode <= 0x20 || characterCode === 0x7f) {
return true;
}
}

return unsafeStaticRedirectCharacters.test(value);
}

export function validateStaticRedirectUrl(url: string): string | undefined {
if (hasUnsafeStaticRedirectCharacters(url)) {
return (
`the value '${url}' contains characters that are not allowed in a statically ` +
`emitted URL (control characters, whitespace, backslash, or HTML-significant characters).`
);
}

if (url.startsWith('/')) {
return url.startsWith('//')
? 'protocol-relative URLs are not supported for statically emitted redirects. ' +
'Only HTTP(S) URLs and same-origin absolute paths are supported.'
: undefined;
}

const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+\-.]*):/.exec(url);
if (!schemeMatch) {
return (
`the value '${url}' is not a valid absolute URL or same-origin absolute path. ` +
`Only HTTP(S) URLs and same-origin absolute paths are supported.`
);
}

const protocol = `${schemeMatch[1].toLowerCase()}:`;
if (!allowedStaticRedirectProtocols.has(protocol)) {
return (
`the protocol '${protocol}' is not allowed for a statically emitted redirect. ` +
`Only HTTP(S) URLs and same-origin absolute paths are supported.`
);
}

return undefined;
}

/**
* Generates a static HTML page with a meta refresh tag to redirect the user to a specified URL.
*
* This function creates a simple HTML page that performs a redirect using a meta tag.
* It includes a fallback link in case the meta-refresh doesn't work.
*
* The provided URL is HTML-escaped before being interpolated.
*
* @param url - The URL to which the page should redirect.
* @returns The HTML content of the static redirect page.
*/
export function generateRedirectStaticPage(url: string): string {
const escapedUrl = escapeHtml(url);

return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Redirecting</title>
<meta http-equiv="refresh" content="0; url=${url}">
<meta http-equiv="refresh" content="0; url=${escapedUrl}">
</head>
<body>
<pre>Redirecting to <a href="${url}">${url}</a></pre>
<pre>Redirecting to <a href="${escapedUrl}">${escapedUrl}</a></pre>
</body>
</html>
`.trim();
Expand Down
16 changes: 15 additions & 1 deletion packages/angular/ssr/src/routes/ng-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ import { Console } from '../console';
import { AngularAppManifest, getAngularAppManifest } from '../manifest';
import { AngularBootstrap, isNgModule } from '../utils/ng';
import { promiseWithAbort } from '../utils/promise';
import { VALID_REDIRECT_RESPONSE_CODES, isValidRedirectResponseCode } from '../utils/redirect';
import {
VALID_REDIRECT_RESPONSE_CODES,
isValidRedirectResponseCode,
validateUrlForStaticEmission,
} from '../utils/redirect';
import { addTrailingSlash, joinUrlParts, stripLeadingSlash } from '../utils/url';
import {
PrerenderFallback,
Expand Down Expand Up @@ -525,6 +529,16 @@ function handlePrerenderParamsReplacement(
);
}

const invalidValueReason = validateUrlForStaticEmission(value);
if (invalidValueReason) {
throw new Error(
`The 'getPrerenderParams' function defined for the '${stripLeadingSlash(currentRoutePath)}' route ` +
`returned an unsafe value for parameter '${parameterName}': ${invalidValueReason} ` +
`Such values would be embedded verbatim in generated URLs and static HTML, ` +
`which can lead to HTML injection. Percent-encode the value or sanitize the source.`,
);
}

return parameterName === '**' ? `/${value}` : value;
};
}
Expand Down
41 changes: 41 additions & 0 deletions packages/angular/ssr/src/utils/redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,47 @@ export function isValidRedirectResponseCode(code: number): boolean {
return VALID_REDIRECT_RESPONSE_CODES.has(code);
}

/**
* Characters that must never appear in a statically-emitted redirect target or
* in a value substituted into a prerendered URL. They are rejected during route
* extraction so they cannot break out of generated HTML contexts or smuggle
* markup through the route path.
*
* - C0 controls and DEL (`\u0000`-`\u001F`, `\u007F`) and whitespace
* - Backslash (`\`) - not a valid URL path separator
* - HTML-significant characters: `<`, `>`, `"`, `'`, `` ` ``
*/
const UNSAFE_URL_CHARACTERS_REGEXP = /[\\<>"'`]/;

function hasUnsafeUrlCharacters(value: string): boolean {
for (let index = 0; index < value.length; index++) {
const characterCode = value.charCodeAt(index);
if (characterCode <= 0x20 || characterCode === 0x7f) {
return true;
}
}

return UNSAFE_URL_CHARACTERS_REGEXP.test(value);
}

/**
* Validates that the given value is safe to embed in a generated redirect page
* or to use as a prerendered URL path segment.
*
* Returns `undefined` when the value is safe, otherwise returns a human-readable
* error message describing why it was rejected.
*/
export function validateUrlForStaticEmission(value: string): string | undefined {
if (hasUnsafeUrlCharacters(value)) {
return (
`the value '${value}' contains characters that are not allowed in a statically ` +
`emitted URL (control characters, whitespace, backslash, or HTML-significant characters).`
);
}

return undefined;
}

/**
* Creates an HTTP redirect response with a specified location and status code.
*
Expand Down
56 changes: 56 additions & 0 deletions packages/angular/ssr/test/routes/ng-routes_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,62 @@ describe('extractRoutesAndCreateRouteTree', () => {
);
});

it("should error when 'getPrerenderParams' returns a value containing HTML-significant characters", async () => {
setAngularAppTestingManifest(
[{ path: 'store/:tenant/legacy/:slug', component: DummyComponent }],
[
{
path: 'store/:tenant/legacy/:slug',
renderMode: RenderMode.Prerender,
fallback: PrerenderFallback.None,
async getPrerenderParams() {
return [
{
tenant: `x"><img src=x onerror=console.log("XSS_PARENT_IMG")>`,
slug: 'old',
},
];
},
},
],
);

const { errors } = await extractRoutesAndCreateRouteTree({
url,
invokeGetPrerenderParams: true,
});

expect(errors[0]).toContain(
`the 'store/:tenant/legacy/:slug' route ` +
`returned an unsafe value for parameter 'tenant'`,
);
});

it("should error when 'getPrerenderParams' returns a value containing control characters", async () => {
setAngularAppTestingManifest(
[{ path: 'docs/:id', component: DummyComponent }],
[
{
path: 'docs/:id',
renderMode: RenderMode.Prerender,
fallback: PrerenderFallback.None,
async getPrerenderParams() {
return [{ id: 'a b' }];
},
},
],
);

const { errors } = await extractRoutesAndCreateRouteTree({
url,
invokeGetPrerenderParams: true,
});

expect(errors[0]).toContain(
`the 'docs/:id' route returned an unsafe value for parameter 'id'`,
);
});

it(`should not error when a catch-all route didn't match any Angular route`, async () => {
setAngularAppTestingManifest(
[{ path: 'home', component: DummyComponent }],
Expand Down
14 changes: 13 additions & 1 deletion packages/angular/ssr/test/utils/redirect_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import { createRedirectResponse } from '../../src/utils/redirect';
import { createRedirectResponse, validateUrlForStaticEmission } from '../../src/utils/redirect';

describe('Redirect Utils', () => {
describe('createRedirectResponse', () => {
Expand Down Expand Up @@ -64,4 +64,16 @@ describe('Redirect Utils', () => {
);
});
});

describe('validateUrlForStaticEmission', () => {
it('should allow URL-safe values', () => {
expect(validateUrlForStaticEmission('docs/page-1?from=ssg&next=/home')).toBeUndefined();
});

it('should reject HTML-significant characters', () => {
expect(validateUrlForStaticEmission('/docs"><script>alert(1)</script>')).toContain(
'contains characters that are not allowed',
);
});
});
});
Loading
Loading