Skip to content

Commit 44ecf65

Browse files
committed
fix(dev-server): validate open-url targets
1 parent b63a6b5 commit 44ecf65

3 files changed

Lines changed: 194 additions & 5 deletions

File tree

‎.changeset/safe-tools-open.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@callstack/repack-dev-server": patch
3+
---
4+
5+
Validate `/open-url` requests before passing their targets to the operating system.
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import Fastify, { type FastifyInstance } from 'fastify';
2+
import fastifyPlugin from 'fastify-plugin';
3+
import open from 'open';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import type { Server } from '../../../types.js';
6+
import devtoolsPlugin from '../devtoolsPlugin.js';
7+
8+
vi.mock('open', () => ({
9+
default: vi.fn(),
10+
}));
11+
12+
const openMock = vi.mocked(open);
13+
14+
async function createTestServer() {
15+
const instance = Fastify();
16+
17+
await instance.register(
18+
fastifyPlugin(
19+
async (fastify) => {
20+
fastify.decorate('wss', {
21+
messageServer: {
22+
broadcast: vi.fn(),
23+
},
24+
} as unknown as FastifyInstance['wss']);
25+
},
26+
{ name: 'wss-plugin' }
27+
)
28+
);
29+
await instance.register(devtoolsPlugin, {
30+
delegate: {} as Server.Delegate,
31+
});
32+
33+
return instance;
34+
}
35+
36+
describe('devtoolsPlugin /open-url', () => {
37+
let instance: FastifyInstance;
38+
39+
beforeEach(async () => {
40+
openMock.mockResolvedValue({} as never);
41+
instance = await createTestServer();
42+
});
43+
44+
afterEach(async () => {
45+
await instance.close();
46+
vi.clearAllMocks();
47+
});
48+
49+
it.each([
50+
'https://reactnative.dev/docs/tutorial',
51+
'http://localhost:8081/docs?platform=ios#debugging',
52+
'https://[::1]:8081/path',
53+
'https://example.com/a%20path?value=%24safe#fragment',
54+
])('should open the validated browser URL %s', async (url) => {
55+
const response = await instance.inject({
56+
method: 'POST',
57+
url: '/open-url',
58+
payload: { url },
59+
});
60+
61+
expect(response.statusCode).toBe(200);
62+
expect(response.body).toBe('OK');
63+
expect(openMock).toHaveBeenCalledOnce();
64+
expect(openMock).toHaveBeenCalledWith(new URL(url).href);
65+
});
66+
67+
it('should accept a text/plain JSON request body', async () => {
68+
const url = 'https://reactnative.dev/docs/debugging';
69+
const response = await instance.inject({
70+
method: 'POST',
71+
url: '/open-url',
72+
headers: {
73+
'content-type': 'text/plain',
74+
},
75+
payload: JSON.stringify({ url }),
76+
});
77+
78+
expect(response.statusCode).toBe(200);
79+
expect(openMock).toHaveBeenCalledWith(url);
80+
});
81+
82+
it.each([
83+
['bare executable', 'calc.exe'],
84+
['Windows executable path', 'C:\\Windows\\System32\\calc.exe'],
85+
['POSIX application path', '/Applications/Calculator.app'],
86+
['UNC path', '\\\\server\\share\\payload.exe'],
87+
['file URL', 'file:///etc/passwd'],
88+
['JavaScript URL', 'javascript:alert(1)'],
89+
['data URL', 'data:text/html,<script>alert(1)</script>'],
90+
['custom URL scheme', 'ms-msdt:/id'],
91+
['PowerShell subexpression', 'https://example.invalid/$(calc.exe)'],
92+
['PowerShell environment expansion', 'https://example.invalid/$env:PATH'],
93+
['PowerShell escape character', 'https://example.invalid/?value=`whoami'],
94+
])('should reject a %s', async (_name, url) => {
95+
const response = await instance.inject({
96+
method: 'POST',
97+
url: '/open-url',
98+
payload: { url },
99+
});
100+
101+
expect(response.statusCode).toBe(400);
102+
expect(response.body).toBe('Invalid URL');
103+
expect(openMock).not.toHaveBeenCalled();
104+
});
105+
106+
it.each([
107+
['missing URL', {}],
108+
['non-string URL', { url: 123 }],
109+
['array body', [{ url: 'https://example.com' }]],
110+
])('should reject a request with a %s', async (_name, payload) => {
111+
const response = await instance.inject({
112+
method: 'POST',
113+
url: '/open-url',
114+
payload,
115+
});
116+
117+
expect(response.statusCode).toBe(400);
118+
expect(response.body).toBe('Invalid URL');
119+
expect(openMock).not.toHaveBeenCalled();
120+
});
121+
122+
it('should reject a request with no body', async () => {
123+
const response = await instance.inject({
124+
method: 'POST',
125+
url: '/open-url',
126+
});
127+
128+
expect(response.statusCode).toBe(400);
129+
expect(response.body).toBe('Invalid URL');
130+
expect(openMock).not.toHaveBeenCalled();
131+
});
132+
133+
it('should reject malformed text/plain JSON', async () => {
134+
const response = await instance.inject({
135+
method: 'POST',
136+
url: '/open-url',
137+
headers: {
138+
'content-type': 'text/plain',
139+
},
140+
payload: 'not JSON',
141+
});
142+
143+
expect(response.statusCode).toBe(400);
144+
expect(response.body).toBe('Invalid URL');
145+
expect(openMock).not.toHaveBeenCalled();
146+
});
147+
});

‎packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts‎

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,51 @@ import launchEditor from 'launch-editor';
44
import open from 'open';
55
import type { Server } from '../../types.js';
66

7-
interface OpenURLRequestBody {
8-
url: string;
9-
}
10-
117
interface OpenStackFrameRequestBody {
128
file: string;
139
lineNumber: number;
1410
}
1511

12+
const INVALID_URL = 'Invalid URL';
13+
const POWERSHELL_INTERPOLATION_CHARACTERS = /[$`]/;
14+
1615
function parseRequestBody<T>(body: unknown): T {
1716
if (typeof body === 'object') return body as T;
1817
if (typeof body === 'string') return JSON.parse(body) as T;
1918
throw new Error(`Unsupported body type: ${typeof body}`);
2019
}
2120

21+
function validateURLForOpen(body: unknown): string {
22+
const parsedBody = parseRequestBody<unknown>(body);
23+
if (
24+
typeof parsedBody !== 'object' ||
25+
parsedBody === null ||
26+
Array.isArray(parsedBody)
27+
) {
28+
throw new Error(INVALID_URL);
29+
}
30+
31+
const { url } = parsedBody as { url?: unknown };
32+
if (typeof url !== 'string') {
33+
throw new Error(INVALID_URL);
34+
}
35+
36+
const parsedURL = new URL(url);
37+
if (parsedURL.protocol !== 'http:' && parsedURL.protocol !== 'https:') {
38+
throw new Error(INVALID_URL);
39+
}
40+
41+
const normalizedURL = parsedURL.href;
42+
43+
// open@10 interpolates its target into a double-quoted PowerShell command on
44+
// Windows. Reject characters that PowerShell expands inside those strings.
45+
if (POWERSHELL_INTERPOLATION_CHARACTERS.test(normalizedURL)) {
46+
throw new Error(INVALID_URL);
47+
}
48+
49+
return normalizedURL;
50+
}
51+
2252
async function devtoolsPlugin(
2353
instance: FastifyInstance,
2454
{ delegate }: { delegate: Server.Delegate }
@@ -29,7 +59,14 @@ async function devtoolsPlugin(
2959
method: ['POST'],
3060
url: '/open-url',
3161
handler: async (request, reply) => {
32-
const { url } = parseRequestBody<OpenURLRequestBody>(request.body);
62+
let url: string;
63+
try {
64+
url = validateURLForOpen(request.body);
65+
} catch {
66+
reply.code(400).send(INVALID_URL);
67+
return;
68+
}
69+
3370
await open(url);
3471
reply.send('OK');
3572
},

0 commit comments

Comments
 (0)