Skip to content

Commit b1e21fa

Browse files
committed
fix(core): prevent ${} re-interpretation in getRoutePath and escape single quotes in getRouteAsArray
getRoutePath: after jsesc escapes ${ to \${, the remaining {evil} was mistaken for an OpenAPI path param and re-converted to ${evil}. Add early-return when { is preceded by $. getRouteAsArray: segments wrapped in single-quoted strings without escaping '. A spec path containing ' would break out. Now escapes single quotes at both wrap sites.
1 parent e09afc1 commit b1e21fa

2 files changed

Lines changed: 39 additions & 8 deletions

File tree

packages/core/src/getters/route.test.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -265,12 +265,35 @@ describe('getRoute — spec path injection', () => {
265265
expect(result).not.toMatch(/(?<!\\)`/);
266266
});
267267

268-
it('neutralizes ${...} that is not a valid path param', () => {
269-
// ${globalThis.X} in a path is NOT an OpenAPI path param ({param}).
270-
// getRoutePath's regex rejects it (dot in param name), so it stays
271-
// as literal text — no live interpolation.
272-
const result = getRoute('/v1/${globalThis.X}/path');
273-
expect(result).not.toMatch(/(?<!\\)\$\{globalThis/);
268+
it('does not re-interpret ${...} as a live interpolation', () => {
269+
// ${evil} in a path is NOT an OpenAPI path param ({param}).
270+
// jsesc escapes ${ to \${, and getRoutePath must not treat the
271+
// remaining {evil} as a param — otherwise it re-creates ${evil}.
272+
for (const payload of [
273+
'/v1/${evil}/path',
274+
'/v1/${globalThis.X}/path',
275+
'/v1/{petId}${evil}/path',
276+
]) {
277+
const result = getRoute(payload);
278+
expect(result).not.toMatch(/(?<!\\)\$\{evil/);
279+
expect(result).not.toMatch(/(?<!\\)\$\{globalThis/);
280+
}
281+
});
282+
283+
it('still converts legitimate path params', () => {
284+
expect(getRoute('/v1/{petId}')).toContain('${petId}');
285+
});
286+
});
287+
288+
describe('getRouteAsArray — single-quote injection', () => {
289+
it('escapes single quote in static segment', () => {
290+
const result = getRouteAsArray("v1/it's/path");
291+
expect(result).toContain("it\\'s");
292+
});
293+
294+
it('escapes single quote in non-interpolation part of mixed segment', () => {
295+
const result = getRouteAsArray("pre's${petId}");
296+
expect(result).toContain("pre\\'s");
274297
});
275298
});
276299

packages/core/src/getters/route.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ function runtimeExpressionToUrlPrefix(expression: string): string {
3434
const hasParam = (path: string): boolean => /[^{]*{[\w*_-]*}.*/.test(path);
3535

3636
const getRoutePath = (path: string): string => {
37+
// Don't treat ${...} as a path param — OpenAPI params use {param}, not
38+
// ${param}. After jsesc boundary escaping, ${ becomes \${, but the { is
39+
// still visible to the regex below and would be misinterpreted as a param.
40+
const braceIdx = path.indexOf('{');
41+
if (braceIdx > 0 && path[braceIdx - 1] === '$') {
42+
return path;
43+
}
44+
3745
const matches = /([^{]*){?([\w*_-]*)}?(.*)/.exec(path);
3846
if (!matches?.length) return path; // impossible due to regexp grouping here, but for TS
3947

@@ -182,15 +190,15 @@ export function getRouteAsArray(route: string): string {
182190
.filter((i) => i !== '')
183191
.flatMap((segment) => {
184192
if (!segment.includes('${')) {
185-
return [`'${segment}'`];
193+
return [`'${segment.replaceAll("'", "\\'")}'`];
186194
}
187195
// Split by template tags, keeping the delimiters
188196
return segment
189197
.split(/(\$\{.+?\})/g)
190198
.filter(Boolean)
191199
.map((part) => {
192200
const match = /^\$\{(.+?)\}$/.exec(part);
193-
return match ? match[1] : `'${part}'`;
201+
return match ? match[1] : `'${part.replaceAll("'", "\\'")}'`;
194202
});
195203
})
196204
.join(',');

0 commit comments

Comments
 (0)