From aaff9d929995338aa4f80f7e8a401a2e912fcb45 Mon Sep 17 00:00:00 2001 From: sanasif786ka Date: Tue, 7 Jul 2026 21:48:37 +0530 Subject: [PATCH] fix(security): block SSRF via untrusted requestUrl hosts Validate absolute requestUrl hosts against an allowlist and reject private/internal addresses unless running on localhost. Fixes #249 --- src/shared/repoData.test.ts | 11 +++++ src/shared/repoData.ts | 89 ++++++++++++++++++++++++++++++++----- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/src/shared/repoData.test.ts b/src/shared/repoData.test.ts index 1e994b44..82b721c7 100644 --- a/src/shared/repoData.test.ts +++ b/src/shared/repoData.test.ts @@ -172,6 +172,17 @@ const flatTestCases = { "localhost:3000/docs", ], }; +describe("getRepoData security", () => { + it("blocks SSRF via arbitrary absolute requestUrl hosts", () => { + expect(() => + getRepoData({ + requestHost: "gitmcp.io", + requestUrl: "http://internal-service:8080/admin", + }), + ).toThrow(/Blocked request to untrusted host/); + }); +}); + describe("getRepoDataFromUrl", () => { Object.entries(flatTestCases).forEach(([testCase, urls]) => { it(`should return the correct repo data for ${testCase}`, () => { diff --git a/src/shared/repoData.ts b/src/shared/repoData.ts index 08d0b3f2..ea6b6a47 100644 --- a/src/shared/repoData.ts +++ b/src/shared/repoData.ts @@ -26,17 +26,7 @@ export function getRepoData(requestData: RequestData): RepoData { requestUrl, requestHost, }; - const protocol = requestHost.includes("localhost") ? "http" : "https"; - let fullUrl = new URL(`${protocol}://${requestHost}`); - if (requestUrl) { - if (requestUrl.startsWith("/")) { - fullUrl = new URL(`${protocol}://${requestHost}${requestUrl}`); - } else if (requestUrl.startsWith("http")) { - fullUrl = new URL(requestUrl); - } else { - fullUrl = new URL(`${protocol}://${requestUrl}`); - } - } + const fullUrl = resolveRequestUrl(requestHost, requestUrl); const path = fullUrl.pathname.split("/").filter(Boolean).join("/"); // Check for subdomain pattern: {subdomain}.gitmcp.io/{path} @@ -112,6 +102,83 @@ function log(...args: any[]) { console.log(...args); } +const BLOCKED_HOSTNAMES = new Set([ + "localhost", + "127.0.0.1", + "0.0.0.0", + "::1", +]); + +function isPrivateIpv4(hostname: string): boolean { + const match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!match) { + return false; + } + const octets = match.slice(1).map((part) => Number(part)); + if (octets.some((octet) => octet > 255)) { + return false; + } + const [a, b] = octets; + return ( + a === 10 || + a === 127 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 169 && b === 254) + ); +} + +function isAllowedRequestHost(hostname: string, requestHost: string): boolean { + const normalizedHost = hostname.toLowerCase(); + const normalizedRequestHost = requestHost.toLowerCase(); + + if (BLOCKED_HOSTNAMES.has(normalizedHost) || isPrivateIpv4(normalizedHost)) { + return normalizedRequestHost.includes("localhost"); + } + + if (normalizedHost === normalizedRequestHost) { + return true; + } + + const allowedHosts = new Set([ + "gitmcp.io", + "www.gitmcp.io", + "github.com", + "www.github.com", + HOST_TEMP_URL.toLowerCase(), + "git-mcp.idosalomon.workers.dev", + ]); + + return ( + allowedHosts.has(normalizedHost) || normalizedHost.endsWith(".gitmcp.io") + ); +} + +function resolveRequestUrl(requestHost: string, requestUrl?: string): URL { + const protocol = requestHost.includes("localhost") ? "http" : "https"; + let fullUrl = new URL(`${protocol}://${requestHost}`); + if (requestUrl) { + if (requestUrl.startsWith("/")) { + fullUrl = new URL(`${protocol}://${requestHost}${requestUrl}`); + } else if (requestUrl.startsWith("http")) { + fullUrl = new URL(requestUrl); + if (!isAllowedRequestHost(fullUrl.hostname, requestHost)) { + throw new Error( + `Blocked request to untrusted host: ${fullUrl.hostname}`, + ); + } + } else { + fullUrl = new URL(`${protocol}://${requestUrl}`); + if (!isAllowedRequestHost(fullUrl.hostname, requestHost)) { + throw new Error( + `Blocked request to untrusted host: ${fullUrl.hostname}`, + ); + } + } + } + return fullUrl; +} + export const HOST_TEMP_URL = "remote-mcp-server-cf.idosalomon.workers.dev"; export function getRepoDataFromUrl(url: string): MinimalRepoData {