Skip to content

Commit 72ad458

Browse files
committed
fix(astro): Avoid RegExp creation during route interpolation
avoid regexp
1 parent b3f8d9b commit 72ad458

File tree

2 files changed

+135
-14
lines changed

2 files changed

+135
-14
lines changed

packages/astro/src/server/middleware.ts

Lines changed: 75 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, escapeStringForRegex, 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,
@@ -203,9 +203,70 @@ function addMetaTagToHead(htmlChunk: string, hub: Hub, span?: Span): string {
203203
*
204204
* exported for testing
205205
*/
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);
206+
export function interpolateRouteFromUrlAndParams(
207+
rawUrlPathname: string,
208+
params: APIContext['params'],
209+
): string | undefined {
210+
const decodedUrlPathname = tryDecodeUrl(rawUrlPathname);
211+
if (!decodedUrlPathname) {
212+
return undefined;
213+
}
214+
215+
// Invert params map so that the param values are the keys
216+
// differentiate between rest params spanning multiple url segments
217+
// and normal, single-segment params.
218+
const valuesToMultiSegmentParams: Record<string, string> = {};
219+
const valuesToParams: Record<string, string> = {};
220+
Object.entries(params).forEach(([key, value]) => {
221+
if (!value) {
222+
return;
223+
}
224+
if (value.includes('/')) {
225+
valuesToMultiSegmentParams[value] = key;
226+
return;
227+
}
228+
valuesToParams[value] = key;
229+
});
230+
231+
function replaceWithParamName(segment: string): string {
232+
const param = valuesToParams[segment];
233+
if (param) {
234+
return `[${param}]`;
235+
}
236+
return segment;
237+
}
238+
239+
// before we match single-segment params, we first replace multi-segment params
240+
const urlWithReplacedMultiSegmentParams = Object.keys(valuesToMultiSegmentParams).reduce((acc, key) => {
241+
return acc.replace(key, `[${valuesToMultiSegmentParams[key]}]`);
242+
}, decodedUrlPathname);
243+
244+
return urlWithReplacedMultiSegmentParams
245+
.split('/')
246+
.map(segment => {
247+
if (!segment) {
248+
return '';
249+
}
250+
251+
if (valuesToParams[segment]) {
252+
return replaceWithParamName(segment);
253+
}
254+
255+
// astro permits multiple params in a single path segment, e.g. /[foo]-[bar]/
256+
const segmentParts = segment.split('-');
257+
if (segmentParts.length > 1) {
258+
return segmentParts.map(part => replaceWithParamName(part)).join('-');
259+
}
260+
261+
return segment;
262+
})
263+
.join('/');
264+
}
265+
266+
function tryDecodeUrl(url: string): string | undefined {
267+
try {
268+
return decodeURI(url);
269+
} catch {
270+
return undefined;
271+
}
211272
}

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)