import { expect } from "@std/expect";
import { describe, it } from "@std/testing/bdd";
import {
assertSafeUrl,
assertSafeUrlSync,
safeFetch,
safeUrlSchema,
} from "./ssrf";
// --- assertSafeUrlSync (no DNS, used in Zod schemas) ---
describe("assertSafeUrlSync", () => {
describe("allows valid public URLs", () => {
const allowed = [
"https://hooks.slack.com/services/T00/B00/xxx",
"https://discord.com/api/webhooks/123/abc",
"https://chat.googleapis.com/v1/spaces/xxx",
"https://ntfy.sh/my-topic",
"https://example.com/webhook",
"http://example.com/webhook",
"https://172.32.0.1/webhook", // 172.32 is NOT private (only 172.16-31)
"https://172.15.255.255/webhook", // 172.15 is NOT private
];
for (const url of allowed) {
it(`allows ${url}`, () => {
expect(() => assertSafeUrlSync(url)).not.toThrow();
});
}
});
describe("blocks non-HTTP protocols", () => {
const blocked = [
"ftp://example.com/file",
"file:///etc/passwd",
"gopher://evil.com/",
"javascript:alert(1)",
"data:text/html,
hi
",
];
for (const url of blocked) {
it(`blocks ${url}`, () => {
expect(() => assertSafeUrlSync(url)).toThrow("not allowed");
});
}
});
describe("blocks private IPv4 addresses", () => {
const blocked = [
"http://127.0.0.1/",
"http://127.0.0.1:8080/test",
"http://10.0.0.1/",
"http://10.255.255.255/",
"http://172.16.0.1/",
"http://172.31.255.255/",
"http://192.168.0.1/",
"http://192.168.1.100:3000/",
"http://169.254.169.254/latest/meta-data/", // AWS metadata
"http://0.0.0.0/",
];
for (const url of blocked) {
it(`blocks ${url}`, () => {
expect(() => assertSafeUrlSync(url)).toThrow("not allowed");
});
}
});
describe("blocks private hostnames", () => {
const blocked = [
"http://localhost/",
"http://localhost:8080/test",
"http://LOCALHOST/", // case insensitive
"http://metadata.google.internal/computeMetadata/v1/",
"http://metadata.internal/",
];
for (const url of blocked) {
it(`blocks ${url}`, () => {
expect(() => assertSafeUrlSync(url)).toThrow("not allowed");
});
}
});
describe("blocks IPv6 private addresses", () => {
const blocked = [
"http://[::1]/", // loopback
"http://[::1]:8080/",
"http://[fc00::1]/", // unique local
"http://[fd12:3456::1]/", // unique local
"http://[fe80::1]/", // link-local
];
for (const url of blocked) {
it(`blocks ${url}`, () => {
expect(() => assertSafeUrlSync(url)).toThrow("not allowed");
});
}
});
describe("blocks IPv4-mapped IPv6 addresses", () => {
it("blocks ::ffff:127.0.0.1 (dotted-quad form)", () => {
expect(() => assertSafeUrlSync("http://[::ffff:127.0.0.1]/")).toThrow(
"not allowed",
);
});
it("blocks ::ffff:7f00:1 (hex form, Node URL parser output)", () => {
// Node converts ::ffff:127.0.0.1 to ::ffff:7f00:1
expect(() => assertSafeUrlSync("http://[::ffff:7f00:1]/")).toThrow(
"not allowed",
);
});
it("blocks ::ffff:a9fe:a9fe (169.254.169.254 AWS metadata in hex)", () => {
expect(() => assertSafeUrlSync("http://[::ffff:a9fe:a9fe]/")).toThrow(
"not allowed",
);
});
it("blocks ::ffff:a00:1 (10.0.0.1 in hex)", () => {
expect(() => assertSafeUrlSync("http://[::ffff:a00:1]/")).toThrow(
"not allowed",
);
});
it("blocks ::ffff:c0a8:1 (192.168.0.1 in hex)", () => {
expect(() => assertSafeUrlSync("http://[::ffff:c0a8:1]/")).toThrow(
"not allowed",
);
});
});
describe("rejects invalid URLs", () => {
it("throws on empty string", () => {
expect(() => assertSafeUrlSync("")).toThrow();
});
it("throws on garbage", () => {
expect(() => assertSafeUrlSync("not-a-url")).toThrow();
});
});
});
// --- assertSafeUrl (async, same string-only checks) ---
describe("assertSafeUrl", () => {
it("allows a valid public URL", async () => {
await expect(
assertSafeUrl("https://example.com/webhook"),
).resolves.toBeUndefined();
});
it("blocks localhost", async () => {
await expect(assertSafeUrl("http://localhost/")).rejects.toThrow(
"not allowed",
);
});
it("blocks private IPs", async () => {
await expect(assertSafeUrl("http://192.168.1.1/")).rejects.toThrow(
"not allowed",
);
});
it("blocks non-HTTP protocols", async () => {
await expect(assertSafeUrl("ftp://example.com/")).rejects.toThrow(
"not allowed",
);
});
it("blocks AWS metadata endpoint", async () => {
await expect(
assertSafeUrl("http://169.254.169.254/latest/meta-data/"),
).rejects.toThrow("not allowed");
});
});
// --- safeUrlSchema (Zod schema) ---
describe("safeUrlSchema", () => {
it("accepts valid public URLs", () => {
const result = safeUrlSchema.safeParse(
"https://hooks.slack.com/services/T00/B00/xxx",
);
expect(result.success).toBe(true);
});
it("rejects non-URL strings", () => {
const result = safeUrlSchema.safeParse("not-a-url");
expect(result.success).toBe(false);
});
it("rejects private IPs", () => {
const result = safeUrlSchema.safeParse("http://127.0.0.1/");
expect(result.success).toBe(false);
});
it("rejects localhost", () => {
const result = safeUrlSchema.safeParse("http://localhost:8080/");
expect(result.success).toBe(false);
});
it("rejects metadata endpoint", () => {
const result = safeUrlSchema.safeParse(
"http://169.254.169.254/latest/meta-data/",
);
expect(result.success).toBe(false);
});
it("rejects ftp protocol", () => {
const result = safeUrlSchema.safeParse("ftp://example.com/file");
expect(result.success).toBe(false);
});
it("rejects IPv4-mapped IPv6 in hex form", () => {
const result = safeUrlSchema.safeParse("http://[::ffff:7f00:1]/");
expect(result.success).toBe(false);
});
});
describe("safeFetch", () => {
it("rejects a private target without fetching", async () => {
const original = globalThis.fetch;
let called = false;
globalThis.fetch = () => {
called = true;
return Promise.resolve(new Response());
};
try {
await expect(
safeFetch("http://169.254.169.254/latest/meta-data"),
).rejects.toThrow();
expect(called).toBe(false);
} finally {
globalThis.fetch = original;
}
});
it("does not follow a 302", async () => {
const original = globalThis.fetch;
let seen: RequestInit | undefined;
globalThis.fetch = (_input, init) => {
seen = init;
return Promise.resolve(
new Response(null, {
status: 302,
headers: { location: "http://169.254.169.254/" },
}),
);
};
try {
const res = await safeFetch("https://example.com/hook", {
method: "POST",
});
expect(seen?.redirect).toBe("manual");
expect(res.ok).toBe(false);
} finally {
globalThis.fetch = original;
}
});
function redirecting(hops: Record) {
const seen: string[] = [];
const original = globalThis.fetch;
globalThis.fetch = (input) => {
const url = String(input);
seen.push(url);
const hop = hops[url];
return Promise.resolve(
hop
? new Response(null, {
status: hop[0],
headers: { location: hop[1] },
})
: new Response("ok"),
);
};
return { seen, restore: () => (globalThis.fetch = original) };
}
it("follows a same-host 307/308, e.g. an http to https upgrade", async () => {
const { seen, restore } = redirecting({
"http://example.com/hook": [308, "https://example.com/hook"],
"https://example.com/hook": [307, "/v2/hook"],
});
try {
const res = await safeFetch("http://example.com/hook", {
method: "POST",
});
expect(res.ok).toBe(true);
expect(seen).toEqual([
"http://example.com/hook",
"https://example.com/hook",
"https://example.com/v2/hook",
]);
} finally {
restore();
}
});
it("does not follow a 307 that would replay headers elsewhere", async () => {
for (const location of [
"https://evil.example/hook",
"http://169.254.169.254/latest/meta-data",
// downgrade to plaintext
"http://example.com/hook",
// same host, different service
"https://example.com:8443/hook",
]) {
const { seen, restore } = redirecting({
"https://example.com/hook": [307, location],
});
try {
const res = await safeFetch("https://example.com/hook");
expect(res.ok).toBe(false);
expect(seen).toEqual(["https://example.com/hook"]);
} finally {
restore();
}
}
});
it("stops after a bounded number of hops", async () => {
const { seen, restore } = redirecting({
"https://example.com/a": [307, "/b"],
"https://example.com/b": [307, "/a"],
});
try {
const res = await safeFetch("https://example.com/a");
expect(res.ok).toBe(false);
expect(seen.length).toBe(4);
} finally {
restore();
}
});
});