@@ -3,7 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'http';
33import type { InputConfigT , Middleware } from 'metro-config' ;
44
55import { addContextToFrame , debug } from '@sentry/core' ;
6- import { readFile } from 'fs' ;
6+ import { readFile , realpath , realpathSync } from 'fs' ;
77import * as path from 'path' ;
88import { promisify } from 'util' ;
99
@@ -12,17 +12,26 @@ import { getRawBody } from '../metro/getRawBody';
1212import { openURLMiddleware } from '../metro/openUrlMiddleware' ;
1313
1414const readFileAsync = promisify ( readFile ) ;
15+ const realpathAsync = promisify ( realpath ) ;
1516
1617/**
1718 * Accepts Sentry formatted stack frames and
1819 * adds source context to the in app frames.
1920 *
2021 * Relative filenames are resolved against the first entry in `allowedRoots`.
21- * A resolved filename must be contained in at least one of `allowedRoots`
22- * (project root plus any Metro `watchFolders`).
22+ * Both the resolved filename and the allowed roots are canonicalized via
23+ * `fs.realpath`, so a symlink inside an allowed root pointing outside of it
24+ * cannot escape the containment check.
2325 */
2426export const createStackFramesContextMiddleware = ( allowedRoots : string [ ] ) : Middleware => {
25- const normalizedRoots = allowedRoots . map ( root => path . resolve ( root ) ) ;
27+ const canonicalRoots = allowedRoots . map ( root => {
28+ const resolved = path . resolve ( root ) ;
29+ try {
30+ return realpathSync ( resolved ) ;
31+ } catch {
32+ return resolved ;
33+ }
34+ } ) ;
2635
2736 return async ( request : IncomingMessage , response : ServerResponse , _next : ( ) => void ) : Promise < void > => {
2837 debug . log ( '[@sentry/react-native/metro] Received request for stack frames context.' ) ;
@@ -47,15 +56,15 @@ export const createStackFramesContextMiddleware = (allowedRoots: string[]): Midd
4756 return ;
4857 }
4958
50- const stackWithSourceContext = await Promise . all ( stack . map ( frame => addSourceContext ( frame , normalizedRoots ) ) ) ;
59+ const stackWithSourceContext = await Promise . all ( stack . map ( frame => addSourceContext ( frame , canonicalRoots ) ) ) ;
5160 response . setHeader ( 'Content-Type' , 'application/json' ) ;
5261 response . statusCode = 200 ;
5362 response . end ( JSON . stringify ( { stack : stackWithSourceContext } ) ) ;
5463 debug . log ( '[@sentry/react-native/metro] Sent stack frames context.' ) ;
5564 } ;
5665} ;
5766
58- async function addSourceContext ( frame : StackFrame , allowedRoots : string [ ] ) : Promise < StackFrame > {
67+ async function addSourceContext ( frame : StackFrame , canonicalRoots : string [ ] ) : Promise < StackFrame > {
5968 if ( ! frame . in_app ) {
6069 return frame ;
6170 }
@@ -66,22 +75,30 @@ async function addSourceContext(frame: StackFrame, allowedRoots: string[]): Prom
6675 return frame ;
6776 }
6877
69- if ( allowedRoots . length === 0 ) {
78+ if ( canonicalRoots . length === 0 ) {
7079 debug . warn ( '[@sentry/react-native/metro] Skipping frame: no allowed roots configured.' ) ;
7180 return frame ;
7281 }
7382
74- const resolvedPath = path . resolve ( allowedRoots [ 0 ] ! , frame . filename ) ;
75- const isInside = allowedRoots . some ( root => {
76- const relative = path . relative ( root , resolvedPath ) ;
83+ const resolvedPath = path . resolve ( canonicalRoots [ 0 ] ! , frame . filename ) ;
84+ let canonicalPath : string ;
85+ try {
86+ canonicalPath = await realpathAsync ( resolvedPath ) ;
87+ } catch {
88+ debug . warn ( '[@sentry/react-native/metro] Skipping frame: could not canonicalize filename.' ) ;
89+ return frame ;
90+ }
91+
92+ const isInside = canonicalRoots . some ( root => {
93+ const relative = path . relative ( root , canonicalPath ) ;
7794 return relative !== '' && ! relative . startsWith ( '..' ) && ! path . isAbsolute ( relative ) ;
7895 } ) ;
7996 if ( ! isInside ) {
8097 debug . warn ( '[@sentry/react-native/metro] Skipping frame whose filename is outside the allowed roots.' ) ;
8198 return frame ;
8299 }
83100
84- const source = await readFileAsync ( resolvedPath , { encoding : 'utf8' } ) ;
101+ const source = await readFileAsync ( canonicalPath , { encoding : 'utf8' } ) ;
85102 const lines = source . split ( '\n' ) ;
86103 addContextToFrame ( lines , frame ) ;
87104 } catch ( error ) {
@@ -127,9 +144,8 @@ export const withSentryMiddleware = (config: InputConfigT): InputConfigT => {
127144 config . server = { } ;
128145 }
129146
130- const typedConfig = config as { projectRoot ?: string ; watchFolders ?: readonly string [ ] } ;
131- const projectRoot = typedConfig . projectRoot || process . cwd ( ) ;
132- const watchFolders = typedConfig . watchFolders || [ ] ;
147+ const projectRoot = config . projectRoot || process . cwd ( ) ;
148+ const watchFolders = config . watchFolders || [ ] ;
133149 const allowedRoots = [ projectRoot , ...watchFolders ] ;
134150
135151 const originalEnhanceMiddleware = config . server . enhanceMiddleware ;
0 commit comments