From 7f3e8baede7a91edd192db4f26265cbe645e14c2 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Thu, 29 Oct 2026 19:41:59 +0000 Subject: [PATCH] Add cross-origin guard to community-cli-plugin dev server --- flow-typed/npm/compression_v1.x.x.js | 15 ++++ flow-typed/npm/nocache_v3.x.x.js | 15 ++++ packages/community-cli-plugin/package.json | 2 + .../createDevServerMiddleware-test.js | 56 +++++++++++++++ .../openStackFrameMiddleware-test.js | 53 ++------------ .../__tests__/openURLMiddleware-test.js | 21 +----- .../securityHeadersMiddleware-test.js | 70 +++++++++++++++++++ .../dev-server/createDevServerMiddleware.js | 39 +++++++++++ .../dev-server/openStackFrameMiddleware.js | 14 +--- .../src/dev-server/openURLMiddleware.js | 20 +----- .../src/dev-server/runDevServer.js | 20 ++---- .../dev-server/securityHeadersMiddleware.js | 42 +++++++++++ 12 files changed, 256 insertions(+), 111 deletions(-) create mode 100644 flow-typed/npm/compression_v1.x.x.js create mode 100644 flow-typed/npm/nocache_v3.x.x.js create mode 100644 packages/community-cli-plugin/src/dev-server/__tests__/createDevServerMiddleware-test.js create mode 100644 packages/community-cli-plugin/src/dev-server/__tests__/securityHeadersMiddleware-test.js create mode 100644 packages/community-cli-plugin/src/dev-server/createDevServerMiddleware.js create mode 100644 packages/community-cli-plugin/src/dev-server/securityHeadersMiddleware.js diff --git a/flow-typed/npm/compression_v1.x.x.js b/flow-typed/npm/compression_v1.x.x.js new file mode 100644 index 00000000000..b7abf1ecbdb --- /dev/null +++ b/flow-typed/npm/compression_v1.x.x.js @@ -0,0 +1,15 @@ +/** + * 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 'compression' { + import type {NextHandleFunction} from 'connect'; + + declare module.exports: () => NextHandleFunction; +} diff --git a/flow-typed/npm/nocache_v3.x.x.js b/flow-typed/npm/nocache_v3.x.x.js new file mode 100644 index 00000000000..fb29b6ab9fa --- /dev/null +++ b/flow-typed/npm/nocache_v3.x.x.js @@ -0,0 +1,15 @@ +/** + * 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 'nocache' { + import type {NextHandleFunction} from 'connect'; + + declare module.exports: () => NextHandleFunction; +} diff --git a/packages/community-cli-plugin/package.json b/packages/community-cli-plugin/package.json index 90a2edb976a..4dea1b8c911 100644 --- a/packages/community-cli-plugin/package.json +++ b/packages/community-cli-plugin/package.json @@ -35,11 +35,13 @@ "@react-native/asset-utils": "0.87.0-main", "@react-native/dev-middleware": "0.87.0-main", "commander": "^12.0.0", + "compression": "^1.7.1", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "launch-editor": "^2.9.1", "metro": "^0.87.2", + "nocache": "^3.0.1", "open": "^8.4.2", "semver": "^7.1.3" }, diff --git a/packages/community-cli-plugin/src/dev-server/__tests__/createDevServerMiddleware-test.js b/packages/community-cli-plugin/src/dev-server/__tests__/createDevServerMiddleware-test.js new file mode 100644 index 00000000000..a439be07e60 --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/__tests__/createDevServerMiddleware-test.js @@ -0,0 +1,56 @@ +/** + * 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 http from 'node:http'; + +jest.useRealTimers(); + +let server: http$Server; +let baseUrl: string; + +beforeEach(async () => { + const connect = require('connect'); + const createDevServerMiddleware = + require('../createDevServerMiddleware').default; + 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( + createDevServerMiddleware({ + serverBaseUrl: new URL(baseUrl), + watchFolders: ['/project'], + }), + ) + .use((req, res) => { + res.writeHead(404); + res.end(); + }); +}); + +afterEach(() => { + server.closeAllConnections(); + return new Promise(resolve => server.close(() => resolve())); +}); + +describe('createDevServerMiddleware', () => { + test('refuses a cross-origin request before any route', async () => { + const resp = await fetch(baseUrl + '/open-url', { + headers: {Origin: 'https://example.com'}, + }); + + expect(resp.status).toBe(403); + }); +}); 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 index a2338c57396..7cd643e7a76 100644 --- a/packages/community-cli-plugin/src/dev-server/__tests__/openStackFrameMiddleware-test.js +++ b/packages/community-cli-plugin/src/dev-server/__tests__/openStackFrameMiddleware-test.js @@ -52,7 +52,7 @@ beforeEach(() => { }); async function startServer( - mount: (app: Server, serverBaseUrl: URL) => void, + mount: (app: Server) => void, ): Promise<{server: http$Server, baseUrl: string}> { const app = connect(); const server = http.createServer(app); @@ -62,7 +62,7 @@ async function startServer( throw new Error('Expected the server to listen on a TCP port'); } const baseUrl = `http://localhost:${address.port}`; - mount(app, new URL(baseUrl)); + mount(app); app.use((req, res) => { res.writeHead(404); res.end(); @@ -70,11 +70,10 @@ async function startServer( return {server, baseUrl}; } -function mountOpenStackFrame(app: Server, serverBaseUrl: URL) { +function mountOpenStackFrame(app: Server) { app.use( '/open-stack-frame', require('../openStackFrameMiddleware').default({ - serverBaseUrl, watchFolders: [projectRoot], }), ); @@ -85,14 +84,10 @@ function stopServer(server: http$Server): Promise { return new Promise(resolve => server.close(() => resolve())); } -function postFrame( - baseUrl: string, - body: string, - headers?: {Origin?: string}, -): Promise { +function postFrame(baseUrl: string, body: string): Promise { return fetch(baseUrl + '/open-stack-frame', { method: 'POST', - headers: {...headers, 'Content-Type': 'application/json'}, + headers: {'Content-Type': 'application/json'}, body, }); } @@ -129,17 +124,6 @@ describe('/open-stack-frame', () => { 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'); @@ -188,30 +172,3 @@ describe('/open-stack-frame', () => { 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 index 4227a4ff0b2..9a07b8de57a 100644 --- a/packages/community-cli-plugin/src/dev-server/__tests__/openURLMiddleware-test.js +++ b/packages/community-cli-plugin/src/dev-server/__tests__/openURLMiddleware-test.js @@ -32,12 +32,7 @@ beforeEach(async () => { } baseUrl = `http://localhost:${address.port}`; app - .use( - '/open-url', - require('../openURLMiddleware').default({ - serverBaseUrl: new URL(baseUrl), - }), - ) + .use('/open-url', require('../openURLMiddleware').default()) .use((req, res) => { res.writeHead(404); res.end(); @@ -49,10 +44,10 @@ afterEach(() => { return new Promise(resolve => server.close(() => resolve())); }); -function postURL(body: string, headers?: {Origin?: string}): Promise { +function postURL(body: string): Promise { return fetch(baseUrl + '/open-url', { method: 'POST', - headers: {...headers, 'Content-Type': 'application/json'}, + headers: {'Content-Type': 'application/json'}, body, }); } @@ -80,16 +75,6 @@ describe('/open-url', () => { 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'); diff --git a/packages/community-cli-plugin/src/dev-server/__tests__/securityHeadersMiddleware-test.js b/packages/community-cli-plugin/src/dev-server/__tests__/securityHeadersMiddleware-test.js new file mode 100644 index 00000000000..20805781924 --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/__tests__/securityHeadersMiddleware-test.js @@ -0,0 +1,70 @@ +/** + * 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 securityHeadersMiddleware from '../securityHeadersMiddleware'; +import connect from 'connect'; +import http from 'node:http'; + +jest.useRealTimers(); + +let server: http$Server; +let baseUrl: string; +let reached: JestMockFn<[], void>; + +beforeEach(async () => { + reached = jest.fn(); + 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(securityHeadersMiddleware({serverBaseUrl: new URL(baseUrl)})) + .use((req, res) => { + reached(); + res.end('OK'); + }); +}); + +afterEach(() => { + server.closeAllConnections(); + return new Promise(resolve => server.close(() => resolve())); +}); + +describe('securityHeadersMiddleware', () => { + test.each([ + ['no origin', undefined], + ['the dev server origin', 'SERVER'], + ['a local origin on another port', 'http://localhost:3000'], + ['Chrome DevTools', 'devtools://devtools/bundled/inspector.html'], + ])('accepts a request from %s', async (_, origin) => { + const resp = await fetch(baseUrl, { + headers: + origin == null ? {} : {Origin: origin === 'SERVER' ? baseUrl : origin}, + }); + + expect(resp.status).toBe(200); + expect(resp.headers.get('X-Content-Type-Options')).toBe('nosniff'); + expect(reached).toHaveBeenCalledTimes(1); + }); + + test.each([ + ['another site', 'https://example.com'], + ['a malformed origin', 'not an origin'], + ])('refuses a request from %s', async (_, origin) => { + const resp = await fetch(baseUrl, {headers: {Origin: origin}}); + + expect(resp.status).toBe(403); + expect(reached).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/community-cli-plugin/src/dev-server/createDevServerMiddleware.js b/packages/community-cli-plugin/src/dev-server/createDevServerMiddleware.js new file mode 100644 index 00000000000..15b801bc16c --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/createDevServerMiddleware.js @@ -0,0 +1,39 @@ +/** + * 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 openStackFrameMiddleware from './openStackFrameMiddleware'; +import openURLMiddleware from './openURLMiddleware'; +import securityHeadersMiddleware from './securityHeadersMiddleware'; +import compression from 'compression'; +import connect from 'connect'; +import nocache from 'nocache'; + +type Options = Readonly<{ + serverBaseUrl: URL, + watchFolders: ReadonlyArray, +}>; + +/** + * The dev server routes that React Native tooling and apps call outside + * Metro and `@react-native/dev-middleware`. + */ +export default function createDevServerMiddleware({ + serverBaseUrl, + watchFolders, +}: Options): Server { + return connect() + .use(securityHeadersMiddleware({serverBaseUrl})) + .use(compression()) + .use(nocache()) + .use('/open-stack-frame', openStackFrameMiddleware({watchFolders})) + .use('/open-url', openURLMiddleware()); +} diff --git a/packages/community-cli-plugin/src/dev-server/openStackFrameMiddleware.js b/packages/community-cli-plugin/src/dev-server/openStackFrameMiddleware.js index 44be52c9728..9c49ca5023e 100644 --- a/packages/community-cli-plugin/src/dev-server/openStackFrameMiddleware.js +++ b/packages/community-cli-plugin/src/dev-server/openStackFrameMiddleware.js @@ -10,7 +10,6 @@ import type {NextHandleFunction} from 'connect'; -import isAllowedOrigin from './isAllowedOrigin'; import readJSONBody from './readJSONBody'; import launchEditor from 'launch-editor'; import fs from 'node:fs'; @@ -19,7 +18,6 @@ import path from 'node:path'; const LAUNCH_THROTTLE_MS = 2000; type Options = Readonly<{ - serverBaseUrl: URL, watchFolders: ReadonlyArray, }>; @@ -34,7 +32,6 @@ type StackFrame = Readonly<{ * inside one of `watchFolders`; a relative path resolves against the first. */ export default function openStackFrameMiddleware({ - serverBaseUrl, watchFolders, }: Options): NextHandleFunction { const rootsPromise = Promise.all( @@ -46,20 +43,11 @@ export default function openStackFrameMiddleware({ 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) { + if (req.method !== 'POST') { 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); diff --git a/packages/community-cli-plugin/src/dev-server/openURLMiddleware.js b/packages/community-cli-plugin/src/dev-server/openURLMiddleware.js index c5c3253e2d8..14d8f36d2f7 100644 --- a/packages/community-cli-plugin/src/dev-server/openURLMiddleware.js +++ b/packages/community-cli-plugin/src/dev-server/openURLMiddleware.js @@ -10,7 +10,6 @@ import type {NextHandleFunction} from 'connect'; -import isAllowedOrigin from './isAllowedOrigin'; import readJSONBody from './readJSONBody'; import open from 'open'; @@ -20,33 +19,18 @@ const LAUNCH_THROTTLE_MS = 2000; // 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 { +export default function openURLMiddleware(): 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) { + if (req.method !== 'POST') { 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); diff --git a/packages/community-cli-plugin/src/dev-server/runDevServer.js b/packages/community-cli-plugin/src/dev-server/runDevServer.js index 872d97531f3..1b0489481c0 100644 --- a/packages/community-cli-plugin/src/dev-server/runDevServer.js +++ b/packages/community-cli-plugin/src/dev-server/runDevServer.js @@ -14,13 +14,11 @@ import type {Reporter, TerminalReportableEvent, TerminalReporter} from 'metro'; import loadMetroConfig from '../utils/loadMetroConfig'; import attachKeyHandlers from './attachKeyHandlers'; import createDevMiddlewareLogger from './createDevMiddlewareLogger'; +import createDevServerMiddleware from './createDevServerMiddleware'; 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'; @@ -125,16 +123,10 @@ 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 pluginMiddleware = createDevServerMiddleware({ + serverBaseUrl: new URL(devServerUrl), + watchFolders: [unstable_serverRoot ?? projectRoot, ...watchFolders], + }); const reporter: Reporter = { update(event: TerminalReportableEvent) { @@ -167,8 +159,8 @@ async function runDevServer( secureCert: args.cert, secureKey: args.key, unstable_extraMiddleware: [ - communityMiddleware, pluginMiddleware, + communityMiddleware, middleware, ], websocketEndpoints: { diff --git a/packages/community-cli-plugin/src/dev-server/securityHeadersMiddleware.js b/packages/community-cli-plugin/src/dev-server/securityHeadersMiddleware.js new file mode 100644 index 00000000000..dfdd62e52f0 --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/securityHeadersMiddleware.js @@ -0,0 +1,42 @@ +/** + * 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'; + +type Options = Readonly<{ + serverBaseUrl: URL, +}>; + +/** + * Refuse browser requests from an origin other than the dev server's own, a + * local hostname, or Chrome's built-in DevTools, and block MIME-type sniffing + * on every response. + */ +export default function securityHeadersMiddleware({ + serverBaseUrl, +}: Options): NextHandleFunction { + return (req, res, next) => { + const {origin} = req.headers; + if ( + typeof origin === 'string' && + !origin.startsWith('devtools://devtools') && + !isAllowedOrigin(origin, serverBaseUrl) + ) { + res.writeHead(403); + res.end('Unauthorized origin'); + return; + } + + res.setHeader('X-Content-Type-Options', 'nosniff'); + next(); + }; +}