Skip to content

Commit 50a2c11

Browse files
committed
Address seer reviews
1 parent 672e215 commit 50a2c11

File tree

8 files changed

+203
-145
lines changed

8 files changed

+203
-145
lines changed

packages/remix/src/client/remixRouteParameterization.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,9 @@ function getManifest(): RouteManifest | null {
8282
};
8383

8484
try {
85-
// The manifest is double-stringified for XSS prevention in the Vite plugin,
86-
// so we need to parse twice: once to get the JSON string, then again to get the object
87-
const manifestJsonString = JSON.parse(currentManifestString);
88-
manifest = JSON.parse(manifestJsonString);
85+
// The manifest string is JSON-stringified in the Vite plugin for safe injection into JavaScript.
86+
// We parse once to convert the JSON string back to an object.
87+
manifest = JSON.parse(currentManifestString);
8988
if (!Array.isArray(manifest.staticRoutes) || !Array.isArray(manifest.dynamicRoutes)) {
9089
return null;
9190
}

packages/remix/src/config/createRemixRouteManifest.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,14 @@ function isRouteFile(filename: string): boolean {
3636
* - users/$id.tsx (nested folder) -> /users/:id
3737
* - users/$id/posts.tsx (nested folder) -> /users/:id/posts
3838
* - users/index.tsx (nested folder) -> /users
39+
* - _layout.tsx -> null (pathless layout route, not URL-addressable)
40+
* - _auth.tsx -> null (pathless layout route, not URL-addressable)
3941
*
4042
* @param filename - The route filename or path (can include directory separators for nested routes)
41-
* @returns Object containing the parameterized path and whether it's dynamic
43+
* @returns Object containing the parameterized path and whether it's dynamic, or null for pathless layout routes
4244
* @internal Exported for testing purposes
4345
*/
44-
export function convertRemixRouteToPath(filename: string): { path: string; isDynamic: boolean } {
46+
export function convertRemixRouteToPath(filename: string): { path: string; isDynamic: boolean } | null {
4547
// Remove file extension
4648
const basename = filename.replace(/\.(tsx?|jsx?)$/, '');
4749

@@ -85,6 +87,12 @@ export function convertRemixRouteToPath(filename: string): { path: string; isDyn
8587
}
8688
}
8789

90+
// If all segments were skipped (pathless layout route like _layout.tsx, _auth.tsx),
91+
// return null to indicate this file should not be added to the route manifest
92+
if (pathSegments.length === 0) {
93+
return null;
94+
}
95+
8896
const path = `/${pathSegments.join('/')}`;
8997
return { path, isDynamic };
9098
}
@@ -151,7 +159,14 @@ function scanRoutesDirectory(
151159
staticRoutes.push(...nested.staticRoutes);
152160
} else if (stat.isFile() && isRouteFile(entry)) {
153161
const routeName = prefix ? `${prefix}/${entry}` : entry;
154-
const { path: routePath, isDynamic } = convertRemixRouteToPath(routeName);
162+
const result = convertRemixRouteToPath(routeName);
163+
164+
// Skip pathless layout routes (e.g., _layout.tsx, _auth.tsx)
165+
if (result === null) {
166+
continue;
167+
}
168+
169+
const { path: routePath, isDynamic } = result;
155170

156171
if (isDynamic) {
157172
const { regex, paramNames } = buildRegexForDynamicRoute(routePath);

packages/remix/src/config/vite.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import * as path from 'path';
22
import type { Plugin } from 'vite';
33
import { createRemixRouteManifest } from './createRemixRouteManifest';
44

5+
/**
6+
* Escapes a JSON string for safe embedding in HTML script tags.
7+
* JSON.stringify alone doesn't escape </script> or <!-- which can break out of script context.
8+
*/
9+
function escapeJsonForHtml(jsonString: string): string {
10+
return jsonString
11+
.replace(/<\//g, '<\\/') // Escape </ to prevent </script> injection
12+
.replace(/<!--/g, '<\\!--'); // Escape <!-- to prevent HTML comment injection
13+
}
14+
515
export type SentryRemixVitePluginOptions = {
616
/**
717
* Path to the app directory (where routes folder is located).
@@ -87,11 +97,11 @@ export function sentryRemixVitePlugin(options: SentryRemixVitePluginOptions = {}
8797
}
8898

8999
/**
90-
* XSS Prevention: Double-stringify strategy prevents injection from malicious route filenames.
91-
* routeManifestJson is already stringified once, stringifying again escapes special chars.
92-
* Client-side code parses once to recover the manifest object.
100+
* XSS Prevention: JSON.stringify escapes quotes/backslashes, but we also need to escape
101+
* HTML-dangerous sequences like </script> and <!-- that could break out of the script context.
93102
*/
94-
const script = `<script>window.${MANIFEST_GLOBAL_KEY} = ${JSON.stringify(routeManifestJson)};</script>`;
103+
const safeJsonValue = escapeJsonForHtml(JSON.stringify(routeManifestJson));
104+
const script = `<script>window.${MANIFEST_GLOBAL_KEY} = ${safeJsonValue};</script>`;
95105

96106
if (/<head>/i.test(html)) {
97107
return html.replace(/<head>/i, match => `${match}\n ${script}`);
@@ -125,11 +135,12 @@ export function sentryRemixVitePlugin(options: SentryRemixVitePluginOptions = {}
125135
/(^|\/)server\.[jt]sx?$/.test(id);
126136

127137
if (isClientEntry) {
128-
// XSS Prevention: Double-stringify strategy (same as transformIndexHtml above)
138+
// XSS Prevention: Escape HTML-dangerous sequences in addition to JSON escaping
139+
const safeJsonValue = escapeJsonForHtml(JSON.stringify(routeManifestJson));
129140
const injectedCode = `
130141
// Sentry Remix Route Manifest - Auto-injected
131142
if (typeof window !== 'undefined') {
132-
window.${MANIFEST_GLOBAL_KEY} = window.${MANIFEST_GLOBAL_KEY} || ${JSON.stringify(routeManifestJson)};
143+
window.${MANIFEST_GLOBAL_KEY} = window.${MANIFEST_GLOBAL_KEY} || ${safeJsonValue};
133144
}
134145
${code}`;
135146

@@ -142,10 +153,12 @@ ${code}`;
142153
if (isServerEntry) {
143154
// Inject into server entry for server-side transaction naming
144155
// Use globalThis for Cloudflare Workers/Hydrogen compatibility
156+
// XSS Prevention: Escape HTML-dangerous sequences (important if server renders this)
157+
const safeJsonValue = escapeJsonForHtml(JSON.stringify(routeManifestJson));
145158
const injectedCode = `
146159
// Sentry Remix Route Manifest - Auto-injected
147160
if (typeof globalThis !== 'undefined') {
148-
globalThis.${MANIFEST_GLOBAL_KEY} = globalThis.${MANIFEST_GLOBAL_KEY} || ${JSON.stringify(routeManifestJson)};
161+
globalThis.${MANIFEST_GLOBAL_KEY} = globalThis.${MANIFEST_GLOBAL_KEY} || ${safeJsonValue};
149162
}
150163
${code}`;
151164

packages/remix/src/utils/utils.ts

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ActionFunctionArgs, LoaderFunctionArgs, ServerBuild } from '@remix-run/node';
22
import type { AgnosticRouteObject } from '@remix-run/router';
33
import type { Span, TransactionSource } from '@sentry/core';
4-
import { debug, GLOBAL_OBJ } from '@sentry/core';
4+
import { debug } from '@sentry/core';
55
import { DEBUG_BUILD } from './debug-build';
66
import { getRequestMatch, matchServerRoutes } from './vendor/response';
77

@@ -103,21 +103,6 @@ export function convertRemixRouteIdToPath(routeId: string): string {
103103
return routePath;
104104
}
105105

106-
/**
107-
* Check if the Vite plugin manifest is available.
108-
* @returns True if the manifest is available, false otherwise.
109-
*/
110-
function hasManifest(): boolean {
111-
const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
112-
_sentryRemixRouteManifest: string | undefined;
113-
};
114-
115-
return (
116-
!!globalWithInjectedManifest?._sentryRemixRouteManifest &&
117-
typeof globalWithInjectedManifest._sentryRemixRouteManifest === 'string'
118-
);
119-
}
120-
121106
/**
122107
* Get transaction name from routes and url
123108
*/
@@ -131,15 +116,10 @@ export function getTransactionName(routes: AgnosticRouteObject[], url: URL): [st
131116

132117
const routeId = match.route.id || 'no-route-id';
133118

134-
// Only use parameterized path if the Vite plugin manifest is available
135-
// This ensures backward compatibility - without the plugin, we use the old behavior
136-
if (hasManifest()) {
137-
const parameterizedPath = convertRemixRouteIdToPath(routeId);
138-
return [parameterizedPath, 'route'];
139-
}
140-
141-
// Fallback to old behavior (route ID) when manifest is not available
142-
return [routeId, 'route'];
119+
// Convert route ID to parameterized path (e.g., "routes/users.$id" -> "/users/:id")
120+
// This is a pure string transformation that works without the Vite plugin manifest
121+
const parameterizedPath = convertRemixRouteIdToPath(routeId);
122+
return [parameterizedPath, 'route'];
143123
}
144124

145125
/**

packages/remix/test/client/remixRouteParameterization.test.ts

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,6 @@ const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
77
_sentryRemixRouteManifest: string | undefined;
88
};
99

10-
/**
11-
* Helper to simulate the Vite plugin's double-stringify behavior for XSS prevention.
12-
* The Vite plugin stringifies the manifest once, then stringifies again when injecting into JS.
13-
*/
14-
function doubleStringify(manifest: RouteManifest): string {
15-
return JSON.stringify(JSON.stringify(manifest));
16-
}
17-
1810
describe('maybeParameterizeRemixRoute', () => {
1911
const originalManifest = globalWithInjectedManifest._sentryRemixRouteManifest;
2012

@@ -38,7 +30,7 @@ describe('maybeParameterizeRemixRoute', () => {
3830
staticRoutes: [{ path: '/' }, { path: '/about' }, { path: '/contact' }, { path: '/blog/posts' }],
3931
dynamicRoutes: [],
4032
};
41-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
33+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
4234

4335
expect(maybeParameterizeRemixRoute('/')).toBeUndefined();
4436
expect(maybeParameterizeRemixRoute('/about')).toBeUndefined();
@@ -69,7 +61,7 @@ describe('maybeParameterizeRemixRoute', () => {
6961
},
7062
],
7163
};
72-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
64+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
7365

7466
expect(maybeParameterizeRemixRoute('/users/123')).toBe('/users/:id');
7567
expect(maybeParameterizeRemixRoute('/users/john-doe')).toBe('/users/:id');
@@ -90,7 +82,7 @@ describe('maybeParameterizeRemixRoute', () => {
9082
},
9183
],
9284
};
93-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
85+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
9486

9587
expect(maybeParameterizeRemixRoute('/')).toBeUndefined();
9688
expect(maybeParameterizeRemixRoute('/about')).toBeUndefined();
@@ -112,7 +104,7 @@ describe('maybeParameterizeRemixRoute', () => {
112104
},
113105
],
114106
};
115-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
107+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
116108

117109
expect(maybeParameterizeRemixRoute('/docs/intro')).toBe('/docs/:*');
118110
expect(maybeParameterizeRemixRoute('/docs/guide/getting-started')).toBe('/docs/:*');
@@ -130,7 +122,7 @@ describe('maybeParameterizeRemixRoute', () => {
130122
},
131123
],
132124
};
133-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
125+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
134126

135127
expect(maybeParameterizeRemixRoute('/users/user-with-dashes/settings')).toBe('/users/:id/settings');
136128
expect(maybeParameterizeRemixRoute('/users/user_with_underscores/settings')).toBe('/users/:id/settings');
@@ -153,7 +145,7 @@ describe('maybeParameterizeRemixRoute', () => {
153145
},
154146
],
155147
};
156-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
148+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
157149

158150
// Should prefer more specific route over catch-all
159151
expect(maybeParameterizeRemixRoute('/users/123')).toBe('/users/:id');
@@ -170,7 +162,7 @@ describe('maybeParameterizeRemixRoute', () => {
170162
},
171163
],
172164
};
173-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
165+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
174166

175167
expect(maybeParameterizeRemixRoute('/users/123')).toBeUndefined();
176168
});
@@ -186,7 +178,7 @@ describe('maybeParameterizeRemixRoute', () => {
186178
},
187179
],
188180
};
189-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
181+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
190182

191183
expect(maybeParameterizeRemixRoute('/users/123')).toBeUndefined();
192184
});
@@ -204,7 +196,7 @@ describe('maybeParameterizeRemixRoute', () => {
204196
},
205197
],
206198
};
207-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
199+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
208200

209201
expect(maybeParameterizeRemixRoute('/unknown')).toBeUndefined();
210202
expect(maybeParameterizeRemixRoute('/posts/123')).toBeUndefined();
@@ -218,7 +210,7 @@ describe('maybeParameterizeRemixRoute', () => {
218210
staticRoutes: [],
219211
dynamicRoutes: [],
220212
};
221-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
213+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
222214

223215
expect(maybeParameterizeRemixRoute('')).toBeUndefined();
224216
});
@@ -228,7 +220,7 @@ describe('maybeParameterizeRemixRoute', () => {
228220
staticRoutes: [{ path: '/' }],
229221
dynamicRoutes: [],
230222
};
231-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
223+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
232224

233225
expect(maybeParameterizeRemixRoute('/')).toBeUndefined();
234226
});
@@ -244,7 +236,7 @@ describe('maybeParameterizeRemixRoute', () => {
244236
},
245237
],
246238
};
247-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
239+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
248240

249241
expect(maybeParameterizeRemixRoute('/api/v1/users/123/posts/456/comments/789')).toBe(
250242
'/api/v1/users/:id/posts/:postId/comments/:commentId',
@@ -297,7 +289,7 @@ describe('maybeParameterizeRemixRoute', () => {
297289
},
298290
],
299291
};
300-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
292+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
301293

302294
if (expectedRoute === undefined) {
303295
expect(maybeParameterizeRemixRoute(inputRoute)).toBeUndefined();
@@ -324,7 +316,7 @@ describe('maybeParameterizeRemixRoute', () => {
324316
},
325317
],
326318
};
327-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
319+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
328320

329321
// Single segment should match the specific route, not the catch-all
330322
expect(maybeParameterizeRemixRoute('/123')).toBe('/:parameter');
@@ -355,7 +347,7 @@ describe('maybeParameterizeRemixRoute', () => {
355347
},
356348
],
357349
};
358-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
350+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
359351

360352
// More specific route with static segments should win
361353
expect(maybeParameterizeRemixRoute('/api/users/123')).toBe('/api/users/:id');
@@ -380,7 +372,7 @@ describe('maybeParameterizeRemixRoute', () => {
380372
},
381373
],
382374
};
383-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest);
375+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
384376

385377
const result1 = maybeParameterizeRemixRoute('/users/123');
386378
const result2 = maybeParameterizeRemixRoute('/users/123');
@@ -400,7 +392,7 @@ describe('maybeParameterizeRemixRoute', () => {
400392
},
401393
],
402394
};
403-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest1);
395+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest1);
404396

405397
expect(maybeParameterizeRemixRoute('/users/123')).toBe('/users/:id');
406398

@@ -415,7 +407,7 @@ describe('maybeParameterizeRemixRoute', () => {
415407
},
416408
],
417409
};
418-
globalWithInjectedManifest._sentryRemixRouteManifest = doubleStringify(manifest2);
410+
globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest2);
419411

420412
expect(maybeParameterizeRemixRoute('/users/123')).toBeUndefined();
421413
expect(maybeParameterizeRemixRoute('/members/123')).toBe('/members/:id');

0 commit comments

Comments
 (0)