Skip to content

Commit 4ec7b37

Browse files
committed
fix(astro): Escape param values during route interpolation
1 parent b3f8d9b commit 4ec7b37

File tree

2 files changed

+87
-12
lines changed

2 files changed

+87
-12
lines changed

packages/astro/src/server/middleware.ts

Lines changed: 31 additions & 12 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,28 @@ 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 {
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+
207215
return Object.entries(params).reduce((interpolateRoute, value) => {
208216
const [paramId, paramValue] = value;
209-
return interpolateRoute.replace(new RegExp(`(/|-)${paramValue}(/|-|$)`), `$1[${paramId}]$2`);
210-
}, rawUrl);
217+
if (!paramValue) {
218+
return interpolateRoute;
219+
}
220+
return interpolateRoute.replace(new RegExp(`(/|-)${escapeStringForRegex(paramValue)}(/|-|$)`), `$1[${paramId}]$2`);
221+
}, decodedUrlPathname);
222+
}
223+
224+
function tryDecodeUrl(url: string): string | undefined {
225+
try {
226+
return decodeURI(url);
227+
} catch {
228+
return undefined;
229+
}
211230
}

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

Lines changed: 56 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

@@ -308,6 +345,18 @@ describe('interpolateRouteFromUrlAndParams', () => {
308345
expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute);
309346
});
310347

348+
it.each([
349+
['/(a+)+/aaaaaaaaa!', { id: '(a+)+', slug: 'aaaaaaaaa!' }, '/[id]/[slug]'],
350+
['/([a-zA-Z]+)*/aaaaaaaaa!', { id: '([a-zA-Z]+)*', slug: 'aaaaaaaaa!' }, '/[id]/[slug]'],
351+
['/(a|aa)+/aaaaaaaaa!', { id: '(a|aa)+', slug: 'aaaaaaaaa!' }, '/[id]/[slug]'],
352+
['/(a|a?)+/aaaaaaaaa!', { id: '(a|a?)+', slug: 'aaaaaaaaa!' }, '/[id]/[slug]'],
353+
// with URL encoding
354+
['/(a%7Caa)+/aaaaaaaaa!', { id: '(a|aa)+', slug: 'aaaaaaaaa!' }, '/[id]/[slug]'],
355+
['/(a%7Ca?)+/aaaaaaaaa!', { id: '(a|a?)+', slug: 'aaaaaaaaa!' }, '/[id]/[slug]'],
356+
])('handles regex characters in param values correctly %s', (rawUrl, params, expectedRoute) => {
357+
expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute);
358+
});
359+
311360
it('handles params across multiple URL segments in catchall routes', () => {
312361
// Ideally, Astro would let us know that this is a catchall route so we can make the param [...catchall] but it doesn't
313362
expect(
@@ -324,4 +373,11 @@ describe('interpolateRouteFromUrlAndParams', () => {
324373
const expectedRoute = '/usernames/[name]';
325374
expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute);
326375
});
376+
377+
it('handles set but undefined params', () => {
378+
const rawUrl = '/usernames/user';
379+
const params = { name: undefined };
380+
const expectedRoute = '/usernames/user';
381+
expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute);
382+
});
327383
});

0 commit comments

Comments
 (0)