Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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))

### Dependencies

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