@@ -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,70 @@ 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 } ` ;
677+ }
678+
679+ /**
680+ * Validates and normalizes the `Location` header of a server route configuration.
681+ *
682+ * A configured `Location` header turns the route into a redirect, both at runtime and in the static
683+ * redirect page generated when prerendering, so its value is validated as a redirect target. HTTP
684+ * header names are case-insensitive, so every entry which names the `Location` header is checked.
685+ *
686+ * @param headers - The headers configured for the route.
687+ * @returns The headers with each `Location` value normalized, or the reason one was rejected.
688+ */
689+ function normalizeLocationHeaders (
690+ headers : Record < string , string > ,
691+ ) : { headers : Record < string , string > ; error ?: undefined } | { headers ?: undefined ; error : string } {
692+ let normalized : Record < string , string > | undefined ;
693+
694+ for ( const [ name , value ] of Object . entries ( headers ) ) {
695+ if ( name . toLowerCase ( ) !== 'location' ) {
696+ continue ;
697+ }
698+
699+ const { url, error } = normalizeAndValidateRedirect ( value ) ;
700+ if ( error !== undefined ) {
701+ return { error } ;
702+ }
703+
704+ // Store the normalized value so that the header sent at runtime and the generated static
705+ // redirect page are the value which was validated.
706+ normalized ??= { ...headers } ;
707+ normalized [ name ] = url ;
708+ }
709+
710+ return { headers : normalized ?? headers } ;
619711}
620712
621713/**
@@ -700,12 +792,45 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
700792 continue ;
701793 }
702794
795+ if ( metadata . headers ) {
796+ const { headers, error } = normalizeLocationHeaders ( metadata . headers ) ;
797+ if ( error !== undefined ) {
798+ errors . push (
799+ `Invalid '${ path } ' route configuration: the 'headers.Location' value is invalid: ${ error } ` ,
800+ ) ;
801+ continue ;
802+ }
803+
804+ metadata . headers = headers ;
805+ }
806+
703807 serverConfigRouteTree . insert ( path , metadata ) ;
704808 }
705809
706810 return { serverConfigRouteTree, errors } ;
707811}
708812
813+ /**
814+ * Builds the key used to de-duplicate an extracted route.
815+ *
816+ * `RouteTree` percent-decodes every segment when a route is inserted, so two route paths which
817+ * differ only in their encoding land on the same node, where the later one overwrites the earlier.
818+ * De-duplication therefore runs on the decoded path rather than on the path as it was written:
819+ * a prerendered path is normalized by the URL parser while the path of every other render mode is
820+ * not, so the same route can be reached in both an encoded and an unencoded form.
821+ *
822+ * @param route - The route path to build a key for.
823+ * @returns The decoded route path.
824+ */
825+ function routeDeduplicationKey ( route : string ) : string {
826+ try {
827+ return route . split ( '/' ) . map ( decodeURIComponent ) . join ( '/' ) ;
828+ } catch {
829+ // A malformed percent escape is reported when the route is inserted into the route tree.
830+ return route ;
831+ }
832+ }
833+
709834/**
710835 * Retrieves routes from the given Angular application.
711836 *
@@ -829,7 +954,7 @@ export async function getRoutesFromAngularRouterConfig(
829954
830955 // If a result already exists for the exact same route, subsequent matches should be ignored.
831956 // This aligns with Angular's app router behavior, which prioritizes the first route.
832- const routePath = routeMetadata . route ;
957+ const routePath = routeDeduplicationKey ( routeMetadata . route ) ;
833958 if ( ! seenRoutes . has ( routePath ) ) {
834959 routesResults . push ( routeMetadata ) ;
835960 seenRoutes . add ( routePath ) ;
0 commit comments