Skip to content

Commit 45f266c

Browse files
Copilothuntiethymikee
authored
fix: prevent RCE in openURLMiddleware via URL sanitization (#2758)
* Initial plan * fix: use strict-url-sanitise to prevent RCE vulnerability (CVE-2025-11953) Co-authored-by: huntie <2547783+huntie@users.noreply.github.com> * refactor: cache sanitizeUrl to improve performance Co-authored-by: huntie <2547783+huntie@users.noreply.github.com> * simplify: use "Invalid URL" error message, remove strict-url-sanitise mock Co-authored-by: huntie <2547783+huntie@users.noreply.github.com> * style: replace it() with test() in openURLMiddleware tests Co-authored-by: huntie <2547783+huntie@users.noreply.github.com> * refactor: use ordinary import for strict-url-sanitise Co-authored-by: huntie <2547783+huntie@users.noreply.github.com> * refactor: remove named export of openURLMiddleware, test default export Co-authored-by: huntie <2547783+huntie@users.noreply.github.com> * test: add CVE-2025-11953 test cases for Windows pipe and command exfiltration Co-authored-by: huntie <2547783+huntie@users.noreply.github.com> * test: add CVE-2025-11953 comment above second test case Co-authored-by: huntie <2547783+huntie@users.noreply.github.com> * fixup tests to not hang when fail --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: huntie <2547783+huntie@users.noreply.github.com> Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
1 parent 24f026e commit 45f266c

4 files changed

Lines changed: 135 additions & 9 deletions

File tree

packages/cli-server-api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"open": "^6.2.0",
1717
"pretty-format": "^29.7.0",
1818
"serve-static": "^1.13.1",
19+
"strict-url-sanitise": "0.0.1",
1920
"ws": "^6.2.3"
2021
},
2122
"devDependencies": {
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import http from 'http';
2+
import {Readable} from 'stream';
3+
import open from 'open';
4+
import openURLMiddleware from '../openURLMiddleware';
5+
6+
jest.mock('open');
7+
8+
function createMockRequest(method: string, body: object): http.IncomingMessage {
9+
const bodyStr = JSON.stringify(body);
10+
const readable = new Readable();
11+
readable.push(bodyStr);
12+
readable.push(null);
13+
14+
return Object.assign(readable, {
15+
method,
16+
url: '/',
17+
headers: {
18+
'content-type': 'application/json',
19+
'content-length': String(Buffer.byteLength(bodyStr)),
20+
},
21+
}) as unknown as http.IncomingMessage;
22+
}
23+
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(),
32+
setHeader: jest.fn(),
33+
} as any;
34+
35+
next = jest.fn();
36+
jest.clearAllMocks();
37+
});
38+
39+
afterEach(() => {
40+
jest.restoreAllMocks();
41+
});
42+
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+
});
59+
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);
77+
});
78+
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);
96+
});
97+
98+
// 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);
116+
});
117+
});

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

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ 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';
1314

1415
/**
1516
* Open a URL in the system browser.
@@ -31,20 +32,22 @@ async function openURLMiddleware(
3132

3233
const {url} = req.body as {url: string};
3334

35+
if (typeof url !== 'string') {
36+
res.writeHead(400);
37+
res.end('URL must be a string');
38+
return;
39+
}
40+
41+
let sanitizedUrl: string;
3442
try {
35-
const parsedUrl = new URL(url);
36-
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
37-
res.writeHead(400);
38-
res.end('Invalid URL protocol');
39-
return;
40-
}
41-
} catch (error) {
43+
sanitizedUrl = sanitizeUrl(url);
44+
} catch {
4245
res.writeHead(400);
43-
res.end('Invalid URL format');
46+
res.end('Invalid URL');
4447
return;
4548
}
4649

47-
await open(url);
50+
await open(sanitizedUrl);
4851

4952
res.writeHead(200);
5053
res.end();

yarn.lock

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

9706+
strict-url-sanitise@0.0.1:
9707+
version "0.0.1"
9708+
resolved "https://registry.yarnpkg.com/strict-url-sanitise/-/strict-url-sanitise-0.0.1.tgz#10cfac63c9dfdd856d98ab9f76433dad5ce99e0c"
9709+
integrity sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg==
9710+
97069711
string-argv@^0.3.1:
97079712
version "0.3.1"
97089713
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"

0 commit comments

Comments
 (0)