Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/safe-tools-open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@callstack/repack-dev-server": patch
---

Validate `/open-url` requests before passing their targets to the operating system.
Original file line number Diff line number Diff line change
@@ -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,<script>alert(1)</script>'],
['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();
});
});
47 changes: 42 additions & 5 deletions packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(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<unknown>(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 }
Expand All @@ -29,7 +59,14 @@ async function devtoolsPlugin(
method: ['POST'],
url: '/open-url',
handler: async (request, reply) => {
const { url } = parseRequestBody<OpenURLRequestBody>(request.body);
let url: string;
try {
url = validateURLForOpen(request.body);
} catch {
reply.code(400).send(INVALID_URL);
return;
}

await open(url);
reply.send('OK');
},
Expand Down
Loading