diff --git a/.changeset/safe-tools-open.md b/.changeset/safe-tools-open.md new file mode 100644 index 000000000..e762502a1 --- /dev/null +++ b/.changeset/safe-tools-open.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack-dev-server": patch +--- + +Validate `/open-url` requests before passing their targets to the operating system. diff --git a/packages/dev-server/src/plugins/devtools/__tests__/devtoolsPlugin.test.ts b/packages/dev-server/src/plugins/devtools/__tests__/devtoolsPlugin.test.ts new file mode 100644 index 000000000..c6f35db71 --- /dev/null +++ b/packages/dev-server/src/plugins/devtools/__tests__/devtoolsPlugin.test.ts @@ -0,0 +1,147 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyPlugin from 'fastify-plugin'; +import open from 'open'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Server } from '../../../types.js'; +import devtoolsPlugin from '../devtoolsPlugin.js'; + +vi.mock('open', () => ({ + default: vi.fn(), +})); + +const openMock = vi.mocked(open); + +async function createTestServer() { + const instance = Fastify(); + + await instance.register( + fastifyPlugin( + async (fastify) => { + fastify.decorate('wss', { + messageServer: { + broadcast: vi.fn(), + }, + } as unknown as FastifyInstance['wss']); + }, + { name: 'wss-plugin' } + ) + ); + await instance.register(devtoolsPlugin, { + delegate: {} as Server.Delegate, + }); + + return instance; +} + +describe('devtoolsPlugin /open-url', () => { + let instance: FastifyInstance; + + beforeEach(async () => { + openMock.mockResolvedValue({} as never); + instance = await createTestServer(); + }); + + afterEach(async () => { + await instance.close(); + vi.clearAllMocks(); + }); + + it.each([ + 'https://reactnative.dev/docs/tutorial', + 'http://localhost:8081/docs?platform=ios#debugging', + 'https://[::1]:8081/path', + 'https://example.com/a%20path?value=%24safe#fragment', + ])('should open the validated browser URL %s', async (url) => { + const response = await instance.inject({ + method: 'POST', + url: '/open-url', + payload: { url }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toBe('OK'); + expect(openMock).toHaveBeenCalledOnce(); + expect(openMock).toHaveBeenCalledWith(new URL(url).href); + }); + + it('should accept a text/plain JSON request body', async () => { + const url = 'https://reactnative.dev/docs/debugging'; + const response = await instance.inject({ + method: 'POST', + url: '/open-url', + headers: { + 'content-type': 'text/plain', + }, + payload: JSON.stringify({ url }), + }); + + expect(response.statusCode).toBe(200); + expect(openMock).toHaveBeenCalledWith(url); + }); + + it.each([ + ['bare executable', 'calc.exe'], + ['Windows executable path', 'C:\\Windows\\System32\\calc.exe'], + ['POSIX application path', '/Applications/Calculator.app'], + ['UNC path', '\\\\server\\share\\payload.exe'], + ['file URL', 'file:///etc/passwd'], + ['JavaScript URL', 'javascript:alert(1)'], + ['data URL', 'data:text/html,'], + ['custom URL scheme', 'ms-msdt:/id'], + ['PowerShell subexpression', 'https://example.invalid/$(calc.exe)'], + ['PowerShell environment expansion', 'https://example.invalid/$env:PATH'], + ['PowerShell escape character', 'https://example.invalid/?value=`whoami'], + ])('should reject a %s', async (_name, url) => { + const response = await instance.inject({ + method: 'POST', + url: '/open-url', + payload: { url }, + }); + + expect(response.statusCode).toBe(400); + expect(response.body).toBe('Invalid URL'); + expect(openMock).not.toHaveBeenCalled(); + }); + + it.each([ + ['missing URL', {}], + ['non-string URL', { url: 123 }], + ['array body', [{ url: 'https://example.com' }]], + ])('should reject a request with a %s', async (_name, payload) => { + const response = await instance.inject({ + method: 'POST', + url: '/open-url', + payload, + }); + + expect(response.statusCode).toBe(400); + expect(response.body).toBe('Invalid URL'); + expect(openMock).not.toHaveBeenCalled(); + }); + + it('should reject a request with no body', async () => { + const response = await instance.inject({ + method: 'POST', + url: '/open-url', + }); + + expect(response.statusCode).toBe(400); + expect(response.body).toBe('Invalid URL'); + expect(openMock).not.toHaveBeenCalled(); + }); + + it('should reject malformed text/plain JSON', async () => { + const response = await instance.inject({ + method: 'POST', + url: '/open-url', + headers: { + 'content-type': 'text/plain', + }, + payload: 'not JSON', + }); + + expect(response.statusCode).toBe(400); + expect(response.body).toBe('Invalid URL'); + expect(openMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts b/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts index 7ffe06063..f76442047 100644 --- a/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts +++ b/packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts @@ -4,21 +4,51 @@ import launchEditor from 'launch-editor'; import open from 'open'; import type { Server } from '../../types.js'; -interface OpenURLRequestBody { - url: string; -} - interface OpenStackFrameRequestBody { file: string; lineNumber: number; } +const INVALID_URL = 'Invalid URL'; +const POWERSHELL_INTERPOLATION_CHARACTERS = /[$`]/; + function parseRequestBody(body: unknown): T { if (typeof body === 'object') return body as T; if (typeof body === 'string') return JSON.parse(body) as T; throw new Error(`Unsupported body type: ${typeof body}`); } +function validateURLForOpen(body: unknown): string { + const parsedBody = parseRequestBody(body); + if ( + typeof parsedBody !== 'object' || + parsedBody === null || + Array.isArray(parsedBody) + ) { + throw new Error(INVALID_URL); + } + + const { url } = parsedBody as { url?: unknown }; + if (typeof url !== 'string') { + throw new Error(INVALID_URL); + } + + const parsedURL = new URL(url); + if (parsedURL.protocol !== 'http:' && parsedURL.protocol !== 'https:') { + throw new Error(INVALID_URL); + } + + const normalizedURL = parsedURL.href; + + // open@10 interpolates its target into a double-quoted PowerShell command on + // Windows. Reject characters that PowerShell expands inside those strings. + if (POWERSHELL_INTERPOLATION_CHARACTERS.test(normalizedURL)) { + throw new Error(INVALID_URL); + } + + return normalizedURL; +} + async function devtoolsPlugin( instance: FastifyInstance, { delegate }: { delegate: Server.Delegate } @@ -29,7 +59,14 @@ async function devtoolsPlugin( method: ['POST'], url: '/open-url', handler: async (request, reply) => { - const { url } = parseRequestBody(request.body); + let url: string; + try { + url = validateURLForOpen(request.body); + } catch { + reply.code(400).send(INVALID_URL); + return; + } + await open(url); reply.send('OK'); },