From eda8100becb7880bf7eb81bf3f5c502aa08852f4 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Thu, 5 Feb 2026 21:27:03 +1300 Subject: [PATCH] feat: add SSRF protection, URL encoding, modelId validation, and media size limits - Add validateMediaUrl() utility to block private IPs, localhost, and non-HTTP protocols before server-side fetches (with tests) - URL-encode Kie taskId in poll URL to prevent injection - Validate WaveSpeed modelId against path traversal in both route.ts and wavespeed.ts provider - Add 500MB Content-Length check before downloading media at all 3 fetch sites (Kie, WaveSpeed route, WaveSpeed provider) Co-Authored-By: Claude Opus 4.5 --- src/app/api/generate/route.ts | 36 ++++++++++- src/lib/providers/wavespeed.ts | 21 +++++++ src/utils/__tests__/urlValidation.test.ts | 73 +++++++++++++++++++++++ src/utils/urlValidation.ts | 42 +++++++++++++ 4 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 src/utils/__tests__/urlValidation.test.ts create mode 100644 src/utils/urlValidation.ts diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 2a43e8b4..ebd9c374 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -16,6 +16,7 @@ import { GoogleGenAI } from "@google/genai"; import { GenerateRequest, GenerateResponse, ModelType, SelectedModel, ProviderType } from "@/types"; import { GenerationInput, GenerationOutput, ProviderModel } from "@/lib/providers/types"; import { uploadImageForUrl, shouldUseImageUrl, deleteImages } from "@/lib/images"; +import { validateMediaUrl } from "@/utils/urlValidation"; export const maxDuration = 300; // 5 minute timeout (Vercel hobby plan limit) export const dynamic = 'force-dynamic'; // Ensure this route is always dynamic @@ -1502,7 +1503,7 @@ async function pollKieTaskCompletion( const startTime = Date.now(); let lastStatus = ""; - const pollUrl = `https://api.kie.ai/api/v1/jobs/recordInfo?taskId=${taskId}`; + const pollUrl = `https://api.kie.ai/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`; while (true) { if (Date.now() - startTime > maxWaitTime) { @@ -1796,6 +1797,12 @@ async function generateWithKie( isVideo = true; } + // Validate URL before fetching + const mediaUrlCheck = validateMediaUrl(mediaUrl); + if (!mediaUrlCheck.valid) { + return { success: false, error: `Invalid media URL: ${mediaUrlCheck.error}` }; + } + // Fetch the media and convert to base64 console.log(`[API:${requestId}] Fetching output from: ${mediaUrl.substring(0, 80)}...`); const mediaResponse = await fetch(mediaUrl); @@ -1807,6 +1814,13 @@ async function generateWithKie( }; } + // Check file size before downloading body + const MAX_MEDIA_SIZE = 500 * 1024 * 1024; // 500MB + const mediaContentLength = parseInt(mediaResponse.headers.get("content-length") || "0", 10); + if (mediaContentLength > MAX_MEDIA_SIZE) { + return { success: false, error: `Media too large: ${(mediaContentLength / (1024 * 1024)).toFixed(0)}MB > 500MB limit` }; + } + const contentType = mediaResponse.headers.get("content-type") || (isVideo ? "video/mp4" : "image/png"); if (contentType.startsWith("video/")) { isVideo = true; @@ -1922,6 +1936,12 @@ async function generateWithWaveSpeed( const WAVESPEED_API_BASE = "https://api.wavespeed.ai/api/v3"; const modelId = input.model.id; + + // Validate modelId to prevent path traversal + if (/[^a-zA-Z0-9\-_/.]/.test(modelId) || modelId.includes('..')) { + return { success: false, error: `Invalid model ID: ${modelId}` }; + } + const hasDynamicInputs = input.dynamicInputs && Object.keys(input.dynamicInputs).length > 0; console.log(`[API:${requestId}] Dynamic inputs: ${hasDynamicInputs ? Object.keys(input.dynamicInputs!).join(", ") : "none"}`); @@ -2144,6 +2164,13 @@ async function generateWithWaveSpeed( // Fetch the first output and convert to base64 const outputUrl = outputUrls[0]; + + // Validate URL before fetching + const outputUrlCheck = validateMediaUrl(outputUrl); + if (!outputUrlCheck.valid) { + return { success: false, error: `Invalid output URL: ${outputUrlCheck.error}` }; + } + console.log(`[API:${requestId}] Fetching WaveSpeed output from: ${outputUrl.substring(0, 80)}...`); const outputResponse = await fetch(outputUrl); @@ -2155,6 +2182,13 @@ async function generateWithWaveSpeed( }; } + // Check file size before downloading body + const MAX_MEDIA_SIZE_WS = 500 * 1024 * 1024; // 500MB + const wsContentLength = parseInt(outputResponse.headers.get("content-length") || "0", 10); + if (wsContentLength > MAX_MEDIA_SIZE_WS) { + return { success: false, error: `Media too large: ${(wsContentLength / (1024 * 1024)).toFixed(0)}MB > 500MB limit` }; + } + const outputArrayBuffer = await outputResponse.arrayBuffer(); const outputSizeMB = outputArrayBuffer.byteLength / (1024 * 1024); diff --git a/src/lib/providers/wavespeed.ts b/src/lib/providers/wavespeed.ts index 0ebccfc2..c330be45 100644 --- a/src/lib/providers/wavespeed.ts +++ b/src/lib/providers/wavespeed.ts @@ -24,6 +24,7 @@ import { GenerationOutput, registerProvider, } from "@/lib/providers"; +import { validateMediaUrl } from "@/utils/urlValidation"; const WAVESPEED_API_BASE = "https://api.wavespeed.ai/api/v3"; const PROVIDER_SETTINGS_KEY = "node-banana-provider-settings"; @@ -211,6 +212,12 @@ const wavespeedProvider: ProviderInterface = { try { const modelId = input.model.id; + + // Validate modelId to prevent path traversal + if (/[^a-zA-Z0-9\-_/.]/.test(modelId) || modelId.includes('..')) { + return { success: false, error: `Invalid model ID: ${modelId}` }; + } + const outputType = inferOutputType(input.model.capabilities); // Build WaveSpeed payload @@ -346,6 +353,13 @@ const wavespeedProvider: ProviderInterface = { // Fetch the first output and convert to base64 const outputUrl = outputUrls[0]; + + // Validate URL before fetching + const urlCheck = validateMediaUrl(outputUrl); + if (!urlCheck.valid) { + return { success: false, error: `Invalid output URL: ${urlCheck.error}` }; + } + const outputResponse = await fetch(outputUrl); if (!outputResponse.ok) { @@ -355,6 +369,13 @@ const wavespeedProvider: ProviderInterface = { }; } + // Check file size before downloading body + const MAX_MEDIA_SIZE = 500 * 1024 * 1024; // 500MB + const contentLength = parseInt(outputResponse.headers.get("content-length") || "0", 10); + if (contentLength > MAX_MEDIA_SIZE) { + return { success: false, error: `Media too large: ${(contentLength / (1024 * 1024)).toFixed(0)}MB > 500MB limit` }; + } + const outputArrayBuffer = await outputResponse.arrayBuffer(); const outputBase64 = Buffer.from(outputArrayBuffer).toString("base64"); diff --git a/src/utils/__tests__/urlValidation.test.ts b/src/utils/__tests__/urlValidation.test.ts new file mode 100644 index 00000000..1843ef5f --- /dev/null +++ b/src/utils/__tests__/urlValidation.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { validateMediaUrl } from "../urlValidation"; + +describe("validateMediaUrl", () => { + it("allows valid HTTPS URLs", () => { + expect(validateMediaUrl("https://example.com/image.png")).toEqual({ valid: true }); + expect(validateMediaUrl("https://cdn.kie.ai/output/abc.mp4")).toEqual({ valid: true }); + }); + + it("allows valid HTTP URLs", () => { + expect(validateMediaUrl("http://example.com/image.png")).toEqual({ valid: true }); + }); + + it("blocks non-http protocols", () => { + expect(validateMediaUrl("file:///etc/passwd")).toEqual({ valid: false, error: "Blocked protocol: file:" }); + expect(validateMediaUrl("ftp://example.com/file")).toEqual({ valid: false, error: "Blocked protocol: ftp:" }); + expect(validateMediaUrl("javascript:alert(1)")).toEqual({ valid: false, error: "Blocked protocol: javascript:" }); + }); + + it("blocks localhost", () => { + const result = validateMediaUrl("http://localhost:3000/api/secret"); + expect(result.valid).toBe(false); + expect(result.error).toContain("Blocked host"); + }); + + it("blocks IPv6 loopback", () => { + const result = validateMediaUrl("http://[::1]:8080/"); + expect(result.valid).toBe(false); + expect(result.error).toContain("Blocked host"); + }); + + it("blocks 127.x.x.x loopback", () => { + const result = validateMediaUrl("http://127.0.0.1/api"); + expect(result.valid).toBe(false); + expect(result.error).toContain("Blocked private IP"); + }); + + it("blocks 10.x private IPs", () => { + const result = validateMediaUrl("http://10.0.0.1/internal"); + expect(result.valid).toBe(false); + expect(result.error).toContain("Blocked private IP"); + }); + + it("blocks 172.16-31.x private IPs", () => { + expect(validateMediaUrl("http://172.16.0.1/")).toEqual({ valid: false, error: "Blocked private IP: 172.16.0.1" }); + expect(validateMediaUrl("http://172.31.255.1/")).toEqual({ valid: false, error: "Blocked private IP: 172.31.255.1" }); + // 172.32.x should be allowed + expect(validateMediaUrl("http://172.32.0.1/")).toEqual({ valid: true }); + }); + + it("blocks 192.168.x private IPs", () => { + const result = validateMediaUrl("http://192.168.1.1/admin"); + expect(result.valid).toBe(false); + expect(result.error).toContain("Blocked private IP"); + }); + + it("blocks 169.254.x link-local", () => { + const result = validateMediaUrl("http://169.254.169.254/latest/meta-data/"); + expect(result.valid).toBe(false); + expect(result.error).toContain("Blocked private IP"); + }); + + it("blocks 0.0.0.0", () => { + const result = validateMediaUrl("http://0.0.0.0/"); + expect(result.valid).toBe(false); + expect(result.error).toContain("Blocked private IP"); + }); + + it("rejects invalid URLs", () => { + expect(validateMediaUrl("not-a-url")).toEqual({ valid: false, error: "Invalid URL" }); + expect(validateMediaUrl("")).toEqual({ valid: false, error: "Invalid URL" }); + }); +}); diff --git a/src/utils/urlValidation.ts b/src/utils/urlValidation.ts new file mode 100644 index 00000000..762c4ea4 --- /dev/null +++ b/src/utils/urlValidation.ts @@ -0,0 +1,42 @@ +/** + * URL validation utility for SSRF prevention. + * Validates URLs before server-side fetches to block requests to internal networks. + */ + +const PRIVATE_IP_PATTERNS = [ + /^127\./, // loopback + /^10\./, // 10.0.0.0/8 + /^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 + /^192\.168\./, // 192.168.0.0/16 + /^169\.254\./, // link-local + /^0\./, // 0.0.0.0/8 +]; + +const BLOCKED_HOSTNAMES = ["localhost", "[::1]"]; + +export function validateMediaUrl(url: string): { valid: boolean; error?: string } { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { valid: false, error: "Invalid URL" }; + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { valid: false, error: `Blocked protocol: ${parsed.protocol}` }; + } + + const hostname = parsed.hostname; + + if (BLOCKED_HOSTNAMES.includes(hostname)) { + return { valid: false, error: `Blocked host: ${hostname}` }; + } + + for (const pattern of PRIVATE_IP_PATTERNS) { + if (pattern.test(hostname)) { + return { valid: false, error: `Blocked private IP: ${hostname}` }; + } + } + + return { valid: true }; +}