Skip to content

Commit fe24eb5

Browse files
authored
fix(astro): Avoid RegExp creation during route interpolation (#9815)
- iterate over route segments to replace parameter values - decode raw url to match previously unmatched param values - prioritize multi-segment [rest parameters](https://docs.astro.build/en/core-concepts/routing/#rest-parameters) before iterating over individual segments
1 parent 35f177a commit fe24eb5

File tree

2 files changed

+140
-14
lines changed

2 files changed

+140
-14
lines changed

packages/astro/src/server/middleware.ts

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,7 @@ import {
77
startSpan,
88
} from '@sentry/node';
99
import type { Hub, Span } from '@sentry/types';
10-
import {
11-
addNonEnumerableProperty,
12-
objectify,
13-
stripUrlQueryAndFragment,
14-
tracingContextFromHeaders,
15-
} from '@sentry/utils';
10+
import { addNonEnumerableProperty, objectify, stripUrlQueryAndFragment } from '@sentry/utils';
1611
import type { APIContext, MiddlewareResponseHandler } from 'astro';
1712

1813
import { getTracingMetaTags } from './meta';
@@ -64,7 +59,11 @@ type AstroLocalsWithSentry = Record<string, unknown> & {
6459
};
6560

6661
export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseHandler = options => {
67-
const handlerOptions = { trackClientIp: false, trackHeaders: false, ...options };
62+
const handlerOptions = {
63+
trackClientIp: false,
64+
trackHeaders: false,
65+
...options,
66+
};
6867

6968
return async (ctx, next) => {
7069
// if there is an active span, we know that this handle call is nested and hence
@@ -113,18 +112,19 @@ async function instrumentRequest(
113112
}
114113

115114
try {
115+
const interpolatedRoute = interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params);
116116
// storing res in a variable instead of directly returning is necessary to
117117
// invoke the catch block if next() throws
118118
const res = await startSpan(
119119
{
120120
...traceCtx,
121-
name: `${method} ${interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params)}`,
121+
name: `${method} ${interpolatedRoute || ctx.url.pathname}`,
122122
op: 'http.server',
123123
origin: 'auto.http.astro',
124124
status: 'ok',
125125
metadata: {
126126
...traceCtx?.metadata,
127-
source: 'route',
127+
source: interpolatedRoute ? 'route' : 'url',
128128
},
129129
data: {
130130
method,
@@ -202,10 +202,76 @@ function addMetaTagToHead(htmlChunk: string, hub: Hub, span?: Span): string {
202202
* Best we can do to get a route name instead of a raw URL.
203203
*
204204
* exported for testing
205+
*
206+
* @param rawUrlPathname - The raw URL pathname, e.g. '/users/123/details'
207+
* @param params - The params object, e.g. `{ userId: '123' }`
208+
*
209+
* @returns The interpolated route, e.g. '/users/[userId]/details'
205210
*/
206-
export function interpolateRouteFromUrlAndParams(rawUrl: string, params: APIContext['params']): string {
207-
return Object.entries(params).reduce((interpolateRoute, value) => {
208-
const [paramId, paramValue] = value;
209-
return interpolateRoute.replace(new RegExp(`(/|-)${paramValue}(/|-|$)`), `$1[${paramId}]$2`);
210-
}, rawUrl);
211+
export function interpolateRouteFromUrlAndParams(
212+
rawUrlPathname: string,
213+
params: APIContext['params'],
214+
): string | undefined {
215+
const decodedUrlPathname = tryDecodeUrl(rawUrlPathname);
216+
if (!decodedUrlPathname) {
217+
return undefined;
218+
}
219+
220+
// Invert params map so that the param values are the keys
221+
// differentiate between rest params spanning multiple url segments
222+
// and normal, single-segment params.
223+
const valuesToMultiSegmentParams: Record<string, string> = {};
224+
const valuesToParams: Record<string, string> = {};
225+
Object.entries(params).forEach(([key, value]) => {
226+
if (!value) {
227+
return;
228+
}
229+
if (value.includes('/')) {
230+
valuesToMultiSegmentParams[value] = key;
231+
return;
232+
}
233+
valuesToParams[value] = key;
234+
});
235+
236+
function replaceWithParamName(segment: string): string {
237+
const param = valuesToParams[segment];
238+
if (param) {
239+
return `[${param}]`;
240+
}
241+
return segment;
242+
}
243+
244+
// before we match single-segment params, we first replace multi-segment params
245+
const urlWithReplacedMultiSegmentParams = Object.keys(valuesToMultiSegmentParams).reduce((acc, key) => {
246+
return acc.replace(key, `[${valuesToMultiSegmentParams[key]}]`);
247+
}, decodedUrlPathname);
248+
249+
return urlWithReplacedMultiSegmentParams
250+
.split('/')
251+
.map(segment => {
252+
if (!segment) {
253+
return '';
254+
}
255+
256+
if (valuesToParams[segment]) {
257+
return replaceWithParamName(segment);
258+
}
259+
260+
// astro permits multiple params in a single path segment, e.g. /[foo]-[bar]/
261+
const segmentParts = segment.split('-');
262+
if (segmentParts.length > 1) {
263+
return segmentParts.map(part => replaceWithParamName(part)).join('-');
264+
}
265+
266+
return segment;
267+
})
268+
.join('/');
269+
}
270+
271+
function tryDecodeUrl(url: string): string | undefined {
272+
try {
273+
return decodeURI(url);
274+
} catch {
275+
return undefined;
276+
}
211277
}

packages/astro/test/server/middleware.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,43 @@ describe('sentryMiddleware', () => {
6969
expect(resultFromNext).toStrictEqual(nextResult);
7070
});
7171

72+
it("sets source route if the url couldn't be decoded correctly", async () => {
73+
const middleware = handleRequest();
74+
const ctx = {
75+
request: {
76+
method: 'GET',
77+
url: '/a%xx',
78+
headers: new Headers(),
79+
},
80+
url: { pathname: 'a%xx', href: 'http://localhost:1234/a%xx' },
81+
params: {},
82+
};
83+
const next = vi.fn(() => nextResult);
84+
85+
// @ts-expect-error, a partial ctx object is fine here
86+
const resultFromNext = middleware(ctx, next);
87+
88+
expect(startSpanSpy).toHaveBeenCalledWith(
89+
{
90+
data: {
91+
method: 'GET',
92+
url: 'http://localhost:1234/a%xx',
93+
},
94+
metadata: {
95+
source: 'url',
96+
},
97+
name: 'GET a%xx',
98+
op: 'http.server',
99+
origin: 'auto.http.astro',
100+
status: 'ok',
101+
},
102+
expect.any(Function), // the `next` function
103+
);
104+
105+
expect(next).toHaveBeenCalled();
106+
expect(resultFromNext).toStrictEqual(nextResult);
107+
});
108+
72109
it('throws and sends an error to sentry if `next()` throws', async () => {
73110
const captureExceptionSpy = vi.spyOn(SentryNode, 'captureException');
74111

@@ -299,15 +336,31 @@ describe('sentryMiddleware', () => {
299336

300337
describe('interpolateRouteFromUrlAndParams', () => {
301338
it.each([
339+
['/', {}, '/'],
302340
['/foo/bar', {}, '/foo/bar'],
303341
['/users/123', { id: '123' }, '/users/[id]'],
304342
['/users/123', { id: '123', foo: 'bar' }, '/users/[id]'],
305343
['/lang/en-US', { lang: 'en', region: 'US' }, '/lang/[lang]-[region]'],
306344
['/lang/en-US/posts', { lang: 'en', region: 'US' }, '/lang/[lang]-[region]/posts'],
345+
// edge cases that astro doesn't support
346+
['/lang/-US', { region: 'US' }, '/lang/-[region]'],
347+
['/lang/en-', { lang: 'en' }, '/lang/[lang]-'],
307348
])('interpolates route from URL and params %s', (rawUrl, params, expectedRoute) => {
308349
expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute);
309350
});
310351

352+
it.each([
353+
['/(a+)+/aaaaaaaaa!', { id: '(a+)+', slug: 'aaaaaaaaa!' }, '/[id]/[slug]'],
354+
['/([a-zA-Z]+)*/aaaaaaaaa!', { id: '([a-zA-Z]+)*', slug: 'aaaaaaaaa!' }, '/[id]/[slug]'],
355+
['/(a|aa)+/aaaaaaaaa!', { id: '(a|aa)+', slug: 'aaaaaaaaa!' }, '/[id]/[slug]'],
356+
['/(a|a?)+/aaaaaaaaa!', { id: '(a|a?)+', slug: 'aaaaaaaaa!' }, '/[id]/[slug]'],
357+
// with URL encoding
358+
['/(a%7Caa)+/aaaaaaaaa!', { id: '(a|aa)+', slug: 'aaaaaaaaa!' }, '/[id]/[slug]'],
359+
['/(a%7Ca?)+/aaaaaaaaa!', { id: '(a|a?)+', slug: 'aaaaaaaaa!' }, '/[id]/[slug]'],
360+
])('handles regex characters in param values correctly %s', (rawUrl, params, expectedRoute) => {
361+
expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute);
362+
});
363+
311364
it('handles params across multiple URL segments in catchall routes', () => {
312365
// Ideally, Astro would let us know that this is a catchall route so we can make the param [...catchall] but it doesn't
313366
expect(
@@ -324,4 +377,11 @@ describe('interpolateRouteFromUrlAndParams', () => {
324377
const expectedRoute = '/usernames/[name]';
325378
expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute);
326379
});
380+
381+
it('handles set but undefined params', () => {
382+
const rawUrl = '/usernames/user';
383+
const params = { name: undefined, name2: '' };
384+
const expectedRoute = '/usernames/user';
385+
expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute);
386+
});
327387
});

0 commit comments

Comments
 (0)