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');
},