diff --git a/flow-typed/npm/launch-editor_v2.x.x.js b/flow-typed/npm/launch-editor_v2.x.x.js new file mode 100644 index 000000000000..791515e9baee --- /dev/null +++ b/flow-typed/npm/launch-editor_v2.x.x.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +declare module 'launch-editor' { + declare type ErrorCallback = ( + fileName: string, + errorMessage: string | null, + ) => void; + + /** + * Launch an editor to open a file, with an optional line and column + * (`/path/to/file.js:10:2`). `specifiedEditor` is parsed with `shell-quote`. + */ + declare module.exports: ( + file: string, + specifiedEditor?: ?string | ErrorCallback, + onErrorCallback?: ErrorCallback, + ) => void; +} diff --git a/packages/community-cli-plugin/package.json b/packages/community-cli-plugin/package.json index c424dcf434ac..90a2edb976a7 100644 --- a/packages/community-cli-plugin/package.json +++ b/packages/community-cli-plugin/package.json @@ -35,9 +35,12 @@ "@react-native/asset-utils": "0.87.0-main", "@react-native/dev-middleware": "0.87.0-main", "commander": "^12.0.0", + "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", + "launch-editor": "^2.9.1", "metro": "^0.87.2", + "open": "^8.4.2", "semver": "^7.1.3" }, "devDependencies": { diff --git a/packages/community-cli-plugin/src/dev-server/__tests__/openStackFrameMiddleware-test.js b/packages/community-cli-plugin/src/dev-server/__tests__/openStackFrameMiddleware-test.js new file mode 100644 index 000000000000..a2338c57396f --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/__tests__/openStackFrameMiddleware-test.js @@ -0,0 +1,217 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {Server} from 'connect'; + +import connect from 'connect'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; + +jest.useRealTimers(); +jest.mock('launch-editor', () => jest.fn()); + +const launchEditor: JestMockFn<[string, ?string], void> = + jest.requireMock('launch-editor'); + +const projectRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'openStackFrameMiddleware-test-')), +); +const appFile = path.join(projectRoot, 'App.js'); +fs.writeFileSync(appFile, ''); +const outsideRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'openStackFrameMiddleware-outside-')), +); +const outsideFile = path.join(outsideRoot, 'secret.txt'); +fs.writeFileSync(outsideFile, ''); +const symlinkOut = path.join(projectRoot, 'SettingsScreen.tsx'); +fs.symlinkSync(outsideFile, symlinkOut); + +const originalEditor = process.env.REACT_EDITOR; + +beforeAll(() => { + process.env.REACT_EDITOR = 'test-editor'; +}); + +afterAll(() => { + process.env.REACT_EDITOR = originalEditor; + fs.rmSync(projectRoot, {recursive: true, force: true}); + fs.rmSync(outsideRoot, {recursive: true, force: true}); +}); + +beforeEach(() => { + launchEditor.mockReset(); +}); + +async function startServer( + mount: (app: Server, serverBaseUrl: URL) => void, +): Promise<{server: http$Server, baseUrl: string}> { + const app = connect(); + const server = http.createServer(app); + await new Promise(resolve => server.listen(0, 'localhost', resolve)); + const address = server.address(); + if (address == null || typeof address === 'string') { + throw new Error('Expected the server to listen on a TCP port'); + } + const baseUrl = `http://localhost:${address.port}`; + mount(app, new URL(baseUrl)); + app.use((req, res) => { + res.writeHead(404); + res.end(); + }); + return {server, baseUrl}; +} + +function mountOpenStackFrame(app: Server, serverBaseUrl: URL) { + app.use( + '/open-stack-frame', + require('../openStackFrameMiddleware').default({ + serverBaseUrl, + watchFolders: [projectRoot], + }), + ); +} + +function stopServer(server: http$Server): Promise { + server.closeAllConnections(); + return new Promise(resolve => server.close(() => resolve())); +} + +function postFrame( + baseUrl: string, + body: string, + headers?: {Origin?: string}, +): Promise { + return fetch(baseUrl + '/open-stack-frame', { + method: 'POST', + headers: {...headers, 'Content-Type': 'application/json'}, + body, + }); +} + +describe('/open-stack-frame', () => { + let server: http$Server; + let baseUrl: string; + + beforeEach(async () => { + ({server, baseUrl} = await startServer(mountOpenStackFrame)); + }); + + afterEach(() => stopServer(server)); + + test('opens the frame in the editor', async () => { + const resp = await postFrame( + baseUrl, + JSON.stringify({file: appFile, lineNumber: 12}), + ); + + expect(resp.status).toBe(200); + expect(await resp.text()).toBe('OK'); + expect(launchEditor).toHaveBeenCalledWith(`${appFile}:12`, 'test-editor'); + }); + + test('throttles a second launch inside the window', async () => { + const body = JSON.stringify({file: appFile, lineNumber: 12}); + + const first = await postFrame(baseUrl, body); + const second = await postFrame(baseUrl, body); + + expect(first.status).toBe(200); + expect(second.status).toBe(429); + expect(launchEditor).toHaveBeenCalledTimes(1); + }); + + test('refuses a cross-origin request', async () => { + const resp = await postFrame( + baseUrl, + JSON.stringify({file: appFile, lineNumber: 12}), + {Origin: 'https://example.com'}, + ); + + expect(resp.status).toBe(403); + expect(launchEditor).not.toHaveBeenCalled(); + }); + + test('rejects a body that is not JSON', async () => { + const resp = await postFrame(baseUrl, 'not json'); + + expect(resp.status).toBe(400); + expect(launchEditor).not.toHaveBeenCalled(); + }); + + test('opens a path relative to the first watch folder', async () => { + const resp = await postFrame( + baseUrl, + JSON.stringify({file: 'App.js', lineNumber: 3}), + ); + + expect(resp.status).toBe(200); + expect(launchEditor).toHaveBeenCalledWith(`${appFile}:3`, 'test-editor'); + }); + + test.each([ + ['a file outside every watch folder', outsideFile], + ['a symlink out of a watch folder', symlinkOut], + ['a nonexistent file', path.join(projectRoot, 'Missing.js')], + ])('rejects %s', async (_, file) => { + const resp = await postFrame( + baseUrl, + JSON.stringify({file, lineNumber: 1}), + ); + + expect(resp.status).toBe(400); + expect(launchEditor).not.toHaveBeenCalled(); + }); + + test.each([ + ['a missing file', {lineNumber: 1}], + ['null', null], + ])('rejects a body with %s', async (_, body) => { + const resp = await postFrame(baseUrl, JSON.stringify(body)); + + expect(resp.status).toBe(400); + expect(launchEditor).not.toHaveBeenCalled(); + }); + + test('passes a GET request through', async () => { + const resp = await fetch(baseUrl + '/open-stack-frame'); + + expect(resp.status).toBe(404); + expect(launchEditor).not.toHaveBeenCalled(); + }); +}); + +describe('/open-stack-frame behind a middleware that serves it', () => { + let server: http$Server; + let baseUrl: string; + + beforeEach(async () => { + ({server, baseUrl} = await startServer((app, serverBaseUrl) => { + app.use('/open-stack-frame', (req, res, next) => { + res.end('handled upstream'); + next(); + }); + mountOpenStackFrame(app, serverBaseUrl); + })); + }); + + afterEach(() => stopServer(server)); + + test('leaves the response to that middleware', async () => { + const resp = await postFrame( + baseUrl, + JSON.stringify({file: appFile, lineNumber: 12}), + ); + + expect(await resp.text()).toBe('handled upstream'); + expect(launchEditor).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/community-cli-plugin/src/dev-server/__tests__/openURLMiddleware-test.js b/packages/community-cli-plugin/src/dev-server/__tests__/openURLMiddleware-test.js new file mode 100644 index 000000000000..4227a4ff0b2b --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/__tests__/openURLMiddleware-test.js @@ -0,0 +1,144 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import connect from 'connect'; +import http from 'node:http'; + +jest.useRealTimers(); +jest.mock('open', () => jest.fn()); + +const open: JestMockFn<[string], Promise> = jest.requireMock('open'); + +let server: http$Server; +let baseUrl: string; + +beforeEach(async () => { + open.mockReset(); + open.mockImplementation(async () => {}); + + const app = connect(); + server = http.createServer(app); + await new Promise(resolve => server.listen(0, 'localhost', resolve)); + const address = server.address(); + if (address == null || typeof address === 'string') { + throw new Error('Expected the server to listen on a TCP port'); + } + baseUrl = `http://localhost:${address.port}`; + app + .use( + '/open-url', + require('../openURLMiddleware').default({ + serverBaseUrl: new URL(baseUrl), + }), + ) + .use((req, res) => { + res.writeHead(404); + res.end(); + }); +}); + +afterEach(() => { + server.closeAllConnections(); + return new Promise(resolve => server.close(() => resolve())); +}); + +function postURL(body: string, headers?: {Origin?: string}): Promise { + return fetch(baseUrl + '/open-url', { + method: 'POST', + headers: {...headers, 'Content-Type': 'application/json'}, + body, + }); +} + +describe('/open-url', () => { + test.each([ + 'https://reactnative.dev/docs/tutorial', + 'https://reactnative.dev/docs/a%20b?q=1&r=2#top', + ])('opens %s in the browser', async url => { + const resp = await postURL(JSON.stringify({url})); + + expect(resp.status).toBe(200); + expect(await resp.text()).toBe('OK'); + expect(open).toHaveBeenCalledWith(url); + }); + + test('throttles a second launch inside the window', async () => { + const body = JSON.stringify({url: 'https://reactnative.dev/'}); + + const first = await postURL(body); + const second = await postURL(body); + + expect(first.status).toBe(200); + expect(second.status).toBe(429); + expect(open).toHaveBeenCalledTimes(1); + }); + + test('refuses a cross-origin request', async () => { + const resp = await postURL( + JSON.stringify({url: 'https://reactnative.dev/'}), + {Origin: 'https://example.com'}, + ); + + expect(resp.status).toBe(403); + expect(open).not.toHaveBeenCalled(); + }); + + test('rejects a body that is not JSON', async () => { + const resp = await postURL('not json'); + + expect(resp.status).toBe(400); + expect(open).not.toHaveBeenCalled(); + }); + + test.each([ + ['a non-string url', {url: 123}], + ['null', null], + ])('rejects a body with %s', async (_, body) => { + const resp = await postURL(JSON.stringify(body)); + + expect(resp.status).toBe(400); + expect(open).not.toHaveBeenCalled(); + }); + + // CVE-2025-11953 + test.each([ + ['a bare executable', 'calc.exe'], + [ + 'a PowerShell subexpression in the query', + 'https://example.com/?x=$(calc)', + ], + ['a PowerShell escape in the query', 'https://example.com/?x=`n'], + ['a file URL', 'file:///etc/passwd'], + ])('rejects %s', async (_, url) => { + const resp = await postURL(JSON.stringify({url})); + + expect(resp.status).toBe(400); + expect(open).not.toHaveBeenCalled(); + }); + + test('responds with 500 when the browser cannot be launched', async () => { + open.mockImplementation(async () => { + throw new Error('spawn xdg-open ENOENT'); + }); + + const resp = await postURL( + JSON.stringify({url: 'https://reactnative.dev/'}), + ); + + expect(resp.status).toBe(500); + }); + + test('passes a GET request through', async () => { + const resp = await fetch(baseUrl + '/open-url'); + + expect(resp.status).toBe(404); + expect(open).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/community-cli-plugin/src/dev-server/isAllowedOrigin.js b/packages/community-cli-plugin/src/dev-server/isAllowedOrigin.js new file mode 100644 index 000000000000..d776849aaf9b --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/isAllowedOrigin.js @@ -0,0 +1,34 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +const ALLOWED_ORIGIN_HOSTNAMES: ReadonlySet = new Set([ + 'localhost', + '127.0.0.1', + '0.0.0.0', + '[::]', +]); + +/** + * Whether a browser request from `origin` may use a dev server endpoint that + * acts on the host machine: the dev server's own origin, or a local hostname, + * as the inspector proxy allows for debugger connections. + */ +export default function isAllowedOrigin( + origin: string, + serverBaseUrl: URL, +): boolean { + if (serverBaseUrl.origin === origin) { + return true; + } + + return URL.canParse(origin) + ? ALLOWED_ORIGIN_HOSTNAMES.has(new URL(origin).hostname) + : false; +} diff --git a/packages/community-cli-plugin/src/dev-server/loadCommunityMiddleware.js b/packages/community-cli-plugin/src/dev-server/loadCommunityMiddleware.js index bd4f314bc77e..901da5955c96 100644 --- a/packages/community-cli-plugin/src/dev-server/loadCommunityMiddleware.js +++ b/packages/community-cli-plugin/src/dev-server/loadCommunityMiddleware.js @@ -40,8 +40,6 @@ const unusedMiddlewareStub: Server = {}; // FIXME: Several features will break without community middleware // (@react-native-community/cli-server-api) and should be migrated into core. // e.g. used by packages/react-native/Libraries/Core/Devtools/: -// - /open-stack-frame -// - /open-url // - /symbolicate // e.g. used by ./isDevServerRunning.js: // - /status diff --git a/packages/community-cli-plugin/src/dev-server/openStackFrameMiddleware.js b/packages/community-cli-plugin/src/dev-server/openStackFrameMiddleware.js new file mode 100644 index 000000000000..44be52c97284 --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/openStackFrameMiddleware.js @@ -0,0 +1,143 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {NextHandleFunction} from 'connect'; + +import isAllowedOrigin from './isAllowedOrigin'; +import readJSONBody from './readJSONBody'; +import launchEditor from 'launch-editor'; +import fs from 'node:fs'; +import path from 'node:path'; + +const LAUNCH_THROTTLE_MS = 2000; + +type Options = Readonly<{ + serverBaseUrl: URL, + watchFolders: ReadonlyArray, +}>; + +type StackFrame = Readonly<{ + file: string, + lineNumber?: number, +}>; + +/** + * Open a stack frame in the user's editor, through `launch-editor` and + * `$REACT_EDITOR`. The file must resolve, through any symlinks, to a path + * inside one of `watchFolders`; a relative path resolves against the first. + */ +export default function openStackFrameMiddleware({ + serverBaseUrl, + watchFolders, +}: Options): NextHandleFunction { + const rootsPromise = Promise.all( + watchFolders.map(folder => + fs.promises.realpath(folder).catch(() => path.resolve(folder)), + ), + ); + + let lastLaunchTime = -Infinity; + + return async (req, res, next) => { + // `@react-native-community/cli-server-api` before v21, mounted ahead of + // this middleware, serves this route and calls `next()` after responding. + if (req.method !== 'POST' || res.writableEnded) { + next(); + return; + } + + const {origin} = req.headers; + if (typeof origin === 'string' && !isAllowedOrigin(origin, serverBaseUrl)) { + res.writeHead(403); + res.end('Unauthorized origin'); + return; + } + + let body; + try { + body = await readJSONBody(req); + } catch { + res.writeHead(400); + res.end('Invalid request body'); + return; + } + + const frame = parseStackFrame(body); + if (frame == null) { + res.writeHead(400); + res.end('Expected a JSON body with a string `file`'); + return; + } + + const roots = await rootsPromise; + const filePath = await resolveInRoots(frame.file, roots); + if (filePath == null) { + res.writeHead(400); + res.end('File is missing or outside the project watch folders'); + return; + } + + const now = Date.now(); + if (now - lastLaunchTime < LAUNCH_THROTTLE_MS) { + res.writeHead(429); + res.end('Too many requests'); + return; + } + lastLaunchTime = now; + + launchEditor( + frame.lineNumber != null ? `${filePath}:${frame.lineNumber}` : filePath, + process.env.REACT_EDITOR, + ); + + res.writeHead(200); + res.end('OK'); + }; +} + +function parseStackFrame(body: unknown): ?StackFrame { + if (body == null || typeof body !== 'object' || Array.isArray(body)) { + return null; + } + const {file, lineNumber} = body; + if (typeof file !== 'string' || file === '') { + return null; + } + return typeof lineNumber === 'number' && + Number.isSafeInteger(lineNumber) && + lineNumber > 0 + ? {file, lineNumber} + : {file}; +} + +async function resolveInRoots( + file: string, + roots: ReadonlyArray, +): Promise { + if (roots.length === 0) { + return null; + } + let realPath; + try { + realPath = await fs.promises.realpath(path.resolve(roots[0], file)); + } catch { + return null; + } + const isInRoot = (root: string) => { + const relativePath = path.relative(root, realPath); + return ( + relativePath !== '' && + relativePath !== '..' && + !relativePath.startsWith('..' + path.sep) && + !path.isAbsolute(relativePath) + ); + }; + return roots.some(isInRoot) ? realPath : null; +} diff --git a/packages/community-cli-plugin/src/dev-server/openURLMiddleware.js b/packages/community-cli-plugin/src/dev-server/openURLMiddleware.js new file mode 100644 index 000000000000..c5c3253e2d88 --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/openURLMiddleware.js @@ -0,0 +1,103 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {NextHandleFunction} from 'connect'; + +import isAllowedOrigin from './isAllowedOrigin'; +import readJSONBody from './readJSONBody'; +import open from 'open'; + +const LAUNCH_THROTTLE_MS = 2000; + +// On Windows, `open` passes the URL inside a double-quoted PowerShell string, +// where these characters expand to variables, subexpressions and escapes. +const POWERSHELL_EXPANSION_CHARS = /[$`"]/; + +type Options = Readonly<{ + serverBaseUrl: URL, +}>; + +/** + * Open an `http:` or `https:` URL in the user's default browser. + */ +export default function openURLMiddleware({ + serverBaseUrl, +}: Options): NextHandleFunction { + let lastLaunchTime = -Infinity; + + return async (req, res, next) => { + // `@react-native-community/cli-server-api` before v21, mounted ahead of + // this middleware, serves this route. + if (req.method !== 'POST' || res.writableEnded) { + next(); + return; + } + + const {origin} = req.headers; + if (typeof origin === 'string' && !isAllowedOrigin(origin, serverBaseUrl)) { + res.writeHead(403); + res.end('Unauthorized origin'); + return; + } + + let body; + try { + body = await readJSONBody(req); + } catch { + res.writeHead(400); + res.end('Invalid request body'); + return; + } + + const url = parseURL(body); + if (url == null) { + res.writeHead(400); + res.end('Expected a JSON body with an http(s) `url`'); + return; + } + + const now = Date.now(); + if (now - lastLaunchTime < LAUNCH_THROTTLE_MS) { + res.writeHead(429); + res.end('Too many requests'); + return; + } + lastLaunchTime = now; + + try { + await open(url); + } catch (e) { + res.writeHead(500); + res.end(`Could not open the URL: ${e.message}`); + return; + } + + res.writeHead(200); + res.end('OK'); + }; +} + +function parseURL(body: unknown): ?string { + if (body == null || typeof body !== 'object' || Array.isArray(body)) { + return null; + } + const {url} = body; + if (typeof url !== 'string' || !URL.canParse(url)) { + return null; + } + const {protocol, href} = new URL(url); + if ( + (protocol !== 'http:' && protocol !== 'https:') || + POWERSHELL_EXPANSION_CHARS.test(href) + ) { + return null; + } + return href; +} diff --git a/packages/community-cli-plugin/src/dev-server/readJSONBody.js b/packages/community-cli-plugin/src/dev-server/readJSONBody.js new file mode 100644 index 000000000000..f23b5f1385dd --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/readJSONBody.js @@ -0,0 +1,41 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {IncomingMessage} from 'node:http'; + +const MAX_BODY_BYTES = 100 * 1024; + +/** + * Read a request body as JSON. Rejects when the body is not JSON or exceeds + * 100 KiB, in which case the request is destroyed. + */ +export default function readJSONBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Array = []; + let size = 0; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(new Error('Request body too large')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch (e) { + reject(e); + } + }); + req.on('error', reject); + }); +} diff --git a/packages/community-cli-plugin/src/dev-server/runDevServer.js b/packages/community-cli-plugin/src/dev-server/runDevServer.js index fe120f4e9537..872d97531f34 100644 --- a/packages/community-cli-plugin/src/dev-server/runDevServer.js +++ b/packages/community-cli-plugin/src/dev-server/runDevServer.js @@ -16,8 +16,11 @@ import attachKeyHandlers from './attachKeyHandlers'; import createDevMiddlewareLogger from './createDevMiddlewareLogger'; import isDevServerRunning from './isDevServerRunning'; import loadCommunityMiddleware from './loadCommunityMiddleware'; +import openStackFrameMiddleware from './openStackFrameMiddleware'; +import openURLMiddleware from './openURLMiddleware'; import * as version from './version'; import {createDevMiddleware} from '@react-native/dev-middleware'; +import connect from 'connect'; import * as Metro from 'metro'; import path from 'node:path'; import url from 'node:url'; @@ -60,7 +63,7 @@ async function runDevServer( const hostname = args.host?.length ? args.host : 'localhost'; const { projectRoot, - server: {port}, + server: {port, unstable_serverRoot}, watchFolders, } = metroConfig; const protocol = args.https === true ? 'https' : 'http'; @@ -122,6 +125,16 @@ async function runDevServer( serverBaseUrl: devServerUrl, logger: createDevMiddlewareLogger(terminalReporter), }); + const serverBaseUrl = new URL(devServerUrl); + const pluginMiddleware = connect() + .use( + '/open-stack-frame', + openStackFrameMiddleware({ + serverBaseUrl, + watchFolders: [unstable_serverRoot ?? projectRoot, ...watchFolders], + }), + ) + .use('/open-url', openURLMiddleware({serverBaseUrl})); const reporter: Reporter = { update(event: TerminalReportableEvent) { @@ -153,7 +166,11 @@ async function runDevServer( secure: args.https, secureCert: args.cert, secureKey: args.key, - unstable_extraMiddleware: [communityMiddleware, middleware], + unstable_extraMiddleware: [ + communityMiddleware, + pluginMiddleware, + middleware, + ], websocketEndpoints: { ...communityWebsocketEndpoints, ...websocketEndpoints,