Skip to content

Commit a04ee31

Browse files
committed
fix: restore open-url browser links
1 parent 04bd056 commit a04ee31

4 files changed

Lines changed: 111 additions & 96 deletions

File tree

packages/cli-server-api/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
"open": "^6.2.0",
1717
"pretty-format": "^29.7.0",
1818
"serve-static": "^1.13.1",
19-
"strict-url-sanitise": "0.0.1",
2019
"ws": "^6.2.3"
2120
},
2221
"devDependencies": {

packages/cli-server-api/src/__tests__/openURLMiddleware.test.ts

Lines changed: 66 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ import openURLMiddleware from '../openURLMiddleware';
55

66
jest.mock('open');
77

8-
function createMockRequest(method: string, body: object): http.IncomingMessage {
9-
const bodyStr = JSON.stringify(body);
8+
function createMockRequest(
9+
method: string,
10+
body?: object,
11+
): http.IncomingMessage {
12+
const bodyStr = body == null ? '' : JSON.stringify(body);
1013
const readable = new Readable();
1114
readable.push(bodyStr);
1215
readable.push(null);
@@ -21,97 +24,82 @@ function createMockRequest(method: string, body: object): http.IncomingMessage {
2124
}) as unknown as http.IncomingMessage;
2225
}
2326

24-
describe('openURLMiddleware', () => {
25-
let res: jest.Mocked<http.ServerResponse>;
26-
let next: jest.Mock;
27-
28-
beforeEach(() => {
29-
res = {
30-
writeHead: jest.fn(),
31-
end: jest.fn(),
27+
type MiddlewareResponse = {
28+
body?: string;
29+
next: jest.Mock;
30+
statusCode?: number;
31+
};
32+
33+
function callOpenURLMiddleware(
34+
body?: object,
35+
method = 'POST',
36+
): Promise<MiddlewareResponse> {
37+
return new Promise((resolve, reject) => {
38+
const response: MiddlewareResponse = {
39+
next: jest.fn((error?: Error) => {
40+
if (error) {
41+
reject(error);
42+
return;
43+
}
44+
45+
resolve(response);
46+
}),
47+
};
48+
49+
const res = {
50+
writeHead: jest.fn((statusCode: number) => {
51+
response.statusCode = statusCode;
52+
}),
53+
end: jest.fn((message?: string) => {
54+
response.body = message;
55+
resolve(response);
56+
}),
3257
setHeader: jest.fn(),
3358
} as any;
3459

35-
next = jest.fn();
60+
openURLMiddleware(createMockRequest(method, body), res, response.next);
61+
});
62+
}
63+
64+
describe('openURLMiddleware', () => {
65+
beforeEach(() => {
3666
jest.clearAllMocks();
3767
});
3868

3969
afterEach(() => {
4070
jest.restoreAllMocks();
4171
});
4272

43-
test('should return 400 for non-string URL', (done) => {
44-
const req = createMockRequest('POST', {url: 123});
45-
46-
res.end = jest.fn(() => {
47-
try {
48-
expect(open).not.toHaveBeenCalled();
49-
expect(res.writeHead).toHaveBeenCalledWith(400);
50-
expect(res.end).toHaveBeenCalledWith('URL must be a string');
51-
done();
52-
} catch (error) {
53-
done(error);
54-
}
55-
}) as any;
56-
57-
openURLMiddleware(req, res, next);
58-
});
73+
test.each([
74+
'https://reactnative.dev/docs/tutorial',
75+
'https://reactnative.dev/docs/fast-refresh',
76+
'https://x.com/reactnative',
77+
])('should open React Native welcome screen URL %s', async (url) => {
78+
const response = await callOpenURLMiddleware({url});
5979

60-
// CVE-2025-11953
61-
test('should reject malicious URL with invalid hostname', (done) => {
62-
const maliciousUrl = 'https://www.$(calc.exe).com/foo';
63-
const req = createMockRequest('POST', {url: maliciousUrl});
64-
65-
res.end = jest.fn(() => {
66-
try {
67-
expect(open).not.toHaveBeenCalled();
68-
expect(res.writeHead).toHaveBeenCalledWith(400);
69-
expect(res.end).toHaveBeenCalledWith('Invalid URL');
70-
done();
71-
} catch (error) {
72-
done(error);
73-
}
74-
}) as any;
75-
76-
openURLMiddleware(req, res, next);
80+
expect(open).toHaveBeenCalledWith(url);
81+
expect(response.statusCode).toBe(200);
82+
expect(response.next).not.toHaveBeenCalled();
7783
});
7884

79-
// CVE-2025-11953
80-
test('should reject URL with Windows pipe separator', (done) => {
81-
const maliciousUrl = 'https://evil.com?|calc.exe';
82-
const req = createMockRequest('POST', {url: maliciousUrl});
83-
84-
res.end = jest.fn(() => {
85-
try {
86-
expect(open).not.toHaveBeenCalled();
87-
expect(res.writeHead).toHaveBeenCalledWith(400);
88-
expect(res.end).toHaveBeenCalledWith('Invalid URL');
89-
done();
90-
} catch (error) {
91-
done(error);
92-
}
93-
}) as any;
94-
95-
openURLMiddleware(req, res, next);
85+
test('should return 400 for non-string URL', async () => {
86+
const response = await callOpenURLMiddleware({url: 123});
87+
88+
expect(open).not.toHaveBeenCalled();
89+
expect(response.statusCode).toBe(400);
90+
expect(response.body).toBe('URL must be a string');
9691
});
9792

9893
// CVE-2025-11953
99-
test('should reject URL with Windows command exfiltration', (done) => {
100-
// Encodes to reveal %BETA% env var
101-
const maliciousUrl = 'https://example.com/?a=%¾TA%';
102-
const req = createMockRequest('POST', {url: maliciousUrl});
103-
104-
res.end = jest.fn(() => {
105-
try {
106-
expect(open).not.toHaveBeenCalled();
107-
expect(res.writeHead).toHaveBeenCalledWith(400);
108-
expect(res.end).toHaveBeenCalledWith('Invalid URL');
109-
done();
110-
} catch (error) {
111-
done(error);
112-
}
113-
}) as any;
114-
115-
openURLMiddleware(req, res, next);
94+
test.each([
95+
['malicious URL with invalid hostname', 'https://www.$(calc.exe).com/foo'],
96+
['URL with Windows pipe separator', 'https://evil.com?|calc.exe'],
97+
['URL with Windows command exfiltration', 'https://example.com/?a=%¾TA%'],
98+
])('should reject %s', async (_name, url) => {
99+
const response = await callOpenURLMiddleware({url});
100+
101+
expect(open).not.toHaveBeenCalled();
102+
expect(response.statusCode).toBe(400);
103+
expect(response.body).toBe('Invalid URL');
116104
});
117105
});

packages/cli-server-api/src/openURLMiddleware.ts

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,43 @@ import type {IncomingMessage, ServerResponse} from 'http';
1010
import {json} from 'body-parser';
1111
import connect from 'connect';
1212
import open from 'open';
13-
import {sanitizeUrl} from 'strict-url-sanitise';
13+
14+
const WINDOWS_SHELL_SPECIAL_CHARS = /[|<>^%!]/;
15+
const INVALID_URL = 'Invalid URL';
16+
17+
function sendResponse(
18+
res: ServerResponse,
19+
statusCode: number,
20+
message?: string,
21+
) {
22+
res.writeHead(statusCode);
23+
res.end(message);
24+
}
25+
26+
function isSafeHostname(hostname: string) {
27+
return (
28+
(hostname.startsWith('[') && hostname.endsWith(']')) ||
29+
hostname === encodeURIComponent(hostname)
30+
);
31+
}
32+
33+
function validateURLForOpen(url: string) {
34+
const parsedUrl = new URL(url);
35+
36+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
37+
throw new Error('Invalid URL protocol');
38+
}
39+
40+
if (!isSafeHostname(parsedUrl.hostname)) {
41+
throw new Error('Invalid URL hostname');
42+
}
43+
44+
if (WINDOWS_SHELL_SPECIAL_CHARS.test(parsedUrl.href)) {
45+
throw new Error('Invalid URL characters');
46+
}
47+
48+
return parsedUrl.href;
49+
}
1450

1551
/**
1652
* Open a URL in the system browser.
@@ -25,32 +61,29 @@ async function openURLMiddleware(
2561
) {
2662
if (req.method === 'POST') {
2763
if (req.body == null) {
28-
res.writeHead(400);
29-
res.end('Missing request body');
64+
sendResponse(res, 400, 'Missing request body');
3065
return;
3166
}
3267

3368
const {url} = req.body as {url: string};
3469

3570
if (typeof url !== 'string') {
36-
res.writeHead(400);
37-
res.end('URL must be a string');
71+
sendResponse(res, 400, 'URL must be a string');
3872
return;
3973
}
4074

41-
let sanitizedUrl: string;
75+
let validatedUrl;
4276
try {
43-
sanitizedUrl = sanitizeUrl(url);
77+
validatedUrl = validateURLForOpen(url);
4478
} catch {
45-
res.writeHead(400);
46-
res.end('Invalid URL');
79+
sendResponse(res, 400, INVALID_URL);
4780
return;
4881
}
4982

50-
await open(sanitizedUrl);
83+
await open(validatedUrl);
5184

52-
res.writeHead(200);
53-
res.end();
85+
sendResponse(res, 200);
86+
return;
5487
}
5588

5689
next();

yarn.lock

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9922,11 +9922,6 @@ stream-buffers@2.2.x:
99229922
resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-2.2.0.tgz#91d5f5130d1cef96dcfa7f726945188741d09ee4"
99239923
integrity sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==
99249924

9925-
strict-url-sanitise@0.0.1:
9926-
version "0.0.1"
9927-
resolved "https://registry.yarnpkg.com/strict-url-sanitise/-/strict-url-sanitise-0.0.1.tgz#10cfac63c9dfdd856d98ab9f76433dad5ce99e0c"
9928-
integrity sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg==
9929-
99309925
string-argv@^0.3.1:
99319926
version "0.3.1"
99329927
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"

0 commit comments

Comments
 (0)