fix(#279): Validate orchestrator URL configuration (SSRF risk)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

Implemented comprehensive URL validation to prevent SSRF attacks:
- Created URL validator utility with protocol whitelist (http/https only)
- Blocked access to private IP ranges (10.x, 192.168.x, 172.16-31.x)
- Blocked loopback addresses (127.x, localhost, 0.0.0.0)
- Blocked link-local addresses (169.254.x)
- Blocked IPv6 localhost (::1, ::)
- Allow localhost in development/test environments only
- Added structured audit logging for invalid URL attempts
- Comprehensive test coverage (37 tests for URL validator)

Security Impact:
- Prevents attackers from redirecting agent spawn requests to internal services
- Blocks data exfiltration via malicious orchestrator URL
- All agent operations now validated against SSRF

Files changed:
- apps/api/src/federation/utils/url-validator.ts (new)
- apps/api/src/federation/utils/url-validator.spec.ts (new)
- apps/api/src/federation/federation-agent.service.ts (validation integration)
- apps/api/src/federation/federation-agent.service.spec.ts (test updates)
- apps/api/src/federation/audit.service.ts (audit logging)
- apps/api/src/federation/federation.module.ts (service exports)

Fixes #279

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 20:47:41 -06:00
parent 09bb6df0b6
commit 0a527d2a4e
7 changed files with 489 additions and 4 deletions

View File

@@ -0,0 +1,238 @@
/**
* Tests for URL Validator Utility
*/
import { describe, it, expect } from "vitest";
import { validateUrl } from "./url-validator";
describe("validateUrl", () => {
describe("valid URLs", () => {
it("should accept valid HTTP URL", () => {
const result = validateUrl("http://orchestrator.example.com:3001");
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should accept valid HTTPS URL", () => {
const result = validateUrl("https://orchestrator.example.com:3001");
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should accept URL without port", () => {
const result = validateUrl("https://orchestrator.example.com");
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should accept URL with path", () => {
const result = validateUrl("https://orchestrator.example.com:3001/api/v1");
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should accept localhost when allowLocalhost=true", () => {
const result = validateUrl("http://localhost:3001", true);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should accept 127.0.0.1 when allowLocalhost=true", () => {
const result = validateUrl("http://127.0.0.1:3001", true);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should accept public IP address", () => {
const result = validateUrl("http://8.8.8.8:3001");
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
});
describe("invalid protocols", () => {
it("should reject FTP protocol", () => {
const result = validateUrl("ftp://orchestrator.example.com:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("Invalid orchestrator URL protocol");
});
it("should reject file protocol", () => {
const result = validateUrl("file:///etc/passwd");
expect(result.valid).toBe(false);
expect(result.error).toContain("Invalid orchestrator URL protocol");
});
it("should reject javascript protocol", () => {
const result = validateUrl("javascript:alert(1)");
expect(result.valid).toBe(false);
expect(result.error).toContain("Invalid orchestrator URL protocol");
});
it("should reject data protocol", () => {
const result = validateUrl("data:text/html,<h1>test</h1>");
expect(result.valid).toBe(false);
expect(result.error).toContain("Invalid orchestrator URL protocol");
});
it("should reject gopher protocol", () => {
const result = validateUrl("gopher://example.com");
expect(result.valid).toBe(false);
expect(result.error).toContain("Invalid orchestrator URL protocol");
});
});
describe("malformed URLs", () => {
it("should reject empty URL", () => {
const result = validateUrl("");
expect(result.valid).toBe(false);
expect(result.error).toContain("Orchestrator URL is required");
});
it("should reject whitespace-only URL", () => {
const result = validateUrl(" ");
expect(result.valid).toBe(false);
expect(result.error).toContain("Orchestrator URL is required");
});
it("should reject URL without protocol", () => {
const result = validateUrl("orchestrator.example.com:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("Invalid orchestrator URL");
});
it("should reject invalid URL format", () => {
const result = validateUrl("not a url at all");
expect(result.valid).toBe(false);
expect(result.error).toContain("Invalid orchestrator URL");
});
});
describe("private IP addresses", () => {
it("should reject localhost", () => {
const result = validateUrl("http://localhost:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should reject 127.0.0.1", () => {
const result = validateUrl("http://127.0.0.1:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should reject 127.x.x.x range", () => {
const result = validateUrl("http://127.1.1.1:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should reject 0.0.0.0", () => {
const result = validateUrl("http://0.0.0.0:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should reject 10.x.x.x range", () => {
const result = validateUrl("http://10.0.0.1:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should reject 10.255.255.255", () => {
const result = validateUrl("http://10.255.255.255:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should reject 192.168.x.x range", () => {
const result = validateUrl("http://192.168.1.1:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should reject 192.168.0.1", () => {
const result = validateUrl("http://192.168.0.1:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should reject 172.16.x.x range", () => {
const result = validateUrl("http://172.16.0.1:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should reject 172.31.x.x (end of range)", () => {
const result = validateUrl("http://172.31.255.255:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should accept 172.15.x.x (before private range)", () => {
const result = validateUrl("http://172.15.0.1:3001");
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should accept 172.32.x.x (after private range)", () => {
const result = validateUrl("http://172.32.0.1:3001");
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should reject 169.254.x.x (link-local)", () => {
const result = validateUrl("http://169.254.1.1:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should reject ::1 (IPv6 localhost)", () => {
const result = validateUrl("http://[::1]:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should reject :: (IPv6 any)", () => {
const result = validateUrl("http://[::]:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should reject .localhost domain", () => {
const result = validateUrl("http://app.localhost:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should reject .local domain", () => {
const result = validateUrl("http://orchestrator.local:3001");
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
});
describe("allowLocalhost parameter", () => {
it("should allow localhost when allowLocalhost=true", () => {
const result = validateUrl("http://localhost:3001", true);
expect(result.valid).toBe(true);
});
it("should allow 127.0.0.1 when allowLocalhost=true", () => {
const result = validateUrl("http://127.0.0.1:3001", true);
expect(result.valid).toBe(true);
});
it("should still reject 10.x.x.x even with allowLocalhost=true", () => {
const result = validateUrl("http://10.0.0.1:3001", true);
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
it("should still reject 192.168.x.x even with allowLocalhost=true", () => {
const result = validateUrl("http://192.168.1.1:3001", true);
expect(result.valid).toBe(false);
expect(result.error).toContain("private/internal addresses");
});
});
});

View File

@@ -0,0 +1,149 @@
/**
* URL Validator Utility
*
* Validates URLs to prevent SSRF attacks by ensuring:
* - Valid URL format
* - Whitelisted protocols (http/https)
* - No access to private/internal IP addresses
*/
/**
* Validation result
*/
export interface UrlValidationResult {
valid: boolean;
error?: string;
}
/**
* Validate a URL for security concerns
* @param url URL to validate
* @param allowLocalhost Whether to allow localhost (for development)
* @returns Validation result
*/
export function validateUrl(url: string, allowLocalhost = false): UrlValidationResult {
// Check if URL is provided
if (!url || url.trim() === "") {
return {
valid: false,
error: "Orchestrator URL is required",
};
}
// Parse URL
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
return {
valid: false,
error: "Invalid orchestrator URL format",
};
}
// Validate protocol (only http and https allowed)
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return {
valid: false,
error: "Invalid orchestrator URL protocol (only http and https allowed)",
};
}
// Validate hostname (prevent SSRF to private addresses)
const hostname = parsedUrl.hostname.toLowerCase();
// Check for private/internal addresses
if (isPrivateOrInternalAddress(hostname, allowLocalhost)) {
return {
valid: false,
error: "Orchestrator URL cannot point to private/internal addresses",
};
}
return { valid: true };
}
/**
* Check if hostname is a private or internal address
* @param hostname Hostname to check
* @param allowLocalhost Whether localhost/loopback is allowed
* @returns True if private/internal
*/
function isPrivateOrInternalAddress(hostname: string, allowLocalhost = false): boolean {
const isLocalhost =
hostname === "localhost" ||
hostname === "0.0.0.0" ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local");
// Check if it's an IP address
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const ipv4Match = ipv4Regex.exec(hostname);
if (ipv4Match) {
const [, octet1, octet2, octet3, octet4] = ipv4Match;
const o1 = parseInt(octet1 ?? "0", 10);
const o2 = parseInt(octet2 ?? "0", 10);
const o3 = parseInt(octet3 ?? "0", 10);
const o4 = parseInt(octet4 ?? "0", 10);
// Validate octets are in range
if (o1 > 255 || o2 > 255 || o3 > 255 || o4 > 255) {
return true; // Invalid IP, treat as suspicious
}
// Check for loopback (127.0.0.0/8) and 0.0.0.0
const isLoopback = o1 === 127 || (o1 === 0 && o2 === 0 && o3 === 0 && o4 === 0);
if (isLoopback && allowLocalhost) {
return false; // Allow loopback in development
}
if (isLoopback) {
return true;
}
// Check for private IP ranges (always blocked)
// 10.0.0.0/8
if (o1 === 10) {
return true;
}
// 172.16.0.0/12
if (o1 === 172 && o2 >= 16 && o2 <= 31) {
return true;
}
// 192.168.0.0/16
if (o1 === 192 && o2 === 168) {
return true;
}
// 169.254.0.0/16 (link-local)
if (o1 === 169 && o2 === 254) {
return true;
}
}
// Check for IPv6 localhost (URL class removes brackets)
const isIpv6Localhost =
hostname === "::1" || hostname === "::" || hostname === "[::1]" || hostname === "[::]";
if (isIpv6Localhost && allowLocalhost) {
return false;
}
if (isIpv6Localhost) {
return true;
}
// Check for localhost domain names
if (isLocalhost && allowLocalhost) {
return false;
}
if (isLocalhost) {
return true;
}
return false;
}