From abd7f7bb75344d8a0f16d663f27bae24f564d27c Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:07:11 -0500 Subject: [PATCH 1/3] 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. Fixes #34569 --- packages/angular/ssr/src/routes/ng-routes.ts | 29 +++++ .../angular/ssr/test/routes/ng-routes_spec.ts | 119 ++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 72caca999488..735312ebed23 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -580,10 +580,39 @@ function handlePrerenderParamsReplacement( ); } + // Route segments are percent-decoded when they are added to the route tree. + const segments = parameterName === '**' ? value.split('/').filter(Boolean) : [value]; + if (!segments.every((segment) => isPathSegment(decodeURIComponent(segment)))) { + throw new Error( + `The 'getPrerenderParams' function defined for the '${stripLeadingSlash(currentRoutePath)}' route ` + + `returned an invalid value '${value}' for parameter '${parameterName}'. ` + + `Parameter values must be non-empty path segments other than '.' and '..', and cannot contain ` + + `'/' or '\\', even when percent-encoded. ` + + `Only '**' values can be empty or use '/' to separate segments.`, + ); + } + return parameterName === '**' ? `/${value}` : value; }; } +/** + * Checks whether a value is a single, non-empty path segment. + * + * The value cannot contain `/`, and the URL parser must keep it as one segment, which rules out `\` + * (a path separator in `http:` URLs), `.`, `..` and values made only of tabs or newlines. The `/` + * check is still needed because the URL parser resolves a value such as `../a` to one segment. + * + * @param value - The value to check. + * @returns `true` if the value is a single, non-empty path segment. + */ +function isPathSegment(value: string): boolean { + const url = new URL('http://localhost'); + url.pathname = `/${value}`; + + return !value.includes('/') && url.pathname.length > 1 && url.pathname.indexOf('/', 1) === -1; +} + /** * Resolves the `redirectTo` property for a given route, replacing any `:id` route parameters * in its path and resolving relative paths against `parentRoute`. diff --git a/packages/angular/ssr/test/routes/ng-routes_spec.ts b/packages/angular/ssr/test/routes/ng-routes_spec.ts index 731bdd9dff46..124a14d5e16c 100644 --- a/packages/angular/ssr/test/routes/ng-routes_spec.ts +++ b/packages/angular/ssr/test/routes/ng-routes_spec.ts @@ -521,6 +521,125 @@ describe('extractRoutesAndCreateRouteTree', () => { { route: '/profile/*', renderMode: RenderMode.Server }, ]); }); + + it('should error when a parameter value is not a valid path segment', async () => { + setAngularAppTestingManifest( + [ + { path: 'slash/:id', component: DummyComponent }, + { path: 'encoded-slash/:id', component: DummyComponent }, + { path: 'backslash/:id', component: DummyComponent }, + { path: 'encoded-backslash/:id', component: DummyComponent }, + { path: 'dot-segment/:id', component: DummyComponent }, + { path: 'traversal/:id', component: DummyComponent }, + { path: 'empty/:id', component: DummyComponent }, + { path: 'tab/**', component: DummyComponent }, + ], + [ + { + path: 'slash/:id', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + return [{ id: 'a/b' }]; + }, + }, + { + path: 'encoded-slash/:id', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + return [{ id: 'a%2Fb' }]; + }, + }, + { + path: 'backslash/:id', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + return [{ id: '\\evil.test' }]; + }, + }, + { + path: 'encoded-backslash/:id', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + return [{ id: '%5Cevil.test' }]; + }, + }, + { + path: 'dot-segment/:id', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + return [{ id: '..' }]; + }, + }, + { + path: 'traversal/:id', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + return [{ id: '../a' }]; + }, + }, + { + path: 'empty/:id', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + return [{ id: '' }]; + }, + }, + { + path: 'tab/**', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + return [{ '**': 'a/\t/b' }]; + }, + }, + ], + ); + + const { errors } = await extractRoutesAndCreateRouteTree({ + url, + invokeGetPrerenderParams: true, + }); + expect(errors).toEqual([ + jasmine.stringContaining(`'slash/:id' route returned an invalid value 'a/b'`), + jasmine.stringContaining(`'encoded-slash/:id' route returned an invalid value 'a%2Fb'`), + jasmine.stringContaining(`'backslash/:id' route returned an invalid value '\\evil.test'`), + jasmine.stringContaining( + `'encoded-backslash/:id' route returned an invalid value '%5Cevil.test'`, + ), + jasmine.stringContaining(`'dot-segment/:id' route returned an invalid value '..'`), + jasmine.stringContaining(`'traversal/:id' route returned an invalid value '../a'`), + jasmine.stringContaining(`'empty/:id' route returned an invalid value ''`), + jasmine.stringContaining( + `'tab/**' route returned an invalid value 'a/\t/b' for parameter '**'.`, + ), + ]); + }); + + it('should not error when a parameter value contains characters allowed in a path segment', async () => { + setAngularAppTestingManifest( + [{ path: 'user/:id', component: DummyComponent }], + [ + { + path: 'user/:id', + renderMode: RenderMode.Prerender, + fallback: PrerenderFallback.None, + async getPrerenderParams() { + return [{ id: "customer's-choice" }, { id: 'café' }, { id: 'a b' }]; + }, + }, + ], + ); + + const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ + url, + invokeGetPrerenderParams: true, + }); + expect(errors).toHaveSize(0); + expect(routeTree.toObject()).toEqual([ + { route: "/user/customer's-choice", renderMode: RenderMode.Prerender }, + { route: '/user/café', renderMode: RenderMode.Prerender }, + { route: '/user/a b', renderMode: RenderMode.Prerender }, + ]); + }); }); it('should extract routes with a route level matcher', async () => { From 5b4836b99aa9de20a2aae4a0f15bfcde3f9ac912 Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:28:40 -0500 Subject: [PATCH 2/3] fixup! fix(@angular/ssr): validate prerender parameter values --- packages/angular/ssr/src/routes/ng-routes.ts | 43 ++++-- .../angular/ssr/test/routes/ng-routes_spec.ts | 134 +++++++----------- 2 files changed, 80 insertions(+), 97 deletions(-) diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 735312ebed23..e02f9d3457ca 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -91,6 +91,14 @@ const URL_PARAMETER_REGEXP = /(? isPathSegment(decodeURIComponent(segment)))) { + const segments = parameterName === '**' ? (value ? value.split('/') : []) : [value]; + if (!segments.every(isPathSegment)) { throw new Error( `The 'getPrerenderParams' function defined for the '${stripLeadingSlash(currentRoutePath)}' route ` + `returned an invalid value '${value}' for parameter '${parameterName}'. ` + - `Parameter values must be non-empty path segments other than '.' and '..', and cannot contain ` + - `'/' or '\\', even when percent-encoded. ` + - `Only '**' values can be empty or use '/' to separate segments.`, + `Parameter values must be non-empty path segments other than '.' and '..'. Once ` + + `percent-decoded, they cannot start with ':' or contain '/', '\\', '?', '#', ';', '*', '%' ` + + `or control characters. Only '**' values can be empty or use '/' to separate segments.`, ); } @@ -599,18 +606,24 @@ function handlePrerenderParamsReplacement( /** * Checks whether a value is a single, non-empty path segment. * - * The value cannot contain `/`, and the URL parser must keep it as one segment, which rules out `\` - * (a path separator in `http:` URLs), `.`, `..` and values made only of tabs or newlines. The `/` - * check is still needed because the URL parser resolves a value such as `../a` to one segment. - * - * @param value - The value to check. + * @param segment - The percent-encoded segment to check. * @returns `true` if the value is a single, non-empty path segment. */ -function isPathSegment(value: string): boolean { - const url = new URL('http://localhost'); - url.pathname = `/${value}`; +function isPathSegment(segment: string): boolean { + let decoded: string; + try { + decoded = decodeURIComponent(segment); + } catch { + return false; + } - return !value.includes('/') && url.pathname.length > 1 && url.pathname.indexOf('/', 1) === -1; + return ( + decoded.length > 0 && + decoded !== '.' && + decoded !== '..' && + decoded[0] !== ':' && + !INVALID_PATH_SEGMENT_CHARS_REGEXP.test(decoded) + ); } /** diff --git a/packages/angular/ssr/test/routes/ng-routes_spec.ts b/packages/angular/ssr/test/routes/ng-routes_spec.ts index 124a14d5e16c..e2d06724a18b 100644 --- a/packages/angular/ssr/test/routes/ng-routes_spec.ts +++ b/packages/angular/ssr/test/routes/ng-routes_spec.ts @@ -523,107 +523,73 @@ describe('extractRoutesAndCreateRouteTree', () => { }); it('should error when a parameter value is not a valid path segment', async () => { + const invalidValues: [path: string, value: string][] = [ + ['slash/:id', 'a/b'], + ['encoded-slash/:id', 'a%2Fb'], + ['backslash/:id', '\\..\\evil.test'], + ['encoded-backslash/:id', '%5Cevil.test'], + ['double-encoded-backslash/:id', '%255Cevil.test'], + ['malformed/:id', '50%-off'], + ['dot/:id', '.'], + ['dot-dot/:id', '..'], + ['query/:id', '..?'], + ['fragment/:id', 'a#b'], + ['matrix-param/:id', 'a;b'], + ['wildcard/:id', '*'], + ['param/:id', ':id'], + ['empty/:id', ''], + ['empty-segment/**', '//evil.test'], + ['tab/**', 'a/\t/b'], + ]; setAngularAppTestingManifest( - [ - { path: 'slash/:id', component: DummyComponent }, - { path: 'encoded-slash/:id', component: DummyComponent }, - { path: 'backslash/:id', component: DummyComponent }, - { path: 'encoded-backslash/:id', component: DummyComponent }, - { path: 'dot-segment/:id', component: DummyComponent }, - { path: 'traversal/:id', component: DummyComponent }, - { path: 'empty/:id', component: DummyComponent }, - { path: 'tab/**', component: DummyComponent }, - ], - [ - { - path: 'slash/:id', - renderMode: RenderMode.Prerender, - async getPrerenderParams() { - return [{ id: 'a/b' }]; - }, - }, - { - path: 'encoded-slash/:id', - renderMode: RenderMode.Prerender, - async getPrerenderParams() { - return [{ id: 'a%2Fb' }]; - }, - }, - { - path: 'backslash/:id', - renderMode: RenderMode.Prerender, - async getPrerenderParams() { - return [{ id: '\\evil.test' }]; - }, - }, - { - path: 'encoded-backslash/:id', - renderMode: RenderMode.Prerender, - async getPrerenderParams() { - return [{ id: '%5Cevil.test' }]; - }, - }, - { - path: 'dot-segment/:id', - renderMode: RenderMode.Prerender, - async getPrerenderParams() { - return [{ id: '..' }]; - }, - }, - { - path: 'traversal/:id', - renderMode: RenderMode.Prerender, - async getPrerenderParams() { - return [{ id: '../a' }]; - }, - }, - { - path: 'empty/:id', - renderMode: RenderMode.Prerender, - async getPrerenderParams() { - return [{ id: '' }]; - }, - }, - { - path: 'tab/**', - renderMode: RenderMode.Prerender, - async getPrerenderParams() { - return [{ '**': 'a/\t/b' }]; - }, + invalidValues.map(([path]) => ({ path, component: DummyComponent })), + invalidValues.map(([path, value]) => ({ + path, + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + return [{ [path.endsWith('**') ? '**' : 'id']: value }]; }, - ], + })), ); const { errors } = await extractRoutesAndCreateRouteTree({ url, invokeGetPrerenderParams: true, }); - expect(errors).toEqual([ - jasmine.stringContaining(`'slash/:id' route returned an invalid value 'a/b'`), - jasmine.stringContaining(`'encoded-slash/:id' route returned an invalid value 'a%2Fb'`), - jasmine.stringContaining(`'backslash/:id' route returned an invalid value '\\evil.test'`), - jasmine.stringContaining( - `'encoded-backslash/:id' route returned an invalid value '%5Cevil.test'`, + expect(errors).toEqual( + invalidValues.map(([path, value]) => + jasmine.stringContaining(`'${path}' route returned an invalid value '${value}'`), ), - jasmine.stringContaining(`'dot-segment/:id' route returned an invalid value '..'`), - jasmine.stringContaining(`'traversal/:id' route returned an invalid value '../a'`), - jasmine.stringContaining(`'empty/:id' route returned an invalid value ''`), - jasmine.stringContaining( - `'tab/**' route returned an invalid value 'a/\t/b' for parameter '**'.`, - ), - ]); + ); }); it('should not error when a parameter value contains characters allowed in a path segment', async () => { setAngularAppTestingManifest( - [{ path: 'user/:id', component: DummyComponent }], + [ + { path: 'user/:id', component: DummyComponent }, + { path: 'docs/**', component: DummyComponent }, + ], [ { path: 'user/:id', renderMode: RenderMode.Prerender, fallback: PrerenderFallback.None, async getPrerenderParams() { - return [{ id: "customer's-choice" }, { id: 'café' }, { id: 'a b' }]; + return [ + { id: "customer's-choice" }, + { id: 'café' }, + { id: 'a b' }, + { id: 'a%20c' }, + { id: 'a:b' }, + ]; + }, + }, + { + path: 'docs/**', + renderMode: RenderMode.Prerender, + fallback: PrerenderFallback.None, + async getPrerenderParams() { + return [{ '**': '' }, { '**': 'a/b' }]; }, }, ], @@ -638,6 +604,10 @@ describe('extractRoutesAndCreateRouteTree', () => { { route: "/user/customer's-choice", renderMode: RenderMode.Prerender }, { route: '/user/café', renderMode: RenderMode.Prerender }, { route: '/user/a b', renderMode: RenderMode.Prerender }, + { route: '/user/a c', renderMode: RenderMode.Prerender }, + { route: '/user/a:b', renderMode: RenderMode.Prerender }, + { route: '/docs', renderMode: RenderMode.Prerender }, + { route: '/docs/a/b', renderMode: RenderMode.Prerender }, ]); }); }); From 8190c2026c0588fc1a5848b6885a4241b3a23f4e Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:01:13 -0500 Subject: [PATCH 3/3] fixup! fix(@angular/ssr): validate prerender parameter values --- packages/angular/ssr/src/routes/ng-routes.ts | 8 +- .../angular/ssr/test/routes/ng-routes_spec.ts | 78 ++++++++++--------- 2 files changed, 46 insertions(+), 40 deletions(-) diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index e02f9d3457ca..29410a5866cb 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -96,8 +96,7 @@ const URL_PARAMETER_GLOBAL_REGEXP = new RegExp(URL_PARAMETER_REGEXP, 'g'); * `http:` URLs), query and fragment delimiters, matrix parameters, route tree wildcards, `%` (route * segments are decoded again when the route tree is restored) and control characters. */ -// eslint-disable-next-line no-control-regex -const INVALID_PATH_SEGMENT_CHARS_REGEXP = /[/\\?#;*%\x00-\x1f\x7f]/; +const INVALID_PATH_SEGMENT_CHARS_REGEXP = /[/\\?#;*%\p{Control}]/u; /** * Additional metadata for a server configuration route tree. @@ -593,9 +592,8 @@ function handlePrerenderParamsReplacement( throw new Error( `The 'getPrerenderParams' function defined for the '${stripLeadingSlash(currentRoutePath)}' route ` + `returned an invalid value '${value}' for parameter '${parameterName}'. ` + - `Parameter values must be non-empty path segments other than '.' and '..'. Once ` + - `percent-decoded, they cannot start with ':' or contain '/', '\\', '?', '#', ';', '*', '%' ` + - `or control characters. Only '**' values can be empty or use '/' to separate segments.`, + `Please make sure the value is a valid URL path${parameterName === '**' ? '' : ' segment'} ` + + 'without dot segments or reserved characters.', ); } diff --git a/packages/angular/ssr/test/routes/ng-routes_spec.ts b/packages/angular/ssr/test/routes/ng-routes_spec.ts index e2d06724a18b..5c3eb325050e 100644 --- a/packages/angular/ssr/test/routes/ng-routes_spec.ts +++ b/packages/angular/ssr/test/routes/ng-routes_spec.ts @@ -524,43 +524,51 @@ describe('extractRoutesAndCreateRouteTree', () => { it('should error when a parameter value is not a valid path segment', async () => { const invalidValues: [path: string, value: string][] = [ - ['slash/:id', 'a/b'], - ['encoded-slash/:id', 'a%2Fb'], - ['backslash/:id', '\\..\\evil.test'], - ['encoded-backslash/:id', '%5Cevil.test'], - ['double-encoded-backslash/:id', '%255Cevil.test'], - ['malformed/:id', '50%-off'], - ['dot/:id', '.'], - ['dot-dot/:id', '..'], - ['query/:id', '..?'], - ['fragment/:id', 'a#b'], - ['matrix-param/:id', 'a;b'], - ['wildcard/:id', '*'], - ['param/:id', ':id'], - ['empty/:id', ''], - ['empty-segment/**', '//evil.test'], - ['tab/**', 'a/\t/b'], + ['users/:id', 'a/b'], + ['users/:id', 'a%2Fb'], + ['users/:id', '\\..\\evil.test'], + ['users/:id', '%5Cevil.test'], + ['users/:id', '%255Cevil.test'], + ['users/:id', '50%-off'], + ['users/:id', '.'], + ['users/:id', '..'], + ['users/:id', '..?'], + ['users/:id', 'a#b'], + ['users/:id', 'a;b'], + ['users/:id', '*'], + ['users/:id', ':id'], + ['users/:id', ''], + ['docs/**', '//evil.test'], + ['docs/**', 'a/\t/b'], ]; - setAngularAppTestingManifest( - invalidValues.map(([path]) => ({ path, component: DummyComponent })), - invalidValues.map(([path, value]) => ({ - path, - renderMode: RenderMode.Prerender, - async getPrerenderParams() { - return [{ [path.endsWith('**') ? '**' : 'id']: value }]; - }, - })), - ); - const { errors } = await extractRoutesAndCreateRouteTree({ - url, - invokeGetPrerenderParams: true, - }); - expect(errors).toEqual( - invalidValues.map(([path, value]) => - jasmine.stringContaining(`'${path}' route returned an invalid value '${value}'`), - ), - ); + for (const [path, value] of invalidValues) { + const paramName = path.endsWith('**') ? '**' : 'id'; + setAngularAppTestingManifest( + [{ path, component: DummyComponent }], + [ + { + path, + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + return [{ [paramName]: value }]; + }, + }, + ], + ); + + const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ + url, + invokeGetPrerenderParams: true, + }); + expect(errors).toEqual([ + jasmine.stringContaining( + `The 'getPrerenderParams' function defined for the '${path}' route ` + + `returned an invalid value '${value}' for parameter '${paramName}'.`, + ), + ]); + expect(routeTree.toObject()).toEqual([]); + } }); it('should not error when a parameter value contains characters allowed in a path segment', async () => {