From 870fce954ed4a38bd0963251ed035145d40c4bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 12 Nov 2025 08:36:53 +0100 Subject: [PATCH 1/3] fix: RCE vulnerability from CVE-2025-11953 --- .../cli-server-api/src/openURLMiddleware.ts | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/cli-server-api/src/openURLMiddleware.ts b/packages/cli-server-api/src/openURLMiddleware.ts index 588600790..d7e13d608 100644 --- a/packages/cli-server-api/src/openURLMiddleware.ts +++ b/packages/cli-server-api/src/openURLMiddleware.ts @@ -31,20 +31,38 @@ 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 parsedUrl: URL; try { - const parsedUrl = new URL(url); - if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - res.writeHead(400); - res.end('Invalid URL protocol'); - return; - } + parsedUrl = new URL(url); } catch (error) { res.writeHead(400); res.end('Invalid URL format'); return; } - await open(url); + // Only allow http and https protocols + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + res.writeHead(400); + res.end('Invalid URL protocol'); + return; + } + + // Reconstruct URL with proper encoding to prevent command injection + // The URL constructor doesn't automatically encode special characters like | in query strings, + // which can be interpreted as shell commands. + // So we create a new URL object with sanitized components to prevent command injection. + const sanitizedUrl = new URL(parsedUrl.origin); + sanitizedUrl.pathname = encodeURI(parsedUrl.pathname); + sanitizedUrl.search = new URLSearchParams(parsedUrl.search).toString(); + sanitizedUrl.hash = encodeURI(parsedUrl.hash); + + await open(sanitizedUrl.href); res.writeHead(200); res.end(); From 1b7488d548c039ca2d6bd6f16c2a6fe30584469a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 12 Nov 2025 08:46:44 +0100 Subject: [PATCH 2/3] remove comment --- packages/cli-server-api/src/openURLMiddleware.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli-server-api/src/openURLMiddleware.ts b/packages/cli-server-api/src/openURLMiddleware.ts index d7e13d608..4df72664b 100644 --- a/packages/cli-server-api/src/openURLMiddleware.ts +++ b/packages/cli-server-api/src/openURLMiddleware.ts @@ -46,7 +46,6 @@ async function openURLMiddleware( return; } - // Only allow http and https protocols if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { res.writeHead(400); res.end('Invalid URL protocol'); From b2fce39fd94b0b867594ae5ef3d42669f87e6ef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 12 Nov 2025 09:57:10 +0100 Subject: [PATCH 3/3] add test --- .../src/__tests__/openURLMiddleware.test.ts | 145 ++++++++++++++++++ .../cli-server-api/src/openURLMiddleware.ts | 2 +- 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 packages/cli-server-api/src/__tests__/openURLMiddleware.test.ts 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..fbafde894 --- /dev/null +++ b/packages/cli-server-api/src/__tests__/openURLMiddleware.test.ts @@ -0,0 +1,145 @@ +import http from 'http'; +import open from 'open'; +import {openURLMiddleware} from '../openURLMiddleware'; + +jest.mock('open'); + +describe('openURLMiddleware', () => { + let req: http.IncomingMessage & {body?: Object}; + let res: jest.Mocked; + let next: jest.Mock; + + beforeEach(() => { + req = { + method: 'POST', + body: {}, + } as any; + + res = { + writeHead: jest.fn(), + end: jest.fn(), + } as any; + + next = jest.fn(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should sanitize URL with pipe character to prevent RCE', async () => { + const maliciousUrl = 'https://example.com/|rm -rf /'; + req.body = {url: maliciousUrl}; + + await openURLMiddleware(req, res, next); + + // Verify that open was called with a sanitized URL + expect(open).toHaveBeenCalledTimes(1); + const sanitizedUrl = (open as jest.Mock).mock.calls[0][0]; + + // The sanitized URL should not contain the raw pipe character that could execute shell commands + // The pipe character should be encoded (as %7C) to prevent shell command execution + expect(sanitizedUrl).not.toContain('|rm -rf /'); + expect(sanitizedUrl).not.toContain('|'); + // Verify the pipe character is URL-encoded (as %7C) instead of raw + expect(sanitizedUrl).toContain('%7C'); + expect(sanitizedUrl).toMatch(/^https:\/\/example\.com/); + + expect(res.writeHead).toHaveBeenCalledWith(200); + expect(res.end).toHaveBeenCalled(); + }); + + it('should sanitize URL with pipe character in query string', async () => { + const maliciousUrl = 'https://example.com/path?param=value|rm -rf /'; + req.body = {url: maliciousUrl}; + + await openURLMiddleware(req, res, next); + + expect(open).toHaveBeenCalledTimes(1); + const sanitizedUrl = (open as jest.Mock).mock.calls[0][0]; + + // The pipe character in query string should be properly encoded (as %7C) + expect(sanitizedUrl).not.toContain('|rm -rf /'); + expect(sanitizedUrl).not.toContain('|'); + expect(sanitizedUrl).toContain('%7C'); + expect(sanitizedUrl).toMatch(/^https:\/\/example\.com/); + + expect(res.writeHead).toHaveBeenCalledWith(200); + expect(res.end).toHaveBeenCalled(); + }); + + it('should sanitize URL with pipe character in path', async () => { + const maliciousUrl = 'https://example.com/path|rm -rf /'; + req.body = {url: maliciousUrl}; + + await openURLMiddleware(req, res, next); + + expect(open).toHaveBeenCalledTimes(1); + const sanitizedUrl = (open as jest.Mock).mock.calls[0][0]; + + // The pipe character in path should be properly encoded (as %7C) + expect(sanitizedUrl).not.toContain('|rm -rf /'); + expect(sanitizedUrl).not.toContain('|'); + expect(sanitizedUrl).toContain('%7C'); + expect(sanitizedUrl).toMatch(/^https:\/\/example\.com/); + + expect(res.writeHead).toHaveBeenCalledWith(200); + expect(res.end).toHaveBeenCalled(); + }); + + it('should handle normal URLs without pipe characters', async () => { + const normalUrl = 'https://example.com/path?param=value'; + req.body = {url: normalUrl}; + + await openURLMiddleware(req, res, next); + + expect(open).toHaveBeenCalledTimes(1); + const sanitizedUrl = (open as jest.Mock).mock.calls[0][0]; + + expect(sanitizedUrl).toBe('https://example.com/path?param=value'); + + expect(res.writeHead).toHaveBeenCalledWith(200); + expect(res.end).toHaveBeenCalled(); + }); + + it('should return 400 for missing request body', async () => { + req.body = undefined; + + await openURLMiddleware(req, res, next); + + expect(open).not.toHaveBeenCalled(); + expect(res.writeHead).toHaveBeenCalledWith(400); + expect(res.end).toHaveBeenCalledWith('Missing request body'); + }); + + it('should return 400 for non-string URL', async () => { + req.body = {url: 123}; + + await openURLMiddleware(req, res, next); + + expect(open).not.toHaveBeenCalled(); + expect(res.writeHead).toHaveBeenCalledWith(400); + expect(res.end).toHaveBeenCalledWith('URL must be a string'); + }); + + it('should return 400 for invalid URL format', async () => { + req.body = {url: 'not-a-valid-url'}; + + await openURLMiddleware(req, res, next); + + expect(open).not.toHaveBeenCalled(); + expect(res.writeHead).toHaveBeenCalledWith(400); + expect(res.end).toHaveBeenCalledWith('Invalid URL format'); + }); + + it('should return 400 for invalid URL protocol', async () => { + req.body = {url: 'file:///etc/passwd'}; + + await openURLMiddleware(req, res, next); + + expect(open).not.toHaveBeenCalled(); + expect(res.writeHead).toHaveBeenCalledWith(400); + expect(res.end).toHaveBeenCalledWith('Invalid URL protocol'); + }); +}); diff --git a/packages/cli-server-api/src/openURLMiddleware.ts b/packages/cli-server-api/src/openURLMiddleware.ts index 4df72664b..361fee575 100644 --- a/packages/cli-server-api/src/openURLMiddleware.ts +++ b/packages/cli-server-api/src/openURLMiddleware.ts @@ -14,7 +14,7 @@ import open from 'open'; /** * Open a URL in the system browser. */ -async function openURLMiddleware( +export async function openURLMiddleware( req: IncomingMessage & { // Populated by body-parser body?: Object;