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:
parent
d41338cafb
commit
ebbcbe363a
3 changed files with 103 additions and 2 deletions
|
|
@ -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)
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
59
src/tests/url-utils.test.ts
Normal file
59
src/tests/url-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue