@@ -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 ,
@@ -200,11 +206,16 @@ async function* handleRoute(options: {
200206 `Please use one of the following redirect response codes: ${ [ ...VALID_REDIRECT_RESPONSE_CODES . values ( ) ] . join ( ', ' ) } .` ,
201207 } ;
202208 } else if ( typeof redirectTo === 'string' ) {
203- parentRoutes . set ( metadata . route , {
204- ...metadata ,
205- redirectTo : resolveRedirectTo ( parentRoute , redirectTo ) ,
206- relativeRedirect : redirectTo [ 0 ] !== '/' ? true : undefined ,
207- } ) ;
209+ const { url, error } = resolveRedirectTo ( parentRoute , redirectTo ) ;
210+ if ( error !== undefined ) {
211+ yield { error : invalidRedirectError ( metadata . route , error ) } ;
212+ } else {
213+ parentRoutes . set ( metadata . route , {
214+ ...metadata ,
215+ redirectTo : url ,
216+ relativeRedirect : redirectTo [ 0 ] !== '/' ? true : undefined ,
217+ } ) ;
218+ }
208219 } else {
209220 parentRoutes . set ( metadata . route , metadata ) ;
210221 }
@@ -452,7 +463,14 @@ async function* handleSSGRoute(
452463 }
453464
454465 if ( redirectTo !== undefined ) {
455- meta . redirectTo = resolveRedirectTo ( parentRoute , redirectTo ) ;
466+ const { url, error } = resolveRedirectTo ( parentRoute , redirectTo ) ;
467+ if ( error !== undefined ) {
468+ yield { error : invalidRedirectError ( currentRoutePath , error ) } ;
469+
470+ return ;
471+ }
472+
473+ meta . redirectTo = url ;
456474 meta . relativeRedirect = redirectTo [ 0 ] !== '/' ? true : undefined ;
457475 }
458476
@@ -505,16 +523,41 @@ async function* handleSSGRoute(
505523 . replace ( URL_PARAMETER_GLOBAL_REGEXP , replacer )
506524 . replace ( CATCH_ALL_REGEXP , replacer ) ;
507525
526+ // A parameter value is only meaningful in the context of the route it is substituted into,
527+ // so the composed path is what gets normalized and validated. This also catches values that
528+ // only become unsafe once combined with the route, such as a protocol-relative `//`.
529+ const { url : route , error : routeError } = normalizeAndValidateRoutePath (
530+ addLeadingSlash ( routeWithResolvedParams ) ,
531+ ) ;
532+ if ( routeError !== undefined ) {
533+ yield {
534+ error :
535+ `The '${ stripLeadingSlash ( currentRoutePath ) } ' route produced an invalid ` +
536+ `prerender path: ${ routeError } ` ,
537+ } ;
538+
539+ continue ;
540+ }
541+
542+ let resolvedRedirectTo : string | undefined ;
543+ if ( redirectTo !== undefined ) {
544+ const { url, error } = resolveRedirectTo (
545+ parentRoute . replace ( URL_PARAMETER_GLOBAL_REGEXP , replacer ) ,
546+ redirectTo ,
547+ ) ;
548+ if ( error !== undefined ) {
549+ yield { error : invalidRedirectError ( routeWithResolvedParams , error ) } ;
550+
551+ continue ;
552+ }
553+
554+ resolvedRedirectTo = url ;
555+ }
556+
508557 yield {
509558 ...meta ,
510- route : routeWithResolvedParams ,
511- redirectTo :
512- redirectTo === undefined
513- ? undefined
514- : resolveRedirectTo (
515- parentRoute . replace ( URL_PARAMETER_GLOBAL_REGEXP , replacer ) ,
516- redirectTo ,
517- ) ,
559+ route,
560+ redirectTo : resolvedRedirectTo ,
518561 } ;
519562 }
520563 } catch ( error ) {
@@ -561,7 +604,10 @@ function handlePrerenderParamsReplacement(
561604 ) ;
562605 }
563606
564- return parameterName === '**' ? `/${ value } ` : value ;
607+ // The matched placeholder includes the separator, so it has to be re-added. The documented
608+ // value of a `**` parameter is a path, which may already carry it: re-adding it unconditionally
609+ // would compose a root catch-all into a protocol-relative `//foo/bar`.
610+ return parameterName === '**' ? addLeadingSlash ( value ) : value ;
565611 } ;
566612}
567613
@@ -573,20 +619,70 @@ function handlePrerenderParamsReplacement(
573619 * it is returned as is. If it is a relative path, it is resolved based on the parent route path,
574620 * and, like in the Angular router, its query and fragment are ignored.
575621 *
622+ * The resolved target is then normalized and validated, so that every `redirectTo` reaching the
623+ * route tree is a safe HTTP(S) URL or same-origin path.
624+ *
576625 * @param parentRoute - The path of the parent route.
577626 * @param redirectTo - The target path for redirection.
578- * @returns The resolved redirect path as a string .
627+ * @returns The normalized redirect target, or the reason it was rejected .
579628 */
580- function resolveRedirectTo ( parentRoute : string , redirectTo : string ) : string {
629+ function resolveRedirectTo ( parentRoute : string , redirectTo : string ) : NormalizedUrl {
581630 if ( redirectTo [ 0 ] === '/' ) {
582- // If the redirectTo path is absolute, return it as is.
583- return redirectTo ;
631+ // If the redirectTo path is absolute, use it as is.
632+ return normalizeAndValidateRedirect ( redirectTo ) ;
584633 }
585634
586635 // Match Angular router behavior: replace all segments matched by the route.
587636 const [ path ] = redirectTo . split ( / [ ? # ] / , 1 ) ;
588637
589- return joinUrlParts ( parentRoute . replace ( URL_PARAMETER_GLOBAL_REGEXP , '*' ) , path ) ;
638+ return normalizeAndValidateRedirect (
639+ joinUrlParts ( parentRoute . replace ( URL_PARAMETER_GLOBAL_REGEXP , '*' ) , path ) ,
640+ ) ;
641+ }
642+
643+ /**
644+ * Builds the diagnostic emitted when a route's `redirectTo` cannot be normalized.
645+ *
646+ * @param routePath - The route the `redirectTo` is defined on.
647+ * @param reason - The reason reported by the redirect normalization.
648+ * @returns The error message to report.
649+ */
650+ function invalidRedirectError ( routePath : string , reason : string ) : string {
651+ return `The 'redirectTo' value for the '${ stripLeadingSlash ( routePath ) } ' route is invalid: ${ reason } ` ;
652+ }
653+
654+ /**
655+ * Validates and normalizes the `Location` header of a server route configuration.
656+ *
657+ * A configured `Location` header turns the route into a redirect, both at runtime and in the static
658+ * redirect page generated when prerendering, so its value is validated as a redirect target. HTTP
659+ * header names are case-insensitive, so every entry which names the `Location` header is checked.
660+ *
661+ * @param headers - The headers configured for the route.
662+ * @returns The headers with each `Location` value normalized, or the reason one was rejected.
663+ */
664+ function normalizeLocationHeaders (
665+ headers : Record < string , string > ,
666+ ) : { headers : Record < string , string > ; error ?: undefined } | { headers ?: undefined ; error : string } {
667+ let normalized : Record < string , string > | undefined ;
668+
669+ for ( const [ name , value ] of Object . entries ( headers ) ) {
670+ if ( name . toLowerCase ( ) !== 'location' ) {
671+ continue ;
672+ }
673+
674+ const { url, error } = normalizeAndValidateRedirect ( value ) ;
675+ if ( error !== undefined ) {
676+ return { error } ;
677+ }
678+
679+ // Store the normalized value so that the header sent at runtime and the generated static
680+ // redirect page are the value which was validated.
681+ normalized ??= { ...headers } ;
682+ normalized [ name ] = url ;
683+ }
684+
685+ return { headers : normalized ?? headers } ;
590686}
591687
592688/**
@@ -629,12 +725,45 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
629725 continue ;
630726 }
631727
728+ if ( metadata . headers ) {
729+ const { headers, error } = normalizeLocationHeaders ( metadata . headers ) ;
730+ if ( error !== undefined ) {
731+ errors . push (
732+ `Invalid '${ path } ' route configuration: the 'headers.Location' value is invalid: ${ error } ` ,
733+ ) ;
734+ continue ;
735+ }
736+
737+ metadata . headers = headers ;
738+ }
739+
632740 serverConfigRouteTree . insert ( path , metadata ) ;
633741 }
634742
635743 return { serverConfigRouteTree, errors } ;
636744}
637745
746+ /**
747+ * Builds the key used to de-duplicate an extracted route.
748+ *
749+ * `RouteTree` percent-decodes every segment when a route is inserted, so two route paths which
750+ * differ only in their encoding land on the same node, where the later one overwrites the earlier.
751+ * De-duplication therefore runs on the decoded path rather than on the path as it was written:
752+ * a prerendered path is normalized by the URL parser while the path of every other render mode is
753+ * not, so the same route can be reached in both an encoded and an unencoded form.
754+ *
755+ * @param route - The route path to build a key for.
756+ * @returns The decoded route path.
757+ */
758+ function routeDeduplicationKey ( route : string ) : string {
759+ try {
760+ return route . split ( '/' ) . map ( decodeURIComponent ) . join ( '/' ) ;
761+ } catch {
762+ // A malformed percent escape is reported when the route is inserted into the route tree.
763+ return route ;
764+ }
765+ }
766+
638767/**
639768 * Retrieves routes from the given Angular application.
640769 *
@@ -757,7 +886,7 @@ export async function getRoutesFromAngularRouterConfig(
757886
758887 // If a result already exists for the exact same route, subsequent matches should be ignored.
759888 // This aligns with Angular's app router behavior, which prioritizes the first route.
760- const routePath = routeMetadata . route ;
889+ const routePath = routeDeduplicationKey ( routeMetadata . route ) ;
761890 if ( ! seenRoutes . has ( routePath ) ) {
762891 routesResults . push ( routeMetadata ) ;
763892 seenRoutes . add ( routePath ) ;
0 commit comments