diff --git a/src/app/api/metrics/repo-analytics/route.ts b/src/app/api/metrics/repo-analytics/route.ts index caf5e0946..d2e7e711e 100644 --- a/src/app/api/metrics/repo-analytics/route.ts +++ b/src/app/api/metrics/repo-analytics/route.ts @@ -37,7 +37,7 @@ export async function GET(req: NextRequest) { const repoUrl = `${GITHUB_API}/repos/${safeRepoPath}`; let urlSafe = false; try { - urlSafe = await isSafeUrl(repoUrl); + urlSafe = (await isSafeUrl(repoUrl)).safe; } catch { urlSafe = false; } diff --git a/src/app/api/webhooks/custom/[id]/deliveries/[deliveryId]/retry/route.ts b/src/app/api/webhooks/custom/[id]/deliveries/[deliveryId]/retry/route.ts index 017e17328..3f80d01d3 100644 --- a/src/app/api/webhooks/custom/[id]/deliveries/[deliveryId]/retry/route.ts +++ b/src/app/api/webhooks/custom/[id]/deliveries/[deliveryId]/retry/route.ts @@ -67,7 +67,7 @@ export async function POST( .eq("id", id) .single(); - if (!webhookUrl || !(await isSafeUrl(webhookUrl.url))) { + if (!webhookUrl || !(await isSafeUrl(webhookUrl.url)).safe) { return Response.json( { error: "Webhook URL is not allowed. Private, loopback, and internal addresses are blocked." }, { status: 400 } diff --git a/src/app/api/webhooks/custom/[id]/route.ts b/src/app/api/webhooks/custom/[id]/route.ts index 83b04d54e..fe9b78b97 100644 --- a/src/app/api/webhooks/custom/[id]/route.ts +++ b/src/app/api/webhooks/custom/[id]/route.ts @@ -110,7 +110,7 @@ export async function PATCH( { status: 400 } ); } - const safe = await isSafeUrl(body.url); + const { safe } = await isSafeUrl(body.url); if (!safe) { return Response.json( { error: "Webhook URL is not allowed. Private, loopback, and internal addresses are blocked." }, diff --git a/src/app/api/webhooks/custom/[id]/test/route.ts b/src/app/api/webhooks/custom/[id]/test/route.ts index ca30086d9..d2cc0b83e 100644 --- a/src/app/api/webhooks/custom/[id]/test/route.ts +++ b/src/app/api/webhooks/custom/[id]/test/route.ts @@ -49,7 +49,7 @@ export async function POST( return Response.json({ error: "Failed to decrypt webhook secret" }, { status: 500 }); } - const safe = await isSafeUrl(webhook.url); + const { safe } = await isSafeUrl(webhook.url); if (!safe) { return Response.json( { error: "Webhook URL is not allowed. Private, loopback, and internal addresses are blocked." }, diff --git a/src/app/api/webhooks/custom/route.ts b/src/app/api/webhooks/custom/route.ts index 92c4ee14a..981284686 100644 --- a/src/app/api/webhooks/custom/route.ts +++ b/src/app/api/webhooks/custom/route.ts @@ -83,7 +83,7 @@ export async function POST(req: NextRequest) { ); } - const safe = await isSafeUrl(url); + const { safe } = await isSafeUrl(url); if (!safe) { return Response.json( { error: "Webhook URL is not allowed. Private, loopback, and internal addresses are blocked." }, diff --git a/src/lib/ssrf-protection.ts b/src/lib/ssrf-protection.ts index 3547600fd..1fa248a7e 100644 --- a/src/lib/ssrf-protection.ts +++ b/src/lib/ssrf-protection.ts @@ -52,11 +52,11 @@ function isPrivateIP(ip: string): boolean { return PRIVATE_RANGES.some(({ start, end }) => num >= start && num <= end); } -export async function isSafeUrl(url: string): Promise { +export async function isSafeUrl(url: string): Promise<{ safe: boolean; ip?: string }> { try { const parsed = new URL(url); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return false; + return { safe: false }; } const hostname = parsed.hostname; @@ -67,11 +67,11 @@ export async function isSafeUrl(url: string): Promise { // Block localhost/unspecified/loopback hostnames before DNS resolution if (hostname === "localhost" || ipToCheck === "0.0.0.0" || ipToCheck === "::1") { - return false; + return { safe: false }; } if (net.isIP(ipToCheck)) { - return !isPrivateIP(ipToCheck); + return { safe: !isPrivateIP(ipToCheck), ip: ipToCheck }; } const addresses: string[] = []; @@ -82,22 +82,22 @@ export async function isSafeUrl(url: string): Promise { addresses.push(...lookupResults.map((r) => r.address)); } } catch { - return false; + return { safe: false }; } if (addresses.length === 0) { - return false; + return { safe: false }; } for (const addr of addresses) { if (isPrivateIP(addr)) { - return false; + return { safe: false }; } } - return true; + return { safe: true, ip: addresses[0] }; } catch { - return false; + return { safe: false }; } } diff --git a/src/lib/webhooks.ts b/src/lib/webhooks.ts index 214d80b2f..3969a439b 100644 --- a/src/lib/webhooks.ts +++ b/src/lib/webhooks.ts @@ -80,8 +80,8 @@ export async function dispatchWebhook( const signature = signPayload(payloadString, secret); const { isSafeUrl } = await import("./ssrf-protection"); - const safe = await isSafeUrl(webhook.url); - if (!safe) { + const { safe, ip } = await isSafeUrl(webhook.url); + if (!safe || !ip) { const errorMessage = "SSRF protection: blocked request to private/internal address"; await supabaseAdmin.from("webhook_deliveries").insert({ webhook_id: webhookId, @@ -97,13 +97,18 @@ export async function dispatchWebhook( let errorMessage: string | undefined; try { - const response = await fetch(webhook.url, { + const originalUrl = new URL(webhook.url); + const fetchUrl = new URL(webhook.url); + fetchUrl.hostname = ip; + + const response = await fetch(fetchUrl.toString(), { method: "POST", headers: { "Content-Type": "application/json", "X-Webhook-Signature": `sha256=${signature}`, "X-Webhook-Event": event, "X-Webhook-Delivery-Id": webhookId, + "Host": originalUrl.host, }, body: payloadString, signal: AbortSignal.timeout(10000), @@ -117,7 +122,7 @@ if ([301, 302, 303, 307, 308].includes(response.status)) { throw new Error("Redirect response missing location header"); } - const redirectSafe = await isSafeUrl(location); + const { safe: redirectSafe } = await isSafeUrl(location); if (!redirectSafe) { throw new Error( diff --git a/test/webhooks.test.ts b/test/webhooks.test.ts index ec8e1053b..98f039dc9 100644 --- a/test/webhooks.test.ts +++ b/test/webhooks.test.ts @@ -27,7 +27,7 @@ describe("Webhooks Module", () => { beforeEach(() => { vi.clearAllMocks(); // Setup default mocks - vi.mocked(ssrfModule.isSafeUrl).mockResolvedValue(true); + vi.mocked(ssrfModule.isSafeUrl).mockResolvedValue({ safe: true, ip: '1.1.1.1' }); vi.mocked(cryptoModule.encryptToken).mockReturnValue({ encrypted: "enc_key", iv: "iv_value" }); vi.mocked(cryptoModule.decryptToken).mockReturnValue("decrypted_secret"); vi.mocked(supabaseAdmin.from).mockReturnValue({ @@ -399,7 +399,7 @@ describe("Webhooks Module", () => { }); it("should block SSRF attacks", async () => { - vi.mocked(ssrfModule.isSafeUrl).mockResolvedValue(false); + vi.mocked(ssrfModule.isSafeUrl).mockResolvedValue({ safe: false }); const result = await dispatchWebhook("webhook_123", "goal.completed", {}); expect(result.success).toBe(false); expect(result.error).toContain("SSRF");