@@ -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 { addTrailingSlash , 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 , addTrailingSlash , joinUrlParts , stripLeadingSlash } from '../utils/url' ;
3541import {
3642 PrerenderFallback ,
3743 RenderMode ,
@@ -189,10 +195,15 @@ async function* handleRoute(options: {
189195 `Please use one of the following redirect response codes: ${ [ ...VALID_REDIRECT_RESPONSE_CODES . values ( ) ] . join ( ', ' ) } .` ,
190196 } ;
191197 } else if ( typeof redirectTo === 'string' ) {
192- yield {
193- ...metadata ,
194- redirectTo : resolveRedirectTo ( metadata . route , redirectTo ) ,
195- } ;
198+ const { url, error } = resolveRedirectTo ( metadata . route , redirectTo ) ;
199+ if ( error !== undefined ) {
200+ yield { error : invalidRedirectError ( metadata . route , error ) } ;
201+ } else {
202+ yield {
203+ ...metadata ,
204+ redirectTo : url ,
205+ } ;
206+ }
196207 } else {
197208 yield metadata ;
198209 }
@@ -420,7 +431,14 @@ async function* handleSSGRoute(
420431 }
421432
422433 if ( redirectTo !== undefined ) {
423- meta . redirectTo = resolveRedirectTo ( currentRoutePath , redirectTo ) ;
434+ const { url, error } = resolveRedirectTo ( currentRoutePath , redirectTo ) ;
435+ if ( error !== undefined ) {
436+ yield { error : invalidRedirectError ( currentRoutePath , error ) } ;
437+
438+ return ;
439+ }
440+
441+ meta . redirectTo = url ;
424442 }
425443
426444 const isCatchAllRoute = CATCH_ALL_REGEXP . test ( currentRoutePath ) ;
@@ -472,13 +490,38 @@ async function* handleSSGRoute(
472490 . replace ( URL_PARAMETER_GLOBAL_REGEXP , replacer )
473491 . replace ( CATCH_ALL_REGEXP , replacer ) ;
474492
493+ // A parameter value is only meaningful in the context of the route it is substituted into,
494+ // so the composed path is what gets normalized and validated. This also catches values that
495+ // only become unsafe once combined with the route, such as a protocol-relative `//`.
496+ const { url : route , error : routeError } = normalizeAndValidateRoutePath (
497+ addLeadingSlash ( routeWithResolvedParams ) ,
498+ ) ;
499+ if ( routeError !== undefined ) {
500+ yield {
501+ error :
502+ `The '${ stripLeadingSlash ( currentRoutePath ) } ' route produced an invalid ` +
503+ `prerender path: ${ routeError } ` ,
504+ } ;
505+
506+ continue ;
507+ }
508+
509+ let resolvedRedirectTo : string | undefined ;
510+ if ( redirectTo !== undefined ) {
511+ const { url, error } = resolveRedirectTo ( routeWithResolvedParams , redirectTo ) ;
512+ if ( error !== undefined ) {
513+ yield { error : invalidRedirectError ( routeWithResolvedParams , error ) } ;
514+
515+ continue ;
516+ }
517+
518+ resolvedRedirectTo = url ;
519+ }
520+
475521 yield {
476522 ...meta ,
477- route : routeWithResolvedParams ,
478- redirectTo :
479- redirectTo === undefined
480- ? undefined
481- : resolveRedirectTo ( routeWithResolvedParams , redirectTo ) ,
523+ route,
524+ redirectTo : resolvedRedirectTo ,
482525 } ;
483526 }
484527 } catch ( error ) {
@@ -525,7 +568,10 @@ function handlePrerenderParamsReplacement(
525568 ) ;
526569 }
527570
528- return parameterName === '**' ? `/${ value } ` : value ;
571+ // The matched placeholder includes the separator, so it has to be re-added. The documented
572+ // value of a `**` parameter is a path, which may already carry it: re-adding it unconditionally
573+ // would compose a root catch-all into a protocol-relative `//foo/bar`.
574+ return parameterName === '**' ? addLeadingSlash ( value ) : value ;
529575 } ;
530576}
531577
@@ -536,21 +582,69 @@ function handlePrerenderParamsReplacement(
536582 * resolves relative to the current route path. If `redirectTo` is an absolute path,
537583 * it is returned as is. If it is a relative path, it is resolved based on the current route path.
538584 *
585+ * The resolved target is then normalized and validated, so that every `redirectTo` reaching the
586+ * route tree is a safe HTTP(S) URL or same-origin path.
587+ *
539588 * @param routePath - The current route path.
540589 * @param redirectTo - The target path for redirection.
541- * @returns The resolved redirect path as a string .
590+ * @returns The normalized redirect target, or the reason it was rejected .
542591 */
543- function resolveRedirectTo ( routePath : string , redirectTo : string ) : string {
592+ function resolveRedirectTo ( routePath : string , redirectTo : string ) : NormalizedUrl {
544593 if ( redirectTo [ 0 ] === '/' ) {
545- // If the redirectTo path is absolute, return it as is.
546- return redirectTo ;
594+ // If the redirectTo path is absolute, use it as is.
595+ return normalizeAndValidateRedirect ( redirectTo ) ;
547596 }
548597
549598 // Resolve relative redirectTo based on the current route path.
550599 const segments = routePath . replace ( URL_PARAMETER_GLOBAL_REGEXP , '*' ) . split ( '/' ) ;
551600 segments . pop ( ) ; // Remove the last segment to make it relative.
552601
553- return joinUrlParts ( ...segments , redirectTo ) ;
602+ return normalizeAndValidateRedirect ( joinUrlParts ( ...segments , redirectTo ) ) ;
603+ }
604+
605+ /**
606+ * Builds the diagnostic emitted when a route's `redirectTo` cannot be normalized.
607+ *
608+ * @param routePath - The route the `redirectTo` is defined on.
609+ * @param reason - The reason reported by the redirect normalization.
610+ * @returns The error message to report.
611+ */
612+ function invalidRedirectError ( routePath : string , reason : string ) : string {
613+ return `The 'redirectTo' value for the '${ stripLeadingSlash ( routePath ) } ' route is invalid: ${ reason } ` ;
614+ }
615+
616+ /**
617+ * Validates and normalizes the `Location` header of a server route configuration.
618+ *
619+ * A configured `Location` header turns the route into a redirect, both at runtime and in the static
620+ * redirect page generated when prerendering, so its value is validated as a redirect target. HTTP
621+ * header names are case-insensitive, so every entry which names the `Location` header is checked.
622+ *
623+ * @param headers - The headers configured for the route.
624+ * @returns The headers with each `Location` value normalized, or the reason one was rejected.
625+ */
626+ function normalizeLocationHeaders (
627+ headers : Record < string , string > ,
628+ ) : { headers : Record < string , string > ; error ?: undefined } | { headers ?: undefined ; error : string } {
629+ let normalized : Record < string , string > | undefined ;
630+
631+ for ( const [ name , value ] of Object . entries ( headers ) ) {
632+ if ( name . toLowerCase ( ) !== 'location' ) {
633+ continue ;
634+ }
635+
636+ const { url, error } = normalizeAndValidateRedirect ( value ) ;
637+ if ( error !== undefined ) {
638+ return { error } ;
639+ }
640+
641+ // Store the normalized value so that the header sent at runtime and the generated static
642+ // redirect page are the value which was validated.
643+ normalized ??= { ...headers } ;
644+ normalized [ name ] = url ;
645+ }
646+
647+ return { headers : normalized ?? headers } ;
554648}
555649
556650/**
@@ -593,12 +687,45 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
593687 continue ;
594688 }
595689
690+ if ( metadata . headers ) {
691+ const { headers, error } = normalizeLocationHeaders ( metadata . headers ) ;
692+ if ( error !== undefined ) {
693+ errors . push (
694+ `Invalid '${ path } ' route configuration: the 'headers.Location' value is invalid: ${ error } ` ,
695+ ) ;
696+ continue ;
697+ }
698+
699+ metadata . headers = headers ;
700+ }
701+
596702 serverConfigRouteTree . insert ( path , metadata ) ;
597703 }
598704
599705 return { serverConfigRouteTree, errors } ;
600706}
601707
708+ /**
709+ * Builds the key used to de-duplicate an extracted route.
710+ *
711+ * `RouteTree` percent-decodes every segment when a route is inserted, so two route paths which
712+ * differ only in their encoding land on the same node, where the later one overwrites the earlier.
713+ * De-duplication therefore runs on the decoded path rather than on the path as it was written:
714+ * a prerendered path is normalized by the URL parser while the path of every other render mode is
715+ * not, so the same route can be reached in both an encoded and an unencoded form.
716+ *
717+ * @param route - The route path to build a key for.
718+ * @returns The decoded route path.
719+ */
720+ function routeDeduplicationKey ( route : string ) : string {
721+ try {
722+ return route . split ( '/' ) . map ( decodeURIComponent ) . join ( '/' ) ;
723+ } catch {
724+ // A malformed percent escape is reported when the route is inserted into the route tree.
725+ return route ;
726+ }
727+ }
728+
602729/**
603730 * Retrieves routes from the given Angular application.
604731 *
@@ -721,7 +848,7 @@ export async function getRoutesFromAngularRouterConfig(
721848
722849 // If a result already exists for the exact same route, subsequent matches should be ignored.
723850 // This aligns with Angular's app router behavior, which prioritizes the first route.
724- const routePath = routeMetadata . route ;
851+ const routePath = routeDeduplicationKey ( routeMetadata . route ) ;
725852 if ( ! seenRoutes . has ( routePath ) ) {
726853 routesResults . push ( routeMetadata ) ;
727854 seenRoutes . add ( routePath ) ;
0 commit comments