@@ -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 ,
@@ -192,11 +198,16 @@ async function* handleRoute(options: {
192198 `Please use one of the following redirect response codes: ${ [ ...VALID_REDIRECT_RESPONSE_CODES . values ( ) ] . join ( ', ' ) } .` ,
193199 } ;
194200 } else if ( typeof redirectTo === 'string' ) {
195- yield {
196- ...metadata ,
197- redirectTo : resolveRedirectTo ( parentRoute , redirectTo ) ,
198- relativeRedirect : redirectTo [ 0 ] !== '/' ? true : undefined ,
199- } ;
201+ const { url, error } = resolveRedirectTo ( parentRoute , redirectTo ) ;
202+ if ( error !== undefined ) {
203+ yield { error : invalidRedirectError ( metadata . route , error ) } ;
204+ } else {
205+ yield {
206+ ...metadata ,
207+ redirectTo : url ,
208+ relativeRedirect : redirectTo [ 0 ] !== '/' ? true : undefined ,
209+ } ;
210+ }
200211 } else {
201212 yield metadata ;
202213 }
@@ -426,7 +437,14 @@ async function* handleSSGRoute(
426437 }
427438
428439 if ( redirectTo !== undefined ) {
429- meta . redirectTo = resolveRedirectTo ( parentRoute , redirectTo ) ;
440+ const { url, error } = resolveRedirectTo ( parentRoute , redirectTo ) ;
441+ if ( error !== undefined ) {
442+ yield { error : invalidRedirectError ( currentRoutePath , error ) } ;
443+
444+ return ;
445+ }
446+
447+ meta . redirectTo = url ;
430448 meta . relativeRedirect = redirectTo [ 0 ] !== '/' ? true : undefined ;
431449 }
432450
@@ -479,16 +497,41 @@ async function* handleSSGRoute(
479497 . replace ( URL_PARAMETER_GLOBAL_REGEXP , replacer )
480498 . replace ( CATCH_ALL_REGEXP , replacer ) ;
481499
500+ // A parameter value is only meaningful in the context of the route it is substituted into,
501+ // so the composed path is what gets normalized and validated. This also catches values that
502+ // only become unsafe once combined with the route, such as a protocol-relative `//`.
503+ const { url : route , error : routeError } = normalizeAndValidateRoutePath (
504+ addLeadingSlash ( routeWithResolvedParams ) ,
505+ ) ;
506+ if ( routeError !== undefined ) {
507+ yield {
508+ error :
509+ `The '${ stripLeadingSlash ( currentRoutePath ) } ' route produced an invalid ` +
510+ `prerender path: ${ routeError } ` ,
511+ } ;
512+
513+ continue ;
514+ }
515+
516+ let resolvedRedirectTo : string | undefined ;
517+ if ( redirectTo !== undefined ) {
518+ const { url, error } = resolveRedirectTo (
519+ parentRoute . replace ( URL_PARAMETER_GLOBAL_REGEXP , replacer ) ,
520+ redirectTo ,
521+ ) ;
522+ if ( error !== undefined ) {
523+ yield { error : invalidRedirectError ( routeWithResolvedParams , error ) } ;
524+
525+ continue ;
526+ }
527+
528+ resolvedRedirectTo = url ;
529+ }
530+
482531 yield {
483532 ...meta ,
484- route : routeWithResolvedParams ,
485- redirectTo :
486- redirectTo === undefined
487- ? undefined
488- : resolveRedirectTo (
489- parentRoute . replace ( URL_PARAMETER_GLOBAL_REGEXP , replacer ) ,
490- redirectTo ,
491- ) ,
533+ route,
534+ redirectTo : resolvedRedirectTo ,
492535 } ;
493536 }
494537 } catch ( error ) {
@@ -535,7 +578,10 @@ function handlePrerenderParamsReplacement(
535578 ) ;
536579 }
537580
538- return parameterName === '**' ? `/${ value } ` : value ;
581+ // The matched placeholder includes the separator, so it has to be re-added. The documented
582+ // value of a `**` parameter is a path, which may already carry it: re-adding it unconditionally
583+ // would compose a root catch-all into a protocol-relative `//foo/bar`.
584+ return parameterName === '**' ? addLeadingSlash ( value ) : value ;
539585 } ;
540586}
541587
@@ -547,20 +593,70 @@ function handlePrerenderParamsReplacement(
547593 * it is returned as is. If it is a relative path, it is resolved based on the parent route path,
548594 * and, like in the Angular router, its query and fragment are ignored.
549595 *
596+ * The resolved target is then normalized and validated, so that every `redirectTo` reaching the
597+ * route tree is a safe HTTP(S) URL or same-origin path.
598+ *
550599 * @param parentRoute - The path of the parent route.
551600 * @param redirectTo - The target path for redirection.
552- * @returns The resolved redirect path as a string .
601+ * @returns The normalized redirect target, or the reason it was rejected .
553602 */
554- function resolveRedirectTo ( parentRoute : string , redirectTo : string ) : string {
603+ function resolveRedirectTo ( parentRoute : string , redirectTo : string ) : NormalizedUrl {
555604 if ( redirectTo [ 0 ] === '/' ) {
556- // If the redirectTo path is absolute, return it as is.
557- return redirectTo ;
605+ // If the redirectTo path is absolute, use it as is.
606+ return normalizeAndValidateRedirect ( redirectTo ) ;
558607 }
559608
560609 // Match Angular router behavior: replace all segments matched by the route.
561610 const [ path ] = redirectTo . split ( / [ ? # ] / , 1 ) ;
562611
563- return joinUrlParts ( parentRoute . replace ( URL_PARAMETER_GLOBAL_REGEXP , '*' ) , path ) ;
612+ return normalizeAndValidateRedirect (
613+ joinUrlParts ( parentRoute . replace ( URL_PARAMETER_GLOBAL_REGEXP , '*' ) , path ) ,
614+ ) ;
615+ }
616+
617+ /**
618+ * Builds the diagnostic emitted when a route's `redirectTo` cannot be normalized.
619+ *
620+ * @param routePath - The route the `redirectTo` is defined on.
621+ * @param reason - The reason reported by the redirect normalization.
622+ * @returns The error message to report.
623+ */
624+ function invalidRedirectError ( routePath : string , reason : string ) : string {
625+ return `The 'redirectTo' value for the '${ stripLeadingSlash ( routePath ) } ' route is invalid: ${ reason } ` ;
626+ }
627+
628+ /**
629+ * Validates and normalizes the `Location` header of a server route configuration.
630+ *
631+ * A configured `Location` header turns the route into a redirect, both at runtime and in the static
632+ * redirect page generated when prerendering, so its value is validated as a redirect target. HTTP
633+ * header names are case-insensitive, so every entry which names the `Location` header is checked.
634+ *
635+ * @param headers - The headers configured for the route.
636+ * @returns The headers with each `Location` value normalized, or the reason one was rejected.
637+ */
638+ function normalizeLocationHeaders (
639+ headers : Record < string , string > ,
640+ ) : { headers : Record < string , string > ; error ?: undefined } | { headers ?: undefined ; error : string } {
641+ let normalized : Record < string , string > | undefined ;
642+
643+ for ( const [ name , value ] of Object . entries ( headers ) ) {
644+ if ( name . toLowerCase ( ) !== 'location' ) {
645+ continue ;
646+ }
647+
648+ const { url, error } = normalizeAndValidateRedirect ( value ) ;
649+ if ( error !== undefined ) {
650+ return { error } ;
651+ }
652+
653+ // Store the normalized value so that the header sent at runtime and the generated static
654+ // redirect page are the value which was validated.
655+ normalized ??= { ...headers } ;
656+ normalized [ name ] = url ;
657+ }
658+
659+ return { headers : normalized ?? headers } ;
564660}
565661
566662/**
@@ -603,12 +699,45 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
603699 continue ;
604700 }
605701
702+ if ( metadata . headers ) {
703+ const { headers, error } = normalizeLocationHeaders ( metadata . headers ) ;
704+ if ( error !== undefined ) {
705+ errors . push (
706+ `Invalid '${ path } ' route configuration: the 'headers.Location' value is invalid: ${ error } ` ,
707+ ) ;
708+ continue ;
709+ }
710+
711+ metadata . headers = headers ;
712+ }
713+
606714 serverConfigRouteTree . insert ( path , metadata ) ;
607715 }
608716
609717 return { serverConfigRouteTree, errors } ;
610718}
611719
720+ /**
721+ * Builds the key used to de-duplicate an extracted route.
722+ *
723+ * `RouteTree` percent-decodes every segment when a route is inserted, so two route paths which
724+ * differ only in their encoding land on the same node, where the later one overwrites the earlier.
725+ * De-duplication therefore runs on the decoded path rather than on the path as it was written:
726+ * a prerendered path is normalized by the URL parser while the path of every other render mode is
727+ * not, so the same route can be reached in both an encoded and an unencoded form.
728+ *
729+ * @param route - The route path to build a key for.
730+ * @returns The decoded route path.
731+ */
732+ function routeDeduplicationKey ( route : string ) : string {
733+ try {
734+ return route . split ( '/' ) . map ( decodeURIComponent ) . join ( '/' ) ;
735+ } catch {
736+ // A malformed percent escape is reported when the route is inserted into the route tree.
737+ return route ;
738+ }
739+ }
740+
612741/**
613742 * Retrieves routes from the given Angular application.
614743 *
@@ -731,7 +860,7 @@ export async function getRoutesFromAngularRouterConfig(
731860
732861 // If a result already exists for the exact same route, subsequent matches should be ignored.
733862 // This aligns with Angular's app router behavior, which prioritizes the first route.
734- const routePath = routeMetadata . route ;
863+ const routePath = routeDeduplicationKey ( routeMetadata . route ) ;
735864 if ( ! seenRoutes . has ( routePath ) ) {
736865 routesResults . push ( routeMetadata ) ;
737866 seenRoutes . add ( routePath ) ;
0 commit comments