Skip to content
84 changes: 50 additions & 34 deletions packages/core/src/js/tools/metroMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { InputConfigT, Middleware } from 'metro-config';

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

import { SENTRY_CONTEXT_REQUEST_PATH, SENTRY_OPEN_URL_REQUEST_PATH } from '../metro/constants';
Expand All @@ -15,42 +16,44 @@ const readFileAsync = promisify(readFile);
/**
* Accepts Sentry formatted stack frames and
* adds source context to the in app frames.
*
* Filenames are resolved relative to `projectRoot` and must remain within it.
*/
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 = (projectRoot: string): Middleware => {
const normalizedRoot = path.resolve(projectRoot);

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);

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.');
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(frame => addSourceContext(frame, normalizedRoot)));
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, projectRoot: string): Promise<StackFrame> {
if (!frame.in_app) {
return frame;
}
Expand All @@ -61,7 +64,14 @@ async function addSourceContext(frame: StackFrame): Promise<StackFrame> {
return frame;
}

const source = await readFileAsync(frame.filename, { encoding: 'utf8' });
const resolvedPath = path.resolve(projectRoot, frame.filename);
const relative = path.relative(projectRoot, resolvedPath);
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
debug.warn('[@sentry/react-native/metro] Skipping frame whose filename is outside the project root.');
return frame;
}

const source = await readFileAsync(resolvedPath, { encoding: 'utf8' });
const lines = source.split('\n');
addContextToFrame(lines, frame);
} catch (error) {
Expand All @@ -78,7 +88,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, projectRoot: string): Middleware => {
const stackFramesContextMiddleware = createStackFramesContextMiddleware(projectRoot) 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 +117,10 @@ export const withSentryMiddleware = (config: InputConfigT): InputConfigT => {
config.server = {};
}

const projectRoot = (config as { projectRoot?: string }).projectRoot || process.cwd();
const originalEnhanceMiddleware = config.server.enhanceMiddleware;
config.server.enhanceMiddleware = (middleware, server) => {
const sentryMiddleware = createSentryMetroMiddleware(middleware);
const sentryMiddleware = createSentryMetroMiddleware(middleware, projectRoot);
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)}};`;
}
80 changes: 66 additions & 14 deletions packages/core/test/tools/metroMiddleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import type { StackFrame } from '@sentry/core';

import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import * as fs from 'fs';
import * as path from 'path';

import * as openUrlMiddlewareModule from '../../src/js/metro/openUrlMiddleware';
import * as metroMiddleware from '../../src/js/tools/metroMiddleware';

const { withSentryMiddleware, createSentryMetroMiddleware, stackFramesContextMiddleware } = metroMiddleware;
const { withSentryMiddleware, createSentryMetroMiddleware, createStackFramesContextMiddleware } = metroMiddleware;

const TEST_PROJECT_ROOT = path.resolve('/tmp/sentry-rn-test-project');

jest.mock('../../src/js/tools/metroMiddleware', () => jest.requireActual('../../src/js/tools/metroMiddleware'));
jest.mock('fs', () => {
Expand Down Expand Up @@ -60,65 +63,69 @@ describe('metroMiddleware', () => {
const next = jest.fn();
const response = {} as any;

let spiedStackFramesContextMiddleware: jest.Spied<typeof stackFramesContextMiddleware>;
let spiedCreateStackFramesContextMiddleware: jest.Spied<typeof createStackFramesContextMiddleware>;
let mockedStackFramesContextMiddleware: jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
spiedStackFramesContextMiddleware = jest
.spyOn(metroMiddleware, 'stackFramesContextMiddleware')
.mockReturnValue(undefined);
mockedStackFramesContextMiddleware = jest.fn();
spiedCreateStackFramesContextMiddleware = jest
.spyOn(metroMiddleware, 'createStackFramesContextMiddleware')
.mockReturnValue(mockedStackFramesContextMiddleware);
});

afterEach(() => {
jest.resetAllMocks();
});

it('should call stackFramesContextMiddleware for sentry context requests', () => {
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware);
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware, TEST_PROJECT_ROOT);

const sentryRequest = {
url: '/__sentry/context',
} as any;
testedMiddleware(sentryRequest, response, next);
expect(defaultMiddleware).not.toHaveBeenCalled();
expect(spiedStackFramesContextMiddleware).toHaveBeenCalledWith(sentryRequest, response, next);
expect(spiedCreateStackFramesContextMiddleware).toHaveBeenCalledWith(TEST_PROJECT_ROOT);
expect(mockedStackFramesContextMiddleware).toHaveBeenCalledWith(sentryRequest, response, next);
});

it('should call openURLMiddleware for sentry open-url requests', () => {
const spiedOpenURLMiddleware = jest
.spyOn(openUrlMiddlewareModule, 'openURLMiddleware')
.mockReturnValue(undefined as any);

const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware);
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware, TEST_PROJECT_ROOT);

const openUrlRequest = {
url: '/__sentry/open-url',
} as any;
testedMiddleware(openUrlRequest, response, next);
expect(defaultMiddleware).not.toHaveBeenCalled();
expect(spiedStackFramesContextMiddleware).not.toHaveBeenCalled();
expect(mockedStackFramesContextMiddleware).not.toHaveBeenCalled();
expect(spiedOpenURLMiddleware).toHaveBeenCalledWith(openUrlRequest, response);

spiedOpenURLMiddleware.mockRestore();
});

it('should call default middleware for non-sentry requests', () => {
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware);
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware, TEST_PROJECT_ROOT);

const regularRequest = {
url: '/regular/path',
} as any;
testedMiddleware(regularRequest, response, next);
expect(defaultMiddleware).toHaveBeenCalledWith(regularRequest, response, next);
expect(defaultMiddleware).toHaveBeenCalledTimes(1);
expect(spiedStackFramesContextMiddleware).not.toHaveBeenCalled();
expect(mockedStackFramesContextMiddleware).not.toHaveBeenCalled();
});
});

describe('stackFramesContextMiddleware', () => {
let request: any;
let response: any;
const next = jest.fn();
const stackFramesContextMiddleware = createStackFramesContextMiddleware(TEST_PROJECT_ROOT);

let testData: string = '';

Expand Down Expand Up @@ -201,7 +208,7 @@ describe('metroMiddleware', () => {
],
} satisfies { stack: StackFrame[] });

mockReadFileOnce(readFileSpy, 'test.js', 'line1\nline2\nline3\nline4\nline5');
mockReadFileOnce(readFileSpy, path.join(TEST_PROJECT_ROOT, 'test.js'), 'line1\nline2\nline3\nline4\nline5');

await stackFramesContextMiddleware(request, response, next);

Expand Down Expand Up @@ -282,10 +289,55 @@ describe('metroMiddleware', () => {
});
});

it('should skip frames whose filename escapes the project root', async () => {
const readFileSpy = jest.spyOn(fs, 'readFile');
testData = JSON.stringify({
stack: [
{
in_app: true,
filename: '/etc/passwd',
function: 'testFunction',
lineno: 1,
colno: 1,
},
{
in_app: true,
filename: '../outside.js',
function: 'testFunction',
lineno: 1,
colno: 1,
},
],
} satisfies { stack: StackFrame[] });

await stackFramesContextMiddleware(request, response, next);

expect(readFileSpy).not.toHaveBeenCalled();
expect(response.statusCode).toBe(200);
expect(JSON.parse(response.end.mock.calls[0][0])).toEqual({
stack: [
{
in_app: true,
filename: '/etc/passwd',
function: 'testFunction',
lineno: 1,
colno: 1,
},
{
in_app: true,
filename: '../outside.js',
function: 'testFunction',
lineno: 1,
colno: 1,
},
],
});
});

it('should handle mixed frame types correctly', async () => {
const readFileSpy = jest.spyOn(fs, 'readFile');
mockReadFileOnce(readFileSpy, 'app1.js', 'line1\nline2\nline3\nline4\nline5');
mockReadFileOnce(readFileSpy, 'app2.js', 'code1\ncode2\ncode3\ncode4\ncode5');
mockReadFileOnce(readFileSpy, path.join(TEST_PROJECT_ROOT, 'app1.js'), 'line1\nline2\nline3\nline4\nline5');
mockReadFileOnce(readFileSpy, path.join(TEST_PROJECT_ROOT, 'app2.js'), 'code1\ncode2\ncode3\ncode4\ncode5');

testData = JSON.stringify({
stack: [
Expand Down
Loading