Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
eda8100bec
  1. 36
      src/app/api/generate/route.ts
  2. 21
      src/lib/providers/wavespeed.ts
  3. 73
      src/utils/__tests__/urlValidation.test.ts
  4. 42
      src/utils/urlValidation.ts

36
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);

21
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");

73
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" });
});
});

42
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 };
}
Loading…
Cancel
Save