diff --git a/packages/cli-server-api/package.json b/packages/cli-server-api/package.json index 4610149de..e4a015479 100644 --- a/packages/cli-server-api/package.json +++ b/packages/cli-server-api/package.json @@ -16,6 +16,7 @@ "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", + "strict-url-sanitise": "0.0.1", "ws": "^6.2.3" }, "devDependencies": { diff --git a/packages/cli-server-api/src/__tests__/openURLMiddleware.test.ts b/packages/cli-server-api/src/__tests__/openURLMiddleware.test.ts new file mode 100644 index 000000000..0f26e8274 --- /dev/null +++ b/packages/cli-server-api/src/__tests__/openURLMiddleware.test.ts @@ -0,0 +1,117 @@ +import http from 'http'; +import {Readable} from 'stream'; +import open from 'open'; +import openURLMiddleware from '../openURLMiddleware'; + +jest.mock('open'); + +function createMockRequest(method: string, body: object): http.IncomingMessage { + const bodyStr = JSON.stringify(body); + const readable = new Readable(); + readable.push(bodyStr); + readable.push(null); + + return Object.assign(readable, { + method, + url: '/', + headers: { + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(bodyStr)), + }, + }) as unknown as http.IncomingMessage; +} + +describe('openURLMiddleware', () => { + let res: jest.Mocked; + let next: jest.Mock; + + beforeEach(() => { + res = { + writeHead: jest.fn(), + end: jest.fn(), + setHeader: jest.fn(), + } as any; + + next = jest.fn(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should return 400 for non-string URL', (done) => { + const req = createMockRequest('POST', {url: 123}); + + res.end = jest.fn(() => { + try { + expect(open).not.toHaveBeenCalled(); + expect(res.writeHead).toHaveBeenCalledWith(400); + expect(res.end).toHaveBeenCalledWith('URL must be a string'); + done(); + } catch (error) { + done(error); + } + }) as any; + + openURLMiddleware(req, res, next); + }); + + // CVE-2025-11953 + test('should reject malicious URL with invalid hostname', (done) => { + const maliciousUrl = 'https://www.$(calc.exe).com/foo'; + const req = createMockRequest('POST', {url: maliciousUrl}); + + res.end = jest.fn(() => { + try { + expect(open).not.toHaveBeenCalled(); + expect(res.writeHead).toHaveBeenCalledWith(400); + expect(res.end).toHaveBeenCalledWith('Invalid URL'); + done(); + } catch (error) { + done(error); + } + }) as any; + + openURLMiddleware(req, res, next); + }); + + // CVE-2025-11953 + test('should reject URL with Windows pipe separator', (done) => { + const maliciousUrl = 'https://evil.com?|calc.exe'; + const req = createMockRequest('POST', {url: maliciousUrl}); + + res.end = jest.fn(() => { + try { + expect(open).not.toHaveBeenCalled(); + expect(res.writeHead).toHaveBeenCalledWith(400); + expect(res.end).toHaveBeenCalledWith('Invalid URL'); + done(); + } catch (error) { + done(error); + } + }) as any; + + openURLMiddleware(req, res, next); + }); + + // CVE-2025-11953 + test('should reject URL with Windows command exfiltration', (done) => { + // Encodes to reveal %BETA% env var + const maliciousUrl = 'https://example.com/?a=%¾TA%'; + const req = createMockRequest('POST', {url: maliciousUrl}); + + res.end = jest.fn(() => { + try { + expect(open).not.toHaveBeenCalled(); + expect(res.writeHead).toHaveBeenCalledWith(400); + expect(res.end).toHaveBeenCalledWith('Invalid URL'); + done(); + } catch (error) { + done(error); + } + }) as any; + + openURLMiddleware(req, res, next); + }); +}); diff --git a/packages/cli-server-api/src/openURLMiddleware.ts b/packages/cli-server-api/src/openURLMiddleware.ts index 588600790..14d5eae1a 100644 --- a/packages/cli-server-api/src/openURLMiddleware.ts +++ b/packages/cli-server-api/src/openURLMiddleware.ts @@ -10,6 +10,7 @@ import type {IncomingMessage, ServerResponse} from 'http'; import {json} from 'body-parser'; import connect from 'connect'; import open from 'open'; +import {sanitizeUrl} from 'strict-url-sanitise'; /** * Open a URL in the system browser. @@ -31,20 +32,22 @@ async function openURLMiddleware( const {url} = req.body as {url: string}; + if (typeof url !== 'string') { + res.writeHead(400); + res.end('URL must be a string'); + return; + } + + let sanitizedUrl: string; try { - const parsedUrl = new URL(url); - if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - res.writeHead(400); - res.end('Invalid URL protocol'); - return; - } - } catch (error) { + sanitizedUrl = sanitizeUrl(url); + } catch { res.writeHead(400); - res.end('Invalid URL format'); + res.end('Invalid URL'); return; } - await open(url); + await open(sanitizedUrl); res.writeHead(200); res.end(); diff --git a/yarn.lock b/yarn.lock index 09cccd258..222f1725c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9703,6 +9703,11 @@ stream-buffers@2.2.x: resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-2.2.0.tgz#91d5f5130d1cef96dcfa7f726945188741d09ee4" integrity sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg== +strict-url-sanitise@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/strict-url-sanitise/-/strict-url-sanitise-0.0.1.tgz#10cfac63c9dfdd856d98ab9f76433dad5ce99e0c" + integrity sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg== + string-argv@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"