Skip to content

Commit 4525cbe

Browse files
antonisclaude
andauthored
fix(core): Harden metro dev helpers (#6044)
* fix(core): Harden metro dev helpers - Restrict source-context middleware reads to files under the project root. - Escape release-constants values when injected into the generated bundle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(changelog): Add entries for metro dev-helper hardening Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(core): Include watchFolders as allowed roots for source context Monorepos and yarn workspaces set projectRoot to the app package and declare sibling packages via Metro's watchFolders. Allow reads under any configured root so source context keeps working in those setups. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(core): Canonicalize paths via realpath; add escaping regression test - metroMiddleware: run realpath on both the allowed roots and each frame filename so a symlink inside an allowed root pointing outside cannot escape the containment check. Reject frames whose realpath fails. - sentryReleaseInjector: add a test asserting JSON.stringify escaping, so a future refactor cannot silently regress to unescaped interpolation. - Drop unneeded config cast now that InputConfigT exposes projectRoot and watchFolders directly. Addresses review feedback on #6044. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1b1809a commit 4525cbe

5 files changed

Lines changed: 271 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
### Fixes
1717

1818
- Stop the Hermes sampling profiler on React instance teardown to prevent `pthread_kill` SIGABRT when the JS thread is torn down with profiling active ([#6035](https://github.com/getsentry/sentry-react-native/pull/6035))
19+
- Restrict the Metro source-context middleware to files within the project root ([#6044](https://github.com/getsentry/sentry-react-native/pull/6044))
20+
- Escape `name` and `version` values when injecting release constants into the web bundle ([#6044](https://github.com/getsentry/sentry-react-native/pull/6044))
1921
- Mask the Sentry auth token in the `sentry.gradle` upload-task lifecycle log ([#6057](https://github.com/getsentry/sentry-react-native/pull/6057))
2022
- Discard invalid navigation/interaction transactions via an event processor instead of mutating the internal `_sampled` flag, removing misleading "dropped due to sampling" debug logs ([#6051](https://github.com/getsentry/sentry-react-native/pull/6051))
2123

packages/core/src/js/tools/metroMiddleware.ts

Lines changed: 81 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,54 +3,68 @@ import type { IncomingMessage, ServerResponse } from 'http';
33
import type { InputConfigT, Middleware } from 'metro-config';
44

55
import { addContextToFrame, debug } from '@sentry/core';
6-
import { readFile } from 'fs';
6+
import { readFile, realpath, realpathSync } from 'fs';
7+
import * as path from 'path';
78
import { promisify } from 'util';
89

910
import { SENTRY_CONTEXT_REQUEST_PATH, SENTRY_OPEN_URL_REQUEST_PATH } from '../metro/constants';
1011
import { getRawBody } from '../metro/getRawBody';
1112
import { openURLMiddleware } from '../metro/openUrlMiddleware';
1213

1314
const readFileAsync = promisify(readFile);
15+
const realpathAsync = promisify(realpath);
1416

1517
/**
1618
* Accepts Sentry formatted stack frames and
1719
* adds source context to the in app frames.
20+
*
21+
* Relative filenames are resolved against the first entry in `allowedRoots`.
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.
1825
*/
19-
export const stackFramesContextMiddleware: Middleware = async (
20-
request: IncomingMessage,
21-
response: ServerResponse,
22-
_next: () => void,
23-
): Promise<void> => {
24-
debug.log('[@sentry/react-native/metro] Received request for stack frames context.');
25-
request.setEncoding('utf8');
26-
const rawBody = await getRawBody(request);
27-
28-
let body: {
29-
stack?: Partial<StackFrame>[];
30-
} = {};
31-
try {
32-
body = JSON.parse(rawBody);
33-
} catch (e) {
34-
debug.log('[@sentry/react-native/metro] Could not parse request body.', e);
35-
badRequest(response, 'Invalid request body. Expected a JSON object.');
36-
return;
37-
}
26+
export const createStackFramesContextMiddleware = (allowedRoots: string[]): Middleware => {
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+
});
3835

39-
const stack = body.stack;
40-
if (!Array.isArray(stack)) {
41-
debug.log('[@sentry/react-native/metro] Invalid stack frames.', stack);
42-
badRequest(response, 'Invalid stack frames. Expected an array.');
43-
return;
44-
}
36+
return async (request: IncomingMessage, response: ServerResponse, _next: () => void): Promise<void> => {
37+
debug.log('[@sentry/react-native/metro] Received request for stack frames context.');
38+
request.setEncoding('utf8');
39+
const rawBody = await getRawBody(request);
40+
41+
let body: {
42+
stack?: Partial<StackFrame>[];
43+
} = {};
44+
try {
45+
body = JSON.parse(rawBody);
46+
} catch (e) {
47+
debug.log('[@sentry/react-native/metro] Could not parse request body.', e);
48+
badRequest(response, 'Invalid request body. Expected a JSON object.');
49+
return;
50+
}
51+
52+
const stack = body.stack;
53+
if (!Array.isArray(stack)) {
54+
debug.log('[@sentry/react-native/metro] Invalid stack frames.', stack);
55+
badRequest(response, 'Invalid stack frames. Expected an array.');
56+
return;
57+
}
4558

46-
const stackWithSourceContext = await Promise.all(stack.map(addSourceContext));
47-
response.setHeader('Content-Type', 'application/json');
48-
response.statusCode = 200;
49-
response.end(JSON.stringify({ stack: stackWithSourceContext }));
50-
debug.log('[@sentry/react-native/metro] Sent stack frames context.');
59+
const stackWithSourceContext = await Promise.all(stack.map(frame => addSourceContext(frame, canonicalRoots)));
60+
response.setHeader('Content-Type', 'application/json');
61+
response.statusCode = 200;
62+
response.end(JSON.stringify({ stack: stackWithSourceContext }));
63+
debug.log('[@sentry/react-native/metro] Sent stack frames context.');
64+
};
5165
};
5266

53-
async function addSourceContext(frame: StackFrame): Promise<StackFrame> {
67+
async function addSourceContext(frame: StackFrame, canonicalRoots: string[]): Promise<StackFrame> {
5468
if (!frame.in_app) {
5569
return frame;
5670
}
@@ -61,7 +75,30 @@ async function addSourceContext(frame: StackFrame): Promise<StackFrame> {
6175
return frame;
6276
}
6377

64-
const source = await readFileAsync(frame.filename, { encoding: 'utf8' });
78+
if (canonicalRoots.length === 0) {
79+
debug.warn('[@sentry/react-native/metro] Skipping frame: no allowed roots configured.');
80+
return frame;
81+
}
82+
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);
94+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
95+
});
96+
if (!isInside) {
97+
debug.warn('[@sentry/react-native/metro] Skipping frame whose filename is outside the allowed roots.');
98+
return frame;
99+
}
100+
101+
const source = await readFileAsync(canonicalPath, { encoding: 'utf8' });
65102
const lines = source.split('\n');
66103
addContextToFrame(lines, frame);
67104
} catch (error) {
@@ -78,7 +115,12 @@ function badRequest(response: ServerResponse, message: string): void {
78115
/**
79116
* Creates a middleware that adds source context to the Sentry formatted stack frames.
80117
*/
81-
export const createSentryMetroMiddleware = (middleware: Middleware): Middleware => {
118+
export const createSentryMetroMiddleware = (middleware: Middleware, allowedRoots: string[]): Middleware => {
119+
const stackFramesContextMiddleware = createStackFramesContextMiddleware(allowedRoots) as (
120+
req: IncomingMessage,
121+
res: ServerResponse,
122+
next: () => void,
123+
) => void;
82124
return (request: IncomingMessage, response: ServerResponse, next: () => void) => {
83125
if (request.url?.startsWith(`/${SENTRY_CONTEXT_REQUEST_PATH}`)) {
84126
return stackFramesContextMiddleware(request, response, next);
@@ -102,9 +144,13 @@ export const withSentryMiddleware = (config: InputConfigT): InputConfigT => {
102144
config.server = {};
103145
}
104146

147+
const projectRoot = config.projectRoot || process.cwd();
148+
const watchFolders = config.watchFolders || [];
149+
const allowedRoots = [projectRoot, ...watchFolders];
150+
105151
const originalEnhanceMiddleware = config.server.enhanceMiddleware;
106152
config.server.enhanceMiddleware = (middleware, server) => {
107-
const sentryMiddleware = createSentryMetroMiddleware(middleware);
153+
const sentryMiddleware = createSentryMetroMiddleware(middleware, allowedRoots);
108154
return originalEnhanceMiddleware ? originalEnhanceMiddleware(sentryMiddleware, server) : sentryMiddleware;
109155
};
110156
return config;

packages/core/src/js/tools/sentryReleaseInjector.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,5 @@ function createSentryReleaseModule({
4343
}
4444

4545
function createReleaseConstantsSnippet({ name, version }: { name: string; version: string }): string {
46-
return `var SENTRY_RELEASE;SENTRY_RELEASE={name: "${name}", version: "${version}"};`;
46+
return `var SENTRY_RELEASE;SENTRY_RELEASE={name: ${JSON.stringify(name)}, version: ${JSON.stringify(version)}};`;
4747
}

0 commit comments

Comments
 (0)