@@ -30,8 +30,14 @@ import { Console } from '../console';
3030import { AngularAppManifest , getAngularAppManifest } from '../manifest' ;
3131import { AngularBootstrap , isNgModule } from '../utils/ng' ;
3232import { promiseWithAbort } from '../utils/promise' ;
33- import { VALID_REDIRECT_RESPONSE_CODES , isValidRedirectResponseCode } from '../utils/redirect' ;
34- import { joinUrlParts , stripLeadingSlash } from '../utils/url' ;
33+ import {
34+ NormalizedUrl ,
35+ VALID_REDIRECT_RESPONSE_CODES ,
36+ isValidRedirectResponseCode ,
37+ normalizeAndValidateRedirect ,
38+ normalizeAndValidateRoutePath ,
39+ } from '../utils/redirect' ;
40+ import { addLeadingSlash , joinUrlParts , stripLeadingSlash } from '../utils/url' ;
3541import {
3642 PrerenderFallback ,
3743 RenderMode ,
@@ -205,18 +211,23 @@ async function* handleRoute(options: {
205211 } else if ( typeof redirectTo === 'string' ) {
206212 // Reference the route parameters by their position in the URL (see `buildPathWithParams`).
207213 const routeSegments = joinUrlParts ( baseHref , currentRoutePath ) . split ( '/' ) ;
208- parentRoutes . set ( metadata . route , {
209- ...metadata ,
210- redirectTo : resolveRedirectTo (
211- parentRoute ,
212- replaceRedirectParams (
213- route ,
214- redirectTo ,
215- ( parameter ) => `*${ routeSegments . lastIndexOf ( parameter ) } ` ,
216- ) ,
214+ const { url, error } = resolveRedirectTo (
215+ parentRoute ,
216+ replaceRedirectParams (
217+ route ,
218+ redirectTo ,
219+ ( parameter ) => `*${ routeSegments . lastIndexOf ( parameter ) } ` ,
217220 ) ,
218- relativeRedirect : redirectTo [ 0 ] !== '/' ? true : undefined ,
219- } ) ;
221+ ) ;
222+ if ( error !== undefined ) {
223+ yield { error : invalidRedirectError ( metadata . route , error ) } ;
224+ } else {
225+ parentRoutes . set ( metadata . route , {
226+ ...metadata ,
227+ redirectTo : url ,
228+ relativeRedirect : redirectTo [ 0 ] !== '/' ? true : undefined ,
229+ } ) ;
230+ }
220231 } else {
221232 parentRoutes . set ( metadata . route , metadata ) ;
222233 }
@@ -470,14 +481,21 @@ async function* handleSSGRoute(
470481 if ( redirectTo !== undefined ) {
471482 // Reference the route parameters by their position in the URL (see `buildPathWithParams`).
472483 const routeSegments = joinUrlParts ( baseHref , currentRoutePath ) . split ( '/' ) ;
473- meta . redirectTo = resolveRedirectTo (
484+ const { url , error } = resolveRedirectTo (
474485 parentRoute ,
475486 replaceRedirectParams (
476487 route ,
477488 redirectTo ,
478489 ( parameter ) => `*${ routeSegments . lastIndexOf ( parameter ) } ` ,
479490 ) ,
480491 ) ;
492+ if ( error !== undefined ) {
493+ yield { error : invalidRedirectError ( currentRoutePath , error ) } ;
494+
495+ return ;
496+ }
497+
498+ meta . redirectTo = url ;
481499 meta . relativeRedirect = redirectTo [ 0 ] !== '/' ? true : undefined ;
482500 }
483501
@@ -530,20 +548,41 @@ async function* handleSSGRoute(
530548 . replace ( URL_PARAMETER_GLOBAL_REGEXP , replacer )
531549 . replace ( CATCH_ALL_REGEXP , replacer ) ;
532550
551+ // A parameter value is only meaningful in the context of the route it is substituted into,
552+ // so the composed path is what gets normalized and validated. This also catches values that
553+ // only become unsafe once combined with the route, such as a protocol-relative `//`.
554+ const { url : normalizedRoute , error : routeError } = normalizeAndValidateRoutePath (
555+ addLeadingSlash ( routeWithResolvedParams ) ,
556+ ) ;
557+ if ( routeError !== undefined ) {
558+ yield {
559+ error :
560+ `The '${ stripLeadingSlash ( currentRoutePath ) } ' route produced an invalid ` +
561+ `prerender path: ${ routeError } ` ,
562+ } ;
563+
564+ continue ;
565+ }
566+
567+ let resolvedRedirectTo : string | undefined ;
568+ if ( redirectTo !== undefined ) {
569+ const { url, error } = resolveRedirectTo (
570+ parentRoute . replace ( URL_PARAMETER_GLOBAL_REGEXP , replacer ) ,
571+ replaceRedirectParams ( route , redirectTo , ( parameter ) => params [ parameter . slice ( 1 ) ] ) ,
572+ ) ;
573+ if ( error !== undefined ) {
574+ yield { error : invalidRedirectError ( routeWithResolvedParams , error ) } ;
575+
576+ continue ;
577+ }
578+
579+ resolvedRedirectTo = url ;
580+ }
581+
533582 yield {
534583 ...meta ,
535- route : routeWithResolvedParams ,
536- redirectTo :
537- redirectTo === undefined
538- ? undefined
539- : resolveRedirectTo (
540- parentRoute . replace ( URL_PARAMETER_GLOBAL_REGEXP , replacer ) ,
541- replaceRedirectParams (
542- route ,
543- redirectTo ,
544- ( parameter ) => params [ parameter . slice ( 1 ) ] ,
545- ) ,
546- ) ,
584+ route : normalizedRoute ,
585+ redirectTo : resolvedRedirectTo ,
547586 } ;
548587 }
549588 } catch ( error ) {
@@ -590,7 +629,10 @@ function handlePrerenderParamsReplacement(
590629 ) ;
591630 }
592631
593- return parameterName === '**' ? `/${ value } ` : value ;
632+ // The matched placeholder includes the separator, so it has to be re-added. The documented
633+ // value of a `**` parameter is a path, which may already carry it: re-adding it unconditionally
634+ // would compose a root catch-all into a protocol-relative `//foo/bar`.
635+ return parameterName === '**' ? addLeadingSlash ( value ) : value ;
594636 } ;
595637}
596638
@@ -602,20 +644,36 @@ function handlePrerenderParamsReplacement(
602644 * it is returned as is. If it is a relative path, it is resolved based on the parent route path,
603645 * and, like in the Angular router, its query and fragment are ignored.
604646 *
647+ * The resolved target is then normalized and validated, so that every `redirectTo` reaching the
648+ * route tree is a safe HTTP(S) URL or same-origin path.
649+ *
605650 * @param parentRoute - The path of the parent route.
606651 * @param redirectTo - The target path for redirection.
607- * @returns The resolved redirect path as a string .
652+ * @returns The normalized redirect target, or the reason it was rejected .
608653 */
609- function resolveRedirectTo ( parentRoute : string , redirectTo : string ) : string {
654+ function resolveRedirectTo ( parentRoute : string , redirectTo : string ) : NormalizedUrl {
610655 if ( redirectTo [ 0 ] === '/' ) {
611- // If the redirectTo path is absolute, return it as is.
612- return redirectTo ;
656+ // If the redirectTo path is absolute, use it as is.
657+ return normalizeAndValidateRedirect ( redirectTo ) ;
613658 }
614659
615660 // Match Angular router behavior: replace all segments matched by the route.
616661 const [ path ] = redirectTo . split ( / [ ? # ] / , 1 ) ;
617662
618- return joinUrlParts ( parentRoute . replace ( URL_PARAMETER_GLOBAL_REGEXP , '*' ) , path ) ;
663+ return normalizeAndValidateRedirect (
664+ joinUrlParts ( parentRoute . replace ( URL_PARAMETER_GLOBAL_REGEXP , '*' ) , path ) ,
665+ ) ;
666+ }
667+
668+ /**
669+ * Builds the diagnostic emitted when a route's `redirectTo` cannot be normalized.
670+ *
671+ * @param routePath - The route the `redirectTo` is defined on.
672+ * @param reason - The reason reported by the redirect normalization.
673+ * @returns The error message to report.
674+ */
675+ function invalidRedirectError ( routePath : string , reason : string ) : string {
676+ return `The 'redirectTo' value for the '${ stripLeadingSlash ( routePath ) } ' route is invalid: ${ reason } ` ;
619677}
620678
621679/**
@@ -706,6 +764,27 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
706764 return { serverConfigRouteTree, errors } ;
707765}
708766
767+ /**
768+ * Builds the key used to de-duplicate an extracted route.
769+ *
770+ * `RouteTree` percent-decodes every segment when a route is inserted, so two route paths which
771+ * differ only in their encoding land on the same node, where the later one overwrites the earlier.
772+ * De-duplication therefore runs on the decoded path rather than on the path as it was written:
773+ * a prerendered path is normalized by the URL parser while the path of every other render mode is
774+ * not, so the same route can be reached in both an encoded and an unencoded form.
775+ *
776+ * @param route - The route path to build a key for.
777+ * @returns The decoded route path.
778+ */
779+ function routeDeduplicationKey ( route : string ) : string {
780+ try {
781+ return route . split ( '/' ) . map ( decodeURIComponent ) . join ( '/' ) ;
782+ } catch {
783+ // A malformed percent escape is reported when the route is inserted into the route tree.
784+ return route ;
785+ }
786+ }
787+
709788/**
710789 * Retrieves routes from the given Angular application.
711790 *
@@ -829,7 +908,7 @@ export async function getRoutesFromAngularRouterConfig(
829908
830909 // If a result already exists for the exact same route, subsequent matches should be ignored.
831910 // This aligns with Angular's app router behavior, which prioritizes the first route.
832- const routePath = routeMetadata . route ;
911+ const routePath = routeDeduplicationKey ( routeMetadata . route ) ;
833912 if ( ! seenRoutes . has ( routePath ) ) {
834913 routesResults . push ( routeMetadata ) ;
835914 seenRoutes . add ( routePath ) ;
0 commit comments