diff --git a/src/resources/extensions/search-the-web/tool-fetch-page.ts b/src/resources/extensions/search-the-web/tool-fetch-page.ts index 6e42d5415..0934af736 100644 --- a/src/resources/extensions/search-the-web/tool-fetch-page.ts +++ b/src/resources/extensions/search-the-web/tool-fetch-page.ts @@ -15,7 +15,7 @@ import { Type } from "@sinclair/typebox"; import { LRUTTLCache } from "./cache.js"; import { fetchSimple, HttpError } from "./http.js"; -import { extractDomain } from "./url-utils.js"; +import { extractDomain, isBlockedUrl } from "./url-utils.js"; import { formatPageContent, type FormatPageOptions } from "./format.js"; import { getOllamaApiKey } from "./provider.js"; @@ -416,6 +416,14 @@ export function registerFetchPageTool(pi: ExtensionAPI) { }; } + if (isBlockedUrl(url)) { + return { + content: [{ type: "text", text: `Blocked URL: requests to private/internal addresses are not allowed.` }], + isError: true, + details: { error: "SSRF blocked", url } satisfies Partial, + }; + } + // ------------------------------------------------------------------ // Cache lookup (full content cached, offset/truncation applied after) // ------------------------------------------------------------------ diff --git a/src/resources/extensions/search-the-web/url-utils.ts b/src/resources/extensions/search-the-web/url-utils.ts index eda74be4f..24b3caedd 100644 --- a/src/resources/extensions/search-the-web/url-utils.ts +++ b/src/resources/extensions/search-the-web/url-utils.ts @@ -1,7 +1,41 @@ /** - * URL normalization and query utilities. + * URL normalization, query utilities, and SSRF protection. */ +const BLOCKED_HOSTNAMES = new Set([ + "localhost", + "metadata.google.internal", + "instance-data", +]); + +const PRIVATE_IP_PATTERNS = [ + /^127\./, + /^10\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^192\.168\./, + /^169\.254\./, + /^0\./, + /^::1$/, + /^fc00:/i, + /^fd/i, + /^fe80:/i, +]; + +export function isBlockedUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return true; + const hostname = parsed.hostname.toLowerCase(); + if (BLOCKED_HOSTNAMES.has(hostname)) return true; + for (const pattern of PRIVATE_IP_PATTERNS) { + if (pattern.test(hostname)) return true; + } + return false; + } catch { + return true; + } +} + /** Normalize a search query into a stable cache key. */ export function normalizeQuery(query: string): string { return query.trim().toLowerCase().replace(/\s+/g, " ").normalize("NFC"); diff --git a/src/tests/url-utils.test.ts b/src/tests/url-utils.test.ts new file mode 100644 index 000000000..c73b359a7 --- /dev/null +++ b/src/tests/url-utils.test.ts @@ -0,0 +1,59 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { isBlockedUrl } from "../resources/extensions/search-the-web/url-utils.ts"; + +describe("isBlockedUrl — SSRF protection", () => { + it("blocks localhost", () => { + assert.equal(isBlockedUrl("http://localhost/admin"), true); + assert.equal(isBlockedUrl("http://localhost:8080/"), true); + }); + + it("blocks 127.0.0.0/8", () => { + assert.equal(isBlockedUrl("http://127.0.0.1/"), true); + assert.equal(isBlockedUrl("http://127.0.0.2:3000/path"), true); + }); + + it("blocks 10.0.0.0/8 (private)", () => { + assert.equal(isBlockedUrl("http://10.0.0.1/"), true); + assert.equal(isBlockedUrl("http://10.255.255.255/"), true); + }); + + it("blocks 172.16-31.x.x (private)", () => { + assert.equal(isBlockedUrl("http://172.16.0.1/"), true); + assert.equal(isBlockedUrl("http://172.31.255.255/"), true); + }); + + it("blocks 192.168.x.x (private)", () => { + assert.equal(isBlockedUrl("http://192.168.1.1/"), true); + assert.equal(isBlockedUrl("http://192.168.0.100:9200/"), true); + }); + + it("blocks 169.254.x.x (link-local / cloud metadata)", () => { + assert.equal(isBlockedUrl("http://169.254.169.254/latest/meta-data/"), true); + }); + + it("blocks cloud metadata hostnames", () => { + assert.equal(isBlockedUrl("http://metadata.google.internal/computeMetadata/"), true); + }); + + it("blocks non-http protocols", () => { + assert.equal(isBlockedUrl("file:///etc/passwd"), true); + assert.equal(isBlockedUrl("ftp://internal.server/data"), true); + }); + + it("blocks invalid URLs", () => { + assert.equal(isBlockedUrl("not-a-url"), true); + assert.equal(isBlockedUrl(""), true); + }); + + it("allows public URLs", () => { + assert.equal(isBlockedUrl("https://example.com"), false); + assert.equal(isBlockedUrl("https://api.github.com/repos"), false); + assert.equal(isBlockedUrl("http://docs.python.org/3/"), false); + }); + + it("allows public IPs", () => { + assert.equal(isBlockedUrl("http://8.8.8.8/"), false); + assert.equal(isBlockedUrl("https://1.1.1.1/"), false); + }); +});