security: add SSRF protection to fetch_page tool

Block requests to private/internal addresses in the fetch_page tool:
- Private IP ranges (10.x, 172.16-31.x, 192.168.x, 127.x, 169.254.x)
- Cloud metadata endpoints (metadata.google.internal, instance-data)
- localhost
- Non-HTTP protocols (file://, ftp://)
- IPv6 private ranges (::1, fc00:, fd, fe80:)

Add isBlockedUrl() to url-utils.ts with 11 new tests covering all
blocked and allowed URL patterns.
This commit is contained in:
Jeremy McSpadden 2026-03-16 13:35:48 -05:00
parent d41338cafb
commit ebbcbe363a
3 changed files with 103 additions and 2 deletions

View file

@ -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<FetchPageDetails>,
};
}
// ------------------------------------------------------------------
// Cache lookup (full content cached, offset/truncation applied after)
// ------------------------------------------------------------------

View file

@ -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");

View file

@ -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);
});
});