Browse Source
- 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
4 changed files with 171 additions and 1 deletions
@ -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" }); |
|||
}); |
|||
}); |
|||
@ -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…
Reference in new issue