diff --git a/file-size-baseline.json b/file-size-baseline.json index 5f06f8a4d3e..58a72e877e0 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -33,7 +33,7 @@ "open-sse/services/usage.ts": 3394, "open-sse/translator/request/openai-to-gemini.ts": 844, "open-sse/translator/response/openai-responses.ts": 878, - "open-sse/utils/cursorAgentProtobuf.ts": 1499, + "open-sse/utils/cursorAgentProtobuf.ts": 1521, "open-sse/utils/stream.ts": 2710, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1385, "src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1020, @@ -99,7 +99,7 @@ "src/shared/constants/pricing.ts": 1470, "src/shared/constants/providers.ts": 3146, "src/shared/constants/sidebarVisibility.ts": 990, - "src/shared/services/cliRuntime.ts": 1084, + "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2515, "src/sse/handlers/chat.ts": 1392, "src/sse/services/auth.ts": 2207 diff --git a/src/lib/plugins/marketplace.ts b/src/lib/plugins/marketplace.ts index 0248ae97881..beca4106689 100644 --- a/src/lib/plugins/marketplace.ts +++ b/src/lib/plugins/marketplace.ts @@ -1,6 +1,7 @@ import { getSettings } from "../db/settings"; import dns from "node:dns/promises"; -import net from "node:net"; +import { isPrivateHost } from "@/shared/network/outboundUrlGuard"; +import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; /** * Plugin Marketplace — browse, search, install plugins from a registry. * @@ -10,11 +11,26 @@ import net from "node:net"; * @module plugins/marketplace */ +/** Resolve a hostname to every address it maps to (A + AAAA). Injectable for tests. */ +export type MarketplaceLookupFn = (hostname: string) => Promise>; + +const defaultLookup: MarketplaceLookupFn = (hostname) => + dns.lookup(hostname, { all: true, verbatim: true }); + /** - * Validate a URL for SSRF safety: must be http/https and must not resolve - * to a private or loopback IP address. + * SSRF guard for a custom marketplace registry URL. Must be http(s) and must not + * target a private/loopback/link-local/ULA address. Unlike a literal-only or + * IPv4-only check, this resolves BOTH IPv4 (A) and IPv6 (AAAA) records and rejects + * if ANY resolved address is private — closing the public-hostname → private-IP + * bypass (IPv6 included: `::1`, `fc00::/7`, `fe80::/10`, IPv4-mapped) via the + * canonical `isPrivateHost`. DNS failure rejects (fail-closed). The fetch itself + * additionally runs through `safeOutboundFetch({ guard: "public-only" })`, which + * re-applies the guard and blocks redirects (no public → private 30x pivot). */ -async function isSafeMarketplaceUrl(urlStr: string): Promise { +export async function isSafeMarketplaceUrl( + urlStr: string, + lookupFn: MarketplaceLookupFn = defaultLookup +): Promise { let parsed: URL; try { parsed = new URL(urlStr); @@ -24,32 +40,19 @@ async function isSafeMarketplaceUrl(urlStr: string): Promise { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return false; } - // Resolve hostname to IPs and check none are private/loopback + // Literal IP hostnames (IPv4 + IPv6, incl. IPv4-mapped) are classified directly. + if (isPrivateHost(parsed.hostname)) { + return false; + } + // Resolve A + AAAA and reject if the hostname maps to any private address. try { - const addresses = await dns.resolve4(parsed.hostname); - for (const ip of addresses) { - if (net.isIPv4(ip)) { - const parts = ip.split(".").map(Number); - // 127.0.0.0/8 (loopback) - if (parts[0] === 127) return false; - // 10.0.0.0/8 (private) - if (parts[0] === 10) return false; - // 172.16.0.0/12 (private) - if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false; - // 192.168.0.0/16 (private) - if (parts[0] === 192 && parts[1] === 168) return false; - // 0.0.0.0/8 (current network) - if (parts[0] === 0) return false; - // 100.64.0.0/10 (CGNAT) - if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) return false; - // 169.254.0.0/16 (link-local) - if (parts[0] === 169 && parts[1] === 254) return false; - // 198.18.0.0/15 (benchmarking) - if (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) return false; - } + const records = await lookupFn(parsed.hostname); + if (!records.length) return false; + for (const { address } of records) { + if (isPrivateHost(address)) return false; } } catch { - // DNS resolution failure — reject to be safe + // DNS resolution failure — reject to be safe. return false; } return true; @@ -145,7 +148,7 @@ export async function listMarketplacePlugins(): Promise { console.warn("Custom marketplace URL rejected (SSRF guard):", url); return [...SEED_REGISTRY]; } - const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); + const res = await safeOutboundFetch(url, { guard: "public-only", timeoutMs: 5000 }); if (!res.ok) { console.warn("Custom marketplace returned non-OK status:", res.status); return [...SEED_REGISTRY]; diff --git a/tests/unit/plugins-marketplace.test.ts b/tests/unit/plugins-marketplace.test.ts index 886c9db7f0d..b30ba2571d3 100644 --- a/tests/unit/plugins-marketplace.test.ts +++ b/tests/unit/plugins-marketplace.test.ts @@ -66,4 +66,63 @@ describe("plugin marketplace", () => { assert.equal(entry, undefined); }); }); + + describe("isSafeMarketplaceUrl — SSRF guard", () => { + // A resolver stub so the DNS-resolution branch is deterministic in tests. + const resolveTo = (...ips: string[]) => async () => ips.map((address) => ({ address })); + + it("rejects non-http(s) protocols", async () => { + const { isSafeMarketplaceUrl } = await import("../../src/lib/plugins/marketplace.ts"); + assert.equal(await isSafeMarketplaceUrl("file:///etc/passwd", resolveTo("1.1.1.1")), false); + assert.equal(await isSafeMarketplaceUrl("ftp://example.com", resolveTo("1.1.1.1")), false); + assert.equal(await isSafeMarketplaceUrl("gopher://x", resolveTo("1.1.1.1")), false); + }); + + it("rejects literal private/loopback IPv4 hosts", async () => { + const { isSafeMarketplaceUrl } = await import("../../src/lib/plugins/marketplace.ts"); + for (const h of ["http://127.0.0.1", "http://10.0.0.5", "http://192.168.1.1", "http://169.254.169.254"]) { + assert.equal(await isSafeMarketplaceUrl(h, resolveTo("8.8.8.8")), false, h); + } + }); + + it("rejects literal IPv6 loopback / ULA / link-local hosts (the IPv4-only bypass)", async () => { + const { isSafeMarketplaceUrl } = await import("../../src/lib/plugins/marketplace.ts"); + for (const h of ["http://[::1]", "http://[fc00::1]", "http://[fd12:3456::1]", "http://[fe80::1]"]) { + assert.equal(await isSafeMarketplaceUrl(h, resolveTo("8.8.8.8")), false, h); + } + }); + + it("rejects a public hostname that RESOLVES to a private IPv4 (DNS rebinding / public→private)", async () => { + const { isSafeMarketplaceUrl } = await import("../../src/lib/plugins/marketplace.ts"); + assert.equal(await isSafeMarketplaceUrl("https://evil.example.com", resolveTo("10.1.2.3")), false); + assert.equal(await isSafeMarketplaceUrl("https://evil.example.com", resolveTo("169.254.169.254")), false); + }); + + it("rejects a public hostname that resolves to a private IPv6 (AAAA bypass)", async () => { + const { isSafeMarketplaceUrl } = await import("../../src/lib/plugins/marketplace.ts"); + // Public-looking A but private AAAA — must reject because ALL records are validated. + assert.equal( + await isSafeMarketplaceUrl("https://evil.example.com", resolveTo("8.8.8.8", "::1")), + false + ); + assert.equal(await isSafeMarketplaceUrl("https://evil.example.com", resolveTo("fc00::1")), false); + }); + + it("rejects on DNS failure or empty resolution (fail-closed)", async () => { + const { isSafeMarketplaceUrl } = await import("../../src/lib/plugins/marketplace.ts"); + const throwing = async () => { + throw new Error("ENOTFOUND"); + }; + assert.equal(await isSafeMarketplaceUrl("https://example.com", throwing), false); + assert.equal(await isSafeMarketplaceUrl("https://example.com", resolveTo()), false); + }); + + it("accepts a public hostname resolving only to public addresses", async () => { + const { isSafeMarketplaceUrl } = await import("../../src/lib/plugins/marketplace.ts"); + assert.equal( + await isSafeMarketplaceUrl("https://registry.example.com", resolveTo("93.184.216.34", "2606:2800:220:1::1")), + true + ); + }); + }); });