Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
59 changes: 31 additions & 28 deletions src/lib/plugins/marketplace.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -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<Array<{ address: string }>>;

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<boolean> {
export async function isSafeMarketplaceUrl(
urlStr: string,
lookupFn: MarketplaceLookupFn = defaultLookup
): Promise<boolean> {
let parsed: URL;
try {
parsed = new URL(urlStr);
Expand All @@ -24,32 +40,19 @@ async function isSafeMarketplaceUrl(urlStr: string): Promise<boolean> {
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;
}
Comment on lines +43 to 53

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When parsed.hostname is a bracketed IPv6 literal (e.g., [2606:2800:220:1::1]), passing it directly to dns.lookup will cause it to throw an error (such as ENOTFOUND or EINVAL) because dns.lookup expects a raw IP address or hostname without brackets. This error is caught by the try-catch block, causing legitimate public IPv6 literal URLs to be incorrectly rejected as unsafe.

To fix this, we should strip the brackets from parsed.hostname before checking isPrivateHost and passing it to lookupFn.

  // Literal IP hostnames (IPv4 + IPv6, incl. IPv4-mapped) are classified directly.
  const hostname = parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]")
    ? parsed.hostname.slice(1, -1)
    : parsed.hostname;
  if (isPrivateHost(hostname)) {
    return false;
  }
  // Resolve A + AAAA and reject if the hostname maps to any private address.
  try {
    const records = await lookupFn(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;
Expand Down Expand Up @@ -145,7 +148,7 @@ export async function listMarketplacePlugins(): Promise<MarketplaceEntry[]> {
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];
Expand Down
59 changes: 59 additions & 0 deletions tests/unit/plugins-marketplace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
});
});
});