import { expect } from "@std/expect";
import { describe, it } from "@std/testing/bdd";
import { assertSafeUrl, assertSafeUrlSync, 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, with DNS resolution) ---
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);
});
});