Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
### Fixes

- 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))
- Restrict the Metro source-context middleware to files within the project root ([#6044](https://github.com/getsentry/sentry-react-native/pull/6044))
- Escape `name` and `version` values when injecting release constants into the web bundle ([#6044](https://github.com/getsentry/sentry-react-native/pull/6044))
- Mask the Sentry auth token in the `sentry.gradle` upload-task lifecycle log ([#6057](https://github.com/getsentry/sentry-react-native/pull/6057))
- 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))

Expand Down
116 changes: 81 additions & 35 deletions packages/core/src/js/tools/metroMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,54 +3,68 @@ import type { IncomingMessage, ServerResponse } from 'http';
import type { InputConfigT, Middleware } from 'metro-config';

import { addContextToFrame, debug } from '@sentry/core';
import { readFile } from 'fs';
import { readFile, realpath, realpathSync } from 'fs';
import * as path from 'path';
import { promisify } from 'util';

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

const readFileAsync = promisify(readFile);
const realpathAsync = promisify(realpath);

/**
* Accepts Sentry formatted stack frames and
* adds source context to the in app frames.
*
* Relative filenames are resolved against the first entry in `allowedRoots`.
* Both the resolved filename and the allowed roots are canonicalized via
* `fs.realpath`, so a symlink inside an allowed root pointing outside of it
* cannot escape the containment check.
*/
export const stackFramesContextMiddleware: Middleware = async (
request: IncomingMessage,
response: ServerResponse,
_next: () => void,
): Promise<void> => {
debug.log('[@sentry/react-native/metro] Received request for stack frames context.');
request.setEncoding('utf8');
const rawBody = await getRawBody(request);

let body: {
stack?: Partial<StackFrame>[];
} = {};
try {
body = JSON.parse(rawBody);
} catch (e) {
debug.log('[@sentry/react-native/metro] Could not parse request body.', e);
badRequest(response, 'Invalid request body. Expected a JSON object.');
return;
}
export const createStackFramesContextMiddleware = (allowedRoots: string[]): Middleware => {
const canonicalRoots = allowedRoots.map(root => {
const resolved = path.resolve(root);
try {
return realpathSync(resolved);
} catch {
return resolved;
}
});

const stack = body.stack;
if (!Array.isArray(stack)) {
debug.log('[@sentry/react-native/metro] Invalid stack frames.', stack);
badRequest(response, 'Invalid stack frames. Expected an array.');
return;
}
return async (request: IncomingMessage, response: ServerResponse, _next: () => void): Promise<void> => {
debug.log('[@sentry/react-native/metro] Received request for stack frames context.');
request.setEncoding('utf8');
const rawBody = await getRawBody(request);

let body: {
stack?: Partial<StackFrame>[];
} = {};
try {
body = JSON.parse(rawBody);
} catch (e) {
debug.log('[@sentry/react-native/metro] Could not parse request body.', e);
badRequest(response, 'Invalid request body. Expected a JSON object.');
return;
}

const stack = body.stack;
if (!Array.isArray(stack)) {
debug.log('[@sentry/react-native/metro] Invalid stack frames.', stack);
badRequest(response, 'Invalid stack frames. Expected an array.');
return;
}

const stackWithSourceContext = await Promise.all(stack.map(addSourceContext));
response.setHeader('Content-Type', 'application/json');
response.statusCode = 200;
response.end(JSON.stringify({ stack: stackWithSourceContext }));
debug.log('[@sentry/react-native/metro] Sent stack frames context.');
const stackWithSourceContext = await Promise.all(stack.map(frame => addSourceContext(frame, canonicalRoots)));
response.setHeader('Content-Type', 'application/json');
response.statusCode = 200;
response.end(JSON.stringify({ stack: stackWithSourceContext }));
debug.log('[@sentry/react-native/metro] Sent stack frames context.');
};
};

async function addSourceContext(frame: StackFrame): Promise<StackFrame> {
async function addSourceContext(frame: StackFrame, canonicalRoots: string[]): Promise<StackFrame> {
if (!frame.in_app) {
return frame;
}
Expand All @@ -61,7 +75,30 @@ async function addSourceContext(frame: StackFrame): Promise<StackFrame> {
return frame;
}

const source = await readFileAsync(frame.filename, { encoding: 'utf8' });
if (canonicalRoots.length === 0) {
debug.warn('[@sentry/react-native/metro] Skipping frame: no allowed roots configured.');
return frame;
}

const resolvedPath = path.resolve(canonicalRoots[0]!, frame.filename);
let canonicalPath: string;
try {
canonicalPath = await realpathAsync(resolvedPath);
} catch {
debug.warn('[@sentry/react-native/metro] Skipping frame: could not canonicalize filename.');
return frame;
}

const isInside = canonicalRoots.some(root => {
const relative = path.relative(root, canonicalPath);
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
});
if (!isInside) {
debug.warn('[@sentry/react-native/metro] Skipping frame whose filename is outside the allowed roots.');
return frame;
}

const source = await readFileAsync(canonicalPath, { encoding: 'utf8' });
const lines = source.split('\n');
addContextToFrame(lines, frame);
} catch (error) {
Expand All @@ -78,7 +115,12 @@ function badRequest(response: ServerResponse, message: string): void {
/**
* Creates a middleware that adds source context to the Sentry formatted stack frames.
*/
export const createSentryMetroMiddleware = (middleware: Middleware): Middleware => {
export const createSentryMetroMiddleware = (middleware: Middleware, allowedRoots: string[]): Middleware => {
const stackFramesContextMiddleware = createStackFramesContextMiddleware(allowedRoots) as (
req: IncomingMessage,
res: ServerResponse,
next: () => void,
) => void;
return (request: IncomingMessage, response: ServerResponse, next: () => void) => {
if (request.url?.startsWith(`/${SENTRY_CONTEXT_REQUEST_PATH}`)) {
return stackFramesContextMiddleware(request, response, next);
Expand All @@ -102,9 +144,13 @@ export const withSentryMiddleware = (config: InputConfigT): InputConfigT => {
config.server = {};
}

const projectRoot = config.projectRoot || process.cwd();
const watchFolders = config.watchFolders || [];
const allowedRoots = [projectRoot, ...watchFolders];

const originalEnhanceMiddleware = config.server.enhanceMiddleware;
config.server.enhanceMiddleware = (middleware, server) => {
const sentryMiddleware = createSentryMetroMiddleware(middleware);
const sentryMiddleware = createSentryMetroMiddleware(middleware, allowedRoots);
return originalEnhanceMiddleware ? originalEnhanceMiddleware(sentryMiddleware, server) : sentryMiddleware;
};
return config;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/js/tools/sentryReleaseInjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,5 @@ function createSentryReleaseModule({
}

function createReleaseConstantsSnippet({ name, version }: { name: string; version: string }): string {
return `var SENTRY_RELEASE;SENTRY_RELEASE={name: "${name}", version: "${version}"};`;
return `var SENTRY_RELEASE;SENTRY_RELEASE={name: ${JSON.stringify(name)}, version: ${JSON.stringify(version)}};`;
}
Loading
Loading