Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/angular/ssr/src/routes/ng-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ const URL_PARAMETER_REGEXP = /(?<!\\):([^/]+)/;
*/
const URL_PARAMETER_GLOBAL_REGEXP = new RegExp(URL_PARAMETER_REGEXP, 'g');

/**
* Characters that cannot appear in a percent-decoded path segment: path separators (`\` is one in
* `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.
*/
const INVALID_PATH_SEGMENT_CHARS_REGEXP = /[/\\?#;*%\p{Control}]/u;

/**
* Additional metadata for a server configuration route tree.
*/
Expand Down Expand Up @@ -580,10 +587,43 @@ function handlePrerenderParamsReplacement(
);
}

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}'. ` +
`Please make sure the value is a valid URL path${parameterName === '**' ? '' : ' segment'} ` +
'without dot segments or reserved characters.',
);
Comment thread
SkyZeroZx marked this conversation as resolved.
Resolved
}

return parameterName === '**' ? `/${value}` : value;
};
}

/**
* Checks whether a value is a single, non-empty path segment.
*
* @param segment - The percent-encoded segment to check.
* @returns `true` if the value is a single, non-empty path segment.
*/
function isPathSegment(segment: string): boolean {
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
return false;
}

return (
decoded.length > 0 &&
decoded !== '.' &&
decoded !== '..' &&
decoded[0] !== ':' &&
!INVALID_PATH_SEGMENT_CHARS_REGEXP.test(decoded)
);
}

/**
* Resolves the `redirectTo` property for a given route, replacing any `:id` route parameters
* in its path and resolving relative paths against `parentRoute`.
Expand Down
97 changes: 97 additions & 0 deletions packages/angular/ssr/test/routes/ng-routes_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,103 @@ describe('extractRoutesAndCreateRouteTree', () => {
{ route: '/profile/*', renderMode: RenderMode.Server },
]);
});

it('should error when a parameter value is not a valid path segment', async () => {
const invalidValues: [path: string, value: string][] = [
['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'],
];

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([]);
}
});
Comment thread
SkyZeroZx marked this conversation as resolved.
Resolved

it('should not error when a parameter value contains characters allowed in a path segment', async () => {
setAngularAppTestingManifest(
[
{ 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' },
{ id: 'a%20c' },
{ id: 'a:b' },
];
},
},
{
path: 'docs/**',
renderMode: RenderMode.Prerender,
fallback: PrerenderFallback.None,
async getPrerenderParams() {
return [{ '**': '' }, { '**': '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 },
{ 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 },
]);
});
});

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