From a8c8f77071aea7e40069e69d146ee7bbeac5175a Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:08:16 -0500 Subject: [PATCH 1/2] 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. --- .../build/src/utils/server-rendering/utils.ts | 31 +- .../src/utils/server-rendering/utils_spec.ts | 34 +++ packages/angular/ssr/src/routes/ng-routes.ts | 145 +++++++--- packages/angular/ssr/src/utils/redirect.ts | 212 ++++++++++++++ .../angular/ssr/test/routes/ng-routes_spec.ts | 264 ++++++++++++++++++ .../angular/ssr/test/utils/redirect_spec.ts | 200 ++++++++++++- .../server-routes-output-mode-static.ts | 12 + 7 files changed, 862 insertions(+), 36 deletions(-) create mode 100644 packages/angular/build/src/utils/server-rendering/utils_spec.ts diff --git a/packages/angular/build/src/utils/server-rendering/utils.ts b/packages/angular/build/src/utils/server-rendering/utils.ts index 2848fba52e75..18ab7def9ec1 100644 --- a/packages/angular/build/src/utils/server-rendering/utils.ts +++ b/packages/angular/build/src/utils/server-rendering/utils.ts @@ -22,26 +22,53 @@ export function isSsrRequestHandler( return typeof value === 'function' && '__ng_request_handler__' in value; } +/** + * A mapping of the characters which have to be escaped to be interpolated into HTML, + * to their entity equivalents. + */ +const HTML_ESCAPE_CHARACTER_MAP: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +/** + * Escapes the characters of a value which is interpolated into HTML text or into a quoted + * attribute value. + * + * @param text - The value to escape. + * @returns The escaped value. + */ +function escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, (character) => HTML_ESCAPE_CHARACTER_MAP[character]); +} + /** * 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 ` Redirecting - + -
Redirecting to ${url}
+
Redirecting to ${escapedUrl}
`.trim(); diff --git a/packages/angular/build/src/utils/server-rendering/utils_spec.ts b/packages/angular/build/src/utils/server-rendering/utils_spec.ts new file mode 100644 index 000000000000..c8d738f8eec1 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/utils_spec.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { generateRedirectStaticPage } from './utils'; + +describe('generateRedirectStaticPage', () => { + it('escapes the ampersands of a redirect target', () => { + const page = generateRedirectStaticPage('https://example.com/docs?from=ssg&next=/ssg'); + + expect(page).toContain( + '', + ); + expect(page).toContain( + '' + + 'https://example.com/docs?from=ssg&next=/ssg', + ); + }); + + it('escapes characters which would break out of the attribute or the tag', () => { + const page = generateRedirectStaticPage(`/">`); + + expect(page).not.toContain('').error, + ).toContain(`the 'data:' protocol is not supported as a redirect target`); + expect(normalizeAndValidateRedirect('JaVaScRiPt:alert(1)').error).toContain( + `the 'javascript:' protocol is not supported as a redirect target`, + ); + }); + + it('should detect the scheme of a target which is padded with whitespace', () => { + // The URL parser strips leading whitespace and control characters before applying the scheme + // grammar, so these carry a scheme even though they do not start with one. + expect(normalizeAndValidateRedirect(' javascript:alert(1)').error).toContain( + `the 'javascript:' protocol is not supported as a redirect target`, + ); + expect(normalizeAndValidateRedirect('\tdata:text/html,x').error).toContain( + `the 'data:' protocol is not supported as a redirect target`, + ); + expect(normalizeAndValidateRedirect(' https://example.com/docs')).toEqual({ + url: 'https://example.com/docs', + }); + }); + + it('should reject an absolute target which cannot be parsed', () => { + expect(normalizeAndValidateRedirect('http://').error).toContain( + `'http://' could not be parsed as a URL.`, + ); + }); + + it('should reject protocol-relative and backslash targets', () => { + expect(normalizeAndValidateRedirect('//evil.example').error).toContain( + `Protocol-relative paths ('//') and backslashes ('\\') are not supported.`, + ); + expect(normalizeAndValidateRedirect('/\\evil.example').error).toContain( + `Protocol-relative paths ('//') and backslashes ('\\') are not supported.`, + ); + }); + + it('should reject a target which the parser resolves into a protocol-relative path', () => { + // Resolving the dot segments produces a leading `//`, which the raw value did not have and + // which leaves the origin unchanged, so it has to be rejected on the resolved path. + for (const target of [ + '/..//evil.example', + './/evil.example', + '/docs/../..//evil.example', + '/%2e%2e//evil.example', + ]) { + expect(normalizeAndValidateRedirect(target).error) + .withContext(target) + .toContain(`It resolves to the protocol-relative path '//evil.example'.`); + } + }); + }); }); diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts index 77f954be4f4d..362cdc97e09e 100644 --- a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts @@ -48,6 +48,10 @@ export default async function () { path: 'ssg-redirect', redirectTo: 'ssg' }, + { + path: 'ssg-redirect-external', + component: Ssg, + }, { path: 'ssg-redirect-via-guard', canActivate: [() => { @@ -73,6 +77,11 @@ export default async function () { import { RenderMode, ServerRoute } from '@angular/ssr'; export const serverRoutes: ServerRoute[] = [ + { + path: 'ssg-redirect-external', + renderMode: RenderMode.Prerender, + headers: { Location: 'https://example.com/docs?from=ssg&next=/ssg' }, + }, { path: 'ssg/:id', renderMode: RenderMode.Prerender, @@ -115,6 +124,9 @@ export default async function () { 'ssg/two/index.html': /ng-server-context="ssg".+ssg-with-params works!/, // When static redirects are generated as meta tags. 'ssg-redirect/index.html': '', + // The target of a 'Location' header is HTML escaped before it is written to the page. + 'ssg-redirect-external/index.html': + '', 'ssg-redirect-via-guard/index.html': '', }; From 77668b045a02ac103bd15e77ad671176b98641e8 Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:32:27 -0500 Subject: [PATCH 2/2] fixup! fix(@angular/build): escape prerender redirect URLs --- packages/angular/ssr/src/routes/ng-routes.ts | 145 +++------- packages/angular/ssr/src/utils/redirect.ts | 212 -------------- .../angular/ssr/test/routes/ng-routes_spec.ts | 264 ------------------ .../angular/ssr/test/utils/redirect_spec.ts | 200 +------------ 4 files changed, 34 insertions(+), 787 deletions(-) diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index fa1524ad7cbd..4176560b88ba 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -30,14 +30,8 @@ import { Console } from '../console'; import { AngularAppManifest, getAngularAppManifest } from '../manifest'; import { AngularBootstrap, isNgModule } from '../utils/ng'; import { promiseWithAbort } from '../utils/promise'; -import { - NormalizedUrl, - VALID_REDIRECT_RESPONSE_CODES, - isValidRedirectResponseCode, - normalizeAndValidateRedirect, - normalizeAndValidateRoutePath, -} from '../utils/redirect'; -import { addLeadingSlash, joinUrlParts, stripLeadingSlash } from '../utils/url'; +import { VALID_REDIRECT_RESPONSE_CODES, isValidRedirectResponseCode } from '../utils/redirect'; +import { joinUrlParts, stripLeadingSlash } from '../utils/url'; import { PrerenderFallback, RenderMode, @@ -211,23 +205,18 @@ async function* handleRoute(options: { } else if (typeof redirectTo === 'string') { // Reference the route parameters by their position in the URL (see `buildPathWithParams`). const routeSegments = joinUrlParts(baseHref, currentRoutePath).split('/'); - const { url, error } = resolveRedirectTo( - parentRoute, - replaceRedirectParams( - route, - redirectTo, - (parameter) => `*${routeSegments.lastIndexOf(parameter)}`, + parentRoutes.set(metadata.route, { + ...metadata, + redirectTo: resolveRedirectTo( + parentRoute, + replaceRedirectParams( + route, + redirectTo, + (parameter) => `*${routeSegments.lastIndexOf(parameter)}`, + ), ), - ); - if (error !== undefined) { - yield { error: invalidRedirectError(metadata.route, error) }; - } else { - parentRoutes.set(metadata.route, { - ...metadata, - redirectTo: url, - relativeRedirect: redirectTo[0] !== '/' ? true : undefined, - }); - } + relativeRedirect: redirectTo[0] !== '/' ? true : undefined, + }); } else { parentRoutes.set(metadata.route, metadata); } @@ -481,7 +470,7 @@ async function* handleSSGRoute( if (redirectTo !== undefined) { // Reference the route parameters by their position in the URL (see `buildPathWithParams`). const routeSegments = joinUrlParts(baseHref, currentRoutePath).split('/'); - const { url, error } = resolveRedirectTo( + meta.redirectTo = resolveRedirectTo( parentRoute, replaceRedirectParams( route, @@ -489,13 +478,6 @@ async function* handleSSGRoute( (parameter) => `*${routeSegments.lastIndexOf(parameter)}`, ), ); - if (error !== undefined) { - yield { error: invalidRedirectError(currentRoutePath, error) }; - - return; - } - - meta.redirectTo = url; meta.relativeRedirect = redirectTo[0] !== '/' ? true : undefined; } @@ -548,41 +530,20 @@ async function* handleSSGRoute( .replace(URL_PARAMETER_GLOBAL_REGEXP, replacer) .replace(CATCH_ALL_REGEXP, replacer); - // A parameter value is only meaningful in the context of the route it is substituted into, - // so the composed path is what gets normalized and validated. This also catches values that - // only become unsafe once combined with the route, such as a protocol-relative `//`. - const { url: normalizedRoute, error: routeError } = normalizeAndValidateRoutePath( - addLeadingSlash(routeWithResolvedParams), - ); - if (routeError !== undefined) { - yield { - error: - `The '${stripLeadingSlash(currentRoutePath)}' route produced an invalid ` + - `prerender path: ${routeError}`, - }; - - continue; - } - - let resolvedRedirectTo: string | undefined; - if (redirectTo !== undefined) { - const { url, error } = resolveRedirectTo( - parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, replacer), - replaceRedirectParams(route, redirectTo, (parameter) => params[parameter.slice(1)]), - ); - if (error !== undefined) { - yield { error: invalidRedirectError(routeWithResolvedParams, error) }; - - continue; - } - - resolvedRedirectTo = url; - } - yield { ...meta, - route: normalizedRoute, - redirectTo: resolvedRedirectTo, + route: routeWithResolvedParams, + redirectTo: + redirectTo === undefined + ? undefined + : resolveRedirectTo( + parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, replacer), + replaceRedirectParams( + route, + redirectTo, + (parameter) => params[parameter.slice(1)], + ), + ), }; } } catch (error) { @@ -629,10 +590,7 @@ function handlePrerenderParamsReplacement( ); } - // The matched placeholder includes the separator, so it has to be re-added. The documented - // value of a `**` parameter is a path, which may already carry it: re-adding it unconditionally - // would compose a root catch-all into a protocol-relative `//foo/bar`. - return parameterName === '**' ? addLeadingSlash(value) : value; + return parameterName === '**' ? `/${value}` : value; }; } @@ -644,36 +602,20 @@ function handlePrerenderParamsReplacement( * it is returned as is. If it is a relative path, it is resolved based on the parent route path, * and, like in the Angular router, its query and fragment are ignored. * - * The resolved target is then normalized and validated, so that every `redirectTo` reaching the - * route tree is a safe HTTP(S) URL or same-origin path. - * * @param parentRoute - The path of the parent route. * @param redirectTo - The target path for redirection. - * @returns The normalized redirect target, or the reason it was rejected. + * @returns The resolved redirect path as a string. */ -function resolveRedirectTo(parentRoute: string, redirectTo: string): NormalizedUrl { +function resolveRedirectTo(parentRoute: string, redirectTo: string): string { if (redirectTo[0] === '/') { - // If the redirectTo path is absolute, use it as is. - return normalizeAndValidateRedirect(redirectTo); + // If the redirectTo path is absolute, return it as is. + return redirectTo; } // Match Angular router behavior: replace all segments matched by the route. const [path] = redirectTo.split(/[?#]/, 1); - return normalizeAndValidateRedirect( - joinUrlParts(parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, '*'), path), - ); -} - -/** - * Builds the diagnostic emitted when a route's `redirectTo` cannot be normalized. - * - * @param routePath - The route the `redirectTo` is defined on. - * @param reason - The reason reported by the redirect normalization. - * @returns The error message to report. - */ -function invalidRedirectError(routePath: string, reason: string): string { - return `The 'redirectTo' value for the '${stripLeadingSlash(routePath)}' route is invalid: ${reason}`; + return joinUrlParts(parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, '*'), path); } /** @@ -764,27 +706,6 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi return { serverConfigRouteTree, errors }; } -/** - * Builds the key used to de-duplicate an extracted route. - * - * `RouteTree` percent-decodes every segment when a route is inserted, so two route paths which - * differ only in their encoding land on the same node, where the later one overwrites the earlier. - * De-duplication therefore runs on the decoded path rather than on the path as it was written: - * a prerendered path is normalized by the URL parser while the path of every other render mode is - * not, so the same route can be reached in both an encoded and an unencoded form. - * - * @param route - The route path to build a key for. - * @returns The decoded route path. - */ -function routeDeduplicationKey(route: string): string { - try { - return route.split('/').map(decodeURIComponent).join('/'); - } catch { - // A malformed percent escape is reported when the route is inserted into the route tree. - return route; - } -} - /** * Retrieves routes from the given Angular application. * @@ -908,7 +829,7 @@ export async function getRoutesFromAngularRouterConfig( // If a result already exists for the exact same route, subsequent matches should be ignored. // This aligns with Angular's app router behavior, which prioritizes the first route. - const routePath = routeDeduplicationKey(routeMetadata.route); + const routePath = routeMetadata.route; if (!seenRoutes.has(routePath)) { routesResults.push(routeMetadata); seenRoutes.add(routePath); diff --git a/packages/angular/ssr/src/utils/redirect.ts b/packages/angular/ssr/src/utils/redirect.ts index dc99ed718072..b67f53265c17 100644 --- a/packages/angular/ssr/src/utils/redirect.ts +++ b/packages/angular/ssr/src/utils/redirect.ts @@ -23,218 +23,6 @@ export function isValidRedirectResponseCode(code: number): boolean { return VALID_REDIRECT_RESPONSE_CODES.has(code); } -/** - * Base URL used to resolve relative values with the WHATWG URL parser. - * - * Any origin works; a loopback address is used so that a value which manages to change the - * origin is trivially detectable. - */ -const RELATIVE_URL_BASE = 'http://127.0.0.1'; - -/** - * Protocols that are supported as the target of a redirect. - */ -const ALLOWED_REDIRECT_PROTOCOLS: ReadonlySet = new Set(['http:', 'https:']); - -/** - * Matches the URL syntax characters that a route segment must not contain once it has been - * percent-decoded. `/` and `\` are separators and `?`, `#` and `%` are URL syntax, so a segment - * which decodes into any of them is no longer the single segment that was validated. - */ -const UNSAFE_DECODED_SEGMENT_REGEXP = /[\\/?#%]/; - -/** - * Determines whether a percent-decoded route segment can still be used as a single path segment. - * - * Control characters are rejected in addition to the URL syntax characters, as they are valid - * neither in a URL nor in the directory that the prerendered page is written to. - * - * @param segment - The decoded segment to check. - * @returns `true` when the segment cannot be used as a path segment. - */ -function isUnsafeDecodedSegment(segment: string): boolean { - if (UNSAFE_DECODED_SEGMENT_REGEXP.test(segment)) { - return true; - } - - for (let index = 0; index < segment.length; index++) { - const code = segment.charCodeAt(index); - if (code < 0x20 || code === 0x7f) { - return true; - } - } - - return false; -} - -/** - * The outcome of normalizing a URL value: either the normalized value, or the reason the value - * was rejected. Exactly one of `url` and `error` is set. - */ -export type NormalizedUrl = { url: string; error?: undefined } | { url?: undefined; error: string }; - -/** - * Parses a value that has to stay within the application origin. - * - * Normalization is delegated to the WHATWG URL parser, which percent-encodes characters that are - * unsafe in a path (spaces, `<`, `>`, `"`, control characters, ...) and collapses `.` and `..` - * segments, while leaving characters that are legal in a path, such as apostrophes, untouched. - * - * The returned URL is guaranteed to resolve to the application origin and to have a path which - * cannot be read as protocol-relative. - * - * @param value - The value to parse, for example `/docs/a b`. - * @returns The parsed URL, or the reason the value was rejected. - */ -function parseSameOriginUrl( - value: string, -): { url: URL; error?: undefined } | { url?: undefined; error: string } { - // The URL parser treats a leading `//` as an authority and normalizes backslashes to forward - // slashes for special schemes. Both allow a value to silently point at a different origin. - if (value.startsWith('//') || value.includes('\\')) { - return { - error: - `'${value}' is not a valid same-origin path. ` + - `Protocol-relative paths ('//') and backslashes ('\\') are not supported.`, - }; - } - - let parsed: URL; - try { - parsed = new URL(value, RELATIVE_URL_BASE); - } catch { - return { error: `'${value}' could not be parsed as a URL.` }; - } - - if (parsed.origin !== RELATIVE_URL_BASE) { - return { error: `'${value}' resolves outside of the application origin.` }; - } - - // The parser resolves `.` and `..` segments, so a value which passed the check above can still - // come back with a leading `//`. The origin is unchanged when that happens, which means neither - // check so far rejects it, yet the resulting path is read as an authority by everything the - // value is handed to. The check is therefore repeated on the parsed path. - if (parsed.pathname.startsWith('//')) { - return { - error: - `'${value}' is not a valid same-origin path. ` + - `It resolves to the protocol-relative path '${parsed.pathname}'.`, - }; - } - - return { url: parsed }; -} - -/** - * Normalizes and validates the path that a route is prerendered under. - * - * On top of the same-origin normalization, the path has to survive the round trip it makes through - * the route tree: `RouteTree` percent-decodes every segment when a route is inserted, and decodes - * it again on the server when the serialized tree is restored. A segment is therefore only accepted - * when decoding it succeeds, yields a plain path segment, and leaves nothing further to decode. - * Dot segments are rejected rather than resolved, because a route which is prerendered somewhere - * other than where it is defined would silently overwrite the output of another route. - * - * @param path - The path to normalize, for example `/store/summer sale`. - * @returns The normalized path, or the reason it was rejected. - */ -export function normalizeAndValidateRoutePath(path: string): NormalizedUrl { - const { url: parsed, error } = parseSameOriginUrl(path); - if (error !== undefined) { - return { error }; - } - - if (parsed.search || parsed.hash) { - return { - error: `'${path}' is not a valid route path. A route cannot carry a query string or a fragment.`, - }; - } - - // Segments are validated on the value which was passed in rather than on the normalized pathname. - // The URL parser resolves `.` and `..` segments, including their percent-encoded forms, which - // would otherwise silently move the route to a completely different path. - for (const segment of path.split('/')) { - if (!segment) { - continue; - } - - let decoded: string; - try { - decoded = decodeURIComponent(segment); - } catch { - return { - error: - `'${path}' is not a valid route path. ` + - `The '${segment}' segment is not a valid percent-encoded value.`, - }; - } - - if (decoded === '.' || decoded === '..') { - return { - error: - `'${path}' is not a valid route path. ` + - `The '${segment}' segment decodes to the '${decoded}' path traversal segment.`, - }; - } - - if (isUnsafeDecodedSegment(decoded)) { - return { - error: - `'${path}' is not a valid route path. ` + - `The '${segment}' segment decodes to a value which is not a single path segment. ` + - `Separators, the URL syntax characters '?', '#' and '%' and control characters are not supported.`, - }; - } - } - - return { url: parsed.pathname }; -} - -/** - * Normalizes and validates a redirect target. - * - * Targets that carry a scheme must use HTTP(S). Anything else, such as `javascript:` or `data:`, - * is rejected because the target is emitted verbatim into the `Location` header and into the - * generated static redirect page. Schemeless targets are normalized as same-origin paths. - * - * @param target - The redirect target, for example `/home` or `https://example.com/docs`. - * @returns The normalized target, or the reason the value was rejected. - */ -export function normalizeAndValidateRedirect(target: string): NormalizedUrl { - // The value reaches this point straight from the route configuration, which is not necessarily - // type checked, so a non-string is reported as a diagnostic rather than thrown as a `TypeError`. - if (typeof target !== 'string') { - return { - error: `the redirect target must be a string, but a value of type '${typeof target}' was provided.`, - }; - } - - if (!target.trim()) { - return { error: 'the redirect target cannot be empty.' }; - } - - // A value which parses without a base is an absolute URL and therefore carries its own scheme. - // Letting the URL parser answer this keeps the check exact: the scheme grammar, and the leading - // whitespace and control characters that are stripped before it is applied, are all handled by - // the parser rather than restated here. - if (URL.canParse(target)) { - const { protocol, href } = new URL(target); - if (!ALLOWED_REDIRECT_PROTOCOLS.has(protocol)) { - return { - error: - `the '${protocol}' protocol is not supported as a redirect target. ` + - `Only 'http:', 'https:' and same-origin paths are supported.`, - }; - } - - return { url: href }; - } - - const { url: parsed, error } = parseSameOriginUrl(target); - - return error !== undefined ? { error } : { url: parsed.pathname + parsed.search + parsed.hash }; -} - /** * Creates an HTTP redirect response with a specified location and status code. * diff --git a/packages/angular/ssr/test/routes/ng-routes_spec.ts b/packages/angular/ssr/test/routes/ng-routes_spec.ts index 426c6b91cbb7..dc98403416b4 100644 --- a/packages/angular/ssr/test/routes/ng-routes_spec.ts +++ b/packages/angular/ssr/test/routes/ng-routes_spec.ts @@ -108,155 +108,6 @@ describe('extractRoutesAndCreateRouteTree', () => { ); }); - it("should error when 'getPrerenderParams' produces a protocol-relative path", async () => { - setAngularAppTestingManifest( - [{ path: ':slug', component: DummyComponent }], - [ - { - path: ':slug', - renderMode: RenderMode.Prerender, - fallback: PrerenderFallback.None, - async getPrerenderParams() { - return [{ slug: '/evil.example' }]; - }, - }, - ], - ); - - const { errors } = await extractRoutesAndCreateRouteTree({ - url, - invokeGetPrerenderParams: true, - }); - - expect(errors[0]).toContain( - `The ':slug' route produced an invalid prerender path: ` + - `'//evil.example' is not a valid same-origin path.`, - ); - }); - - it("should error when 'getPrerenderParams' produces a path containing a backslash", async () => { - setAngularAppTestingManifest( - [{ path: 'docs/:id', component: DummyComponent }], - [ - { - path: 'docs/:id', - renderMode: RenderMode.Prerender, - fallback: PrerenderFallback.None, - async getPrerenderParams() { - return [{ id: '\\evil.example' }]; - }, - }, - ], - ); - - const { errors } = await extractRoutesAndCreateRouteTree({ - url, - invokeGetPrerenderParams: true, - }); - - expect(errors[0]).toContain( - `The 'docs/:id' route produced an invalid prerender path: ` + - `'/docs/\\evil.example' is not a valid same-origin path.`, - ); - }); - - it("should error when a 'redirectTo' is protocol-relative", async () => { - setAngularAppTestingManifest( - [ - { path: 'home', component: DummyComponent }, - { path: 'redirect', redirectTo: '//evil.example' }, - ], - [{ path: '**', renderMode: RenderMode.Server }], - ); - - const { errors } = await extractRoutesAndCreateRouteTree({ url }); - expect(errors[0]).toContain( - `The 'redirectTo' value for the 'redirect' route is invalid: ` + - `'//evil.example' is not a valid same-origin path.`, - ); - }); - - it("should error when 'getPrerenderParams' produces a segment which decodes to a separator", async () => { - setAngularAppTestingManifest( - [{ path: 'store/:slug', component: DummyComponent }], - [ - { - path: 'store/:slug', - renderMode: RenderMode.Prerender, - fallback: PrerenderFallback.None, - async getPrerenderParams() { - return [{ slug: 'foo%2Fbar' }]; - }, - }, - ], - ); - - const { errors } = await extractRoutesAndCreateRouteTree({ - url, - invokeGetPrerenderParams: true, - }); - - expect(errors[0]).toContain( - `The 'store/:slug' route produced an invalid prerender path: ` + - `'/store/foo%2Fbar' is not a valid route path. ` + - `The 'foo%2Fbar' segment decodes to a value which is not a single path segment.`, - ); - }); - - it("should error when 'getPrerenderParams' produces an encoded dot segment", async () => { - setAngularAppTestingManifest( - [{ path: 'store/:slug', component: DummyComponent }], - [ - { - path: 'store/:slug', - renderMode: RenderMode.Prerender, - fallback: PrerenderFallback.None, - async getPrerenderParams() { - return [{ slug: '%2E%2E' }]; - }, - }, - ], - ); - - const { errors } = await extractRoutesAndCreateRouteTree({ - url, - invokeGetPrerenderParams: true, - }); - - expect(errors[0]).toContain( - `The 'store/:slug' route produced an invalid prerender path: ` + - `'/store/%2E%2E' is not a valid route path. ` + - `The '%2E%2E' segment decodes to the '..' path traversal segment.`, - ); - }); - - it("should error when 'getPrerenderParams' produces a malformed percent escape", async () => { - setAngularAppTestingManifest( - [{ path: 'store/:slug', component: DummyComponent }], - [ - { - path: 'store/:slug', - renderMode: RenderMode.Prerender, - fallback: PrerenderFallback.None, - async getPrerenderParams() { - return [{ slug: '50%' }]; - }, - }, - ], - ); - - const { errors } = await extractRoutesAndCreateRouteTree({ - url, - invokeGetPrerenderParams: true, - }); - - expect(errors[0]).toContain( - `The 'store/:slug' route produced an invalid prerender path: ` + - `'/store/50%' is not a valid route path. ` + - `The '50%' segment is not a valid percent-encoded value.`, - ); - }); - it(`should not error when a catch-all route didn't match any Angular route`, async () => { setAngularAppTestingManifest( [{ path: 'home', component: DummyComponent }], @@ -337,65 +188,6 @@ describe('extractRoutesAndCreateRouteTree', () => { }); describe('when `invokeGetPrerenderParams` is true', () => { - it("should allow 'getPrerenderParams' values containing apostrophes and spaces", async () => { - setAngularAppTestingManifest( - [{ path: 'store/:slug', component: DummyComponent }], - [ - { - path: 'store/:slug', - renderMode: RenderMode.Prerender, - fallback: PrerenderFallback.None, - async getPrerenderParams() { - return [{ slug: `customer's-choice` }, { slug: 'summer sale' }]; - }, - }, - ], - ); - - const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ - url, - invokeGetPrerenderParams: true, - }); - expect(errors).toHaveSize(0); - expect(routeTree.toObject()).toEqual([ - { route: `/store/customer's-choice`, renderMode: RenderMode.Prerender }, - { route: '/store/summer sale', renderMode: RenderMode.Prerender }, - ]); - }); - - it('should keep the first route when a prerendered path collides with a literal route', async () => { - // A prerendered path is normalized by the URL parser while a literal route path is not, so - // the two reach the route tree in a different encoding even though they are the same node. - setAngularAppTestingManifest( - [ - { path: 'produits/café', component: DummyComponent }, - { path: 'produits/:slug', component: DummyComponent }, - ], - [ - { path: 'produits/café', renderMode: RenderMode.Server, status: 201 }, - { - path: 'produits/:slug', - renderMode: RenderMode.Prerender, - fallback: PrerenderFallback.None, - async getPrerenderParams() { - return [{ slug: 'café' }]; - }, - }, - { path: '**', renderMode: RenderMode.Server }, - ], - ); - - const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ - url, - invokeGetPrerenderParams: true, - }); - - expect(errors).toHaveSize(0); - expect(routeTree.toObject()).toEqual([ - { route: '/produits/café', renderMode: RenderMode.Server, status: 201 }, - ]); - }); - it('should resolve parameterized routes for SSG and add a fallback route if fallback is Server', async () => { setAngularAppTestingManifest( [{ path: 'user/:id/role/:role', component: DummyComponent }], @@ -542,62 +334,6 @@ describe('extractRoutesAndCreateRouteTree', () => { ]); }); - it('should resolve a catch-all value which carries the leading separator', async () => { - // The documented value of a '**' parameter is a path such as '/foo/bar', which already - // carries the separator that the matched '/**' placeholder includes. A root catch-all is the - // case where re-adding it composes a protocol-relative '//foo/bar'. - setAngularAppTestingManifest( - [{ path: '**', component: DummyComponent }], - [ - { - path: '**', - renderMode: RenderMode.Prerender, - fallback: PrerenderFallback.None, - async getPrerenderParams() { - return [{ '**': '/foo/bar' }, { '**': 'foo/baz' }]; - }, - }, - ], - ); - - const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ - url, - invokeGetPrerenderParams: true, - }); - - expect(errors).toHaveSize(0); - expect(routeTree.toObject()).toEqual([ - { route: '/foo/bar', renderMode: RenderMode.Prerender }, - { route: '/foo/baz', renderMode: RenderMode.Prerender }, - ]); - }); - - it('should still reject a catch-all value which is itself protocol-relative', async () => { - setAngularAppTestingManifest( - [{ path: '**', component: DummyComponent }], - [ - { - path: '**', - renderMode: RenderMode.Prerender, - fallback: PrerenderFallback.None, - async getPrerenderParams() { - return [{ '**': '//evil.example' }]; - }, - }, - ], - ); - - const { errors } = await extractRoutesAndCreateRouteTree({ - url, - invokeGetPrerenderParams: true, - }); - - expect(errors[0]).toContain( - `The '**' route produced an invalid prerender path: ` + - `'//evil.example' is not a valid same-origin path.`, - ); - }); - it('should extract nested redirects that are not explicitly defined.', async () => { setAngularAppTestingManifest( [ diff --git a/packages/angular/ssr/test/utils/redirect_spec.ts b/packages/angular/ssr/test/utils/redirect_spec.ts index 0bbe1a556873..4d0b94a87798 100644 --- a/packages/angular/ssr/test/utils/redirect_spec.ts +++ b/packages/angular/ssr/test/utils/redirect_spec.ts @@ -6,11 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - createRedirectResponse, - normalizeAndValidateRedirect, - normalizeAndValidateRoutePath, -} from '../../src/utils/redirect'; +import { createRedirectResponse } from '../../src/utils/redirect'; describe('Redirect Utils', () => { describe('createRedirectResponse', () => { @@ -68,198 +64,4 @@ describe('Redirect Utils', () => { ); }); }); - - describe('normalizeAndValidateRoutePath', () => { - it('should keep characters which are legal in a path', () => { - expect(normalizeAndValidateRoutePath(`/store/customer's-choice`)).toEqual({ - url: `/store/customer's-choice`, - }); - }); - - it('should percent-encode spaces, non-ASCII and HTML-significant characters', () => { - expect(normalizeAndValidateRoutePath('/docs/a b')).toEqual({ url: '/docs/a%20b' }); - expect(normalizeAndValidateRoutePath('/produits/café')).toEqual({ - url: '/produits/caf%C3%A9', - }); - expect(normalizeAndValidateRoutePath('/docs/').error, - ).toContain(`the 'data:' protocol is not supported as a redirect target`); - expect(normalizeAndValidateRedirect('JaVaScRiPt:alert(1)').error).toContain( - `the 'javascript:' protocol is not supported as a redirect target`, - ); - }); - - it('should detect the scheme of a target which is padded with whitespace', () => { - // The URL parser strips leading whitespace and control characters before applying the scheme - // grammar, so these carry a scheme even though they do not start with one. - expect(normalizeAndValidateRedirect(' javascript:alert(1)').error).toContain( - `the 'javascript:' protocol is not supported as a redirect target`, - ); - expect(normalizeAndValidateRedirect('\tdata:text/html,x').error).toContain( - `the 'data:' protocol is not supported as a redirect target`, - ); - expect(normalizeAndValidateRedirect(' https://example.com/docs')).toEqual({ - url: 'https://example.com/docs', - }); - }); - - it('should reject an absolute target which cannot be parsed', () => { - expect(normalizeAndValidateRedirect('http://').error).toContain( - `'http://' could not be parsed as a URL.`, - ); - }); - - it('should reject protocol-relative and backslash targets', () => { - expect(normalizeAndValidateRedirect('//evil.example').error).toContain( - `Protocol-relative paths ('//') and backslashes ('\\') are not supported.`, - ); - expect(normalizeAndValidateRedirect('/\\evil.example').error).toContain( - `Protocol-relative paths ('//') and backslashes ('\\') are not supported.`, - ); - }); - - it('should reject a target which the parser resolves into a protocol-relative path', () => { - // Resolving the dot segments produces a leading `//`, which the raw value did not have and - // which leaves the origin unchanged, so it has to be rejected on the resolved path. - for (const target of [ - '/..//evil.example', - './/evil.example', - '/docs/../..//evil.example', - '/%2e%2e//evil.example', - ]) { - expect(normalizeAndValidateRedirect(target).error) - .withContext(target) - .toContain(`It resolves to the protocol-relative path '//evil.example'.`); - } - }); - }); });