Skip to content

Commit bf1602a

Browse files
authored
fix(@angular/ssr): validate prerender parameter values
Reject `getPrerenderParams` values that are not valid path segments, such as `a/b`, `..` or a value containing `\`, instead of prerendering a different route or redirect target.
1 parent c770609 commit bf1602a

2 files changed

Lines changed: 137 additions & 0 deletions

File tree

‎packages/angular/ssr/src/routes/ng-routes.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ const URL_PARAMETER_REGEXP = /(?<!\\):([^/]+)/;
9191
*/
9292
const URL_PARAMETER_GLOBAL_REGEXP = new RegExp(URL_PARAMETER_REGEXP, 'g');
9393

94+
/**
95+
* Characters that cannot appear in a percent-decoded path segment: path separators (`\` is one in
96+
* `http:` URLs), query and fragment delimiters, matrix parameters, route tree wildcards, `%` (route
97+
* segments are decoded again when the route tree is restored) and control characters.
98+
*/
99+
const INVALID_PATH_SEGMENT_CHARS_REGEXP = /[/\\?#;*%\p{Control}]/u;
100+
94101
/**
95102
* Additional metadata for a server configuration route tree.
96103
*/
@@ -580,10 +587,43 @@ function handlePrerenderParamsReplacement(
580587
);
581588
}
582589

590+
const segments = parameterName === '**' ? (value ? value.split('/') : []) : [value];
591+
if (!segments.every(isPathSegment)) {
592+
throw new Error(
593+
`The 'getPrerenderParams' function defined for the '${stripLeadingSlash(currentRoutePath)}' route ` +
594+
`returned an invalid value '${value}' for parameter '${parameterName}'. ` +
595+
`Please make sure the value is a valid URL path${parameterName === '**' ? '' : ' segment'} ` +
596+
'without dot segments or reserved characters.',
597+
);
598+
}
599+
583600
return parameterName === '**' ? `/${value}` : value;
584601
};
585602
}
586603

604+
/**
605+
* Checks whether a value is a single, non-empty path segment.
606+
*
607+
* @param segment - The percent-encoded segment to check.
608+
* @returns `true` if the value is a single, non-empty path segment.
609+
*/
610+
function isPathSegment(segment: string): boolean {
611+
let decoded: string;
612+
try {
613+
decoded = decodeURIComponent(segment);
614+
} catch {
615+
return false;
616+
}
617+
618+
return (
619+
decoded.length > 0 &&
620+
decoded !== '.' &&
621+
decoded !== '..' &&
622+
decoded[0] !== ':' &&
623+
!INVALID_PATH_SEGMENT_CHARS_REGEXP.test(decoded)
624+
);
625+
}
626+
587627
/**
588628
* Resolves the `redirectTo` property for a given route, replacing any `:id` route parameters
589629
* in its path and resolving relative paths against `parentRoute`.

‎packages/angular/ssr/test/routes/ng-routes_spec.ts‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,103 @@ describe('extractRoutesAndCreateRouteTree', () => {
521521
{ route: '/profile/*', renderMode: RenderMode.Server },
522522
]);
523523
});
524+
525+
it('should error when a parameter value is not a valid path segment', async () => {
526+
const invalidValues: [path: string, value: string][] = [
527+
['users/:id', 'a/b'],
528+
['users/:id', 'a%2Fb'],
529+
['users/:id', '\\..\\evil.test'],
530+
['users/:id', '%5Cevil.test'],
531+
['users/:id', '%255Cevil.test'],
532+
['users/:id', '50%-off'],
533+
['users/:id', '.'],
534+
['users/:id', '..'],
535+
['users/:id', '..?'],
536+
['users/:id', 'a#b'],
537+
['users/:id', 'a;b'],
538+
['users/:id', '*'],
539+
['users/:id', ':id'],
540+
['users/:id', ''],
541+
['docs/**', '//evil.test'],
542+
['docs/**', 'a/\t/b'],
543+
];
544+
545+
for (const [path, value] of invalidValues) {
546+
const paramName = path.endsWith('**') ? '**' : 'id';
547+
setAngularAppTestingManifest(
548+
[{ path, component: DummyComponent }],
549+
[
550+
{
551+
path,
552+
renderMode: RenderMode.Prerender,
553+
async getPrerenderParams() {
554+
return [{ [paramName]: value }];
555+
},
556+
},
557+
],
558+
);
559+
560+
const { routeTree, errors } = await extractRoutesAndCreateRouteTree({
561+
url,
562+
invokeGetPrerenderParams: true,
563+
});
564+
expect(errors).toEqual([
565+
jasmine.stringContaining(
566+
`The 'getPrerenderParams' function defined for the '${path}' route ` +
567+
`returned an invalid value '${value}' for parameter '${paramName}'.`,
568+
),
569+
]);
570+
expect(routeTree.toObject()).toEqual([]);
571+
}
572+
});
573+
574+
it('should not error when a parameter value contains characters allowed in a path segment', async () => {
575+
setAngularAppTestingManifest(
576+
[
577+
{ path: 'user/:id', component: DummyComponent },
578+
{ path: 'docs/**', component: DummyComponent },
579+
],
580+
[
581+
{
582+
path: 'user/:id',
583+
renderMode: RenderMode.Prerender,
584+
fallback: PrerenderFallback.None,
585+
async getPrerenderParams() {
586+
return [
587+
{ id: "customer's-choice" },
588+
{ id: 'café' },
589+
{ id: 'a b' },
590+
{ id: 'a%20c' },
591+
{ id: 'a:b' },
592+
];
593+
},
594+
},
595+
{
596+
path: 'docs/**',
597+
renderMode: RenderMode.Prerender,
598+
fallback: PrerenderFallback.None,
599+
async getPrerenderParams() {
600+
return [{ '**': '' }, { '**': 'a/b' }];
601+
},
602+
},
603+
],
604+
);
605+
606+
const { routeTree, errors } = await extractRoutesAndCreateRouteTree({
607+
url,
608+
invokeGetPrerenderParams: true,
609+
});
610+
expect(errors).toHaveSize(0);
611+
expect(routeTree.toObject()).toEqual([
612+
{ route: "/user/customer's-choice", renderMode: RenderMode.Prerender },
613+
{ route: '/user/café', renderMode: RenderMode.Prerender },
614+
{ route: '/user/a b', renderMode: RenderMode.Prerender },
615+
{ route: '/user/a c', renderMode: RenderMode.Prerender },
616+
{ route: '/user/a:b', renderMode: RenderMode.Prerender },
617+
{ route: '/docs', renderMode: RenderMode.Prerender },
618+
{ route: '/docs/a/b', renderMode: RenderMode.Prerender },
619+
]);
620+
});
524621
});
525622

526623
it('should extract routes with a route level matcher', async () => {

0 commit comments

Comments
 (0)