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

@@ -207,4 +207,18 @@ export class FederationAuditService {
securityEvent: true,
});
}
/**
* Log invalid orchestrator URL configuration attempt
* Logged when orchestrator URL validation fails (SSRF prevention)
*/
logInvalidOrchestratorUrl(url: string, error: string): void {
this.logger.warn({
event: "FEDERATION_INVALID_ORCHESTRATOR_URL",
url,
error,
timestamp: new Date().toISOString(),
securityEvent: true,
});
}
}

View File

@@ -8,6 +8,7 @@ import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { FederationAgentService } from "./federation-agent.service";
import { CommandService } from "./command.service";
import { FederationAuditService } from "./audit.service";
import { PrismaService } from "../prisma/prisma.service";
import { FederationConnectionStatus } from "@prisma/client";
import { of, throwError } from "rxjs";
@@ -55,10 +56,17 @@ describe("FederationAgentService", () => {
if (key === "orchestrator.url") {
return mockOrchestratorUrl;
}
if (key === "NODE_ENV") {
return "test";
}
return undefined;
}),
};
const mockAuditService = {
logInvalidOrchestratorUrl: vi.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
FederationAgentService,
@@ -66,6 +74,7 @@ describe("FederationAgentService", () => {
{ provide: PrismaService, useValue: mockPrisma },
{ provide: HttpService, useValue: mockHttpService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: FederationAuditService, useValue: mockAuditService },
],
}).compile();

View File

@@ -10,7 +10,9 @@ import { ConfigService } from "@nestjs/config";
import { firstValueFrom } from "rxjs";
import { PrismaService } from "../prisma/prisma.service";
import { CommandService } from "./command.service";
import { FederationAuditService } from "./audit.service";
import { FederationConnectionStatus } from "@prisma/client";
import { validateUrl } from "./utils/url-validator";
import type { CommandMessageDetails } from "./types/message.types";
import type {
SpawnAgentCommandPayload,
@@ -46,10 +48,24 @@ export class FederationAgentService {
private readonly prisma: PrismaService,
private readonly commandService: CommandService,
private readonly httpService: HttpService,
private readonly configService: ConfigService
private readonly configService: ConfigService,
private readonly auditService: FederationAuditService
) {
this.orchestratorUrl =
this.configService.get<string>("orchestrator.url") ?? "http://localhost:3001";
const url = this.configService.get<string>("orchestrator.url") ?? "";
const nodeEnv = this.configService.get<string>("NODE_ENV") ?? "production";
const isDevelopment = nodeEnv === "development" || nodeEnv === "test";
// Validate orchestrator URL (SSRF prevention)
const validationResult = validateUrl(url, isDevelopment);
if (!validationResult.valid) {
const errorMessage = validationResult.error ?? "Unknown validation error";
this.logger.error(`Invalid orchestrator URL: ${errorMessage}`);
// Log security event
this.auditService.logInvalidOrchestratorUrl(url, errorMessage);
throw new Error(errorMessage);
}
this.orchestratorUrl = url;
this.logger.log(
`FederationAgentService initialized with orchestrator URL: ${this.orchestratorUrl}`
);

View File

@@ -17,6 +17,8 @@ import { FederationAuditService } from "./audit.service";
import { SignatureService } from "./signature.service";
import { ConnectionService } from "./connection.service";
import { OIDCService } from "./oidc.service";
import { CommandService } from "./command.service";
import { FederationAgentService } from "./federation-agent.service";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
@@ -56,7 +58,18 @@ import { PrismaModule } from "../prisma/prisma.module";
SignatureService,
ConnectionService,
OIDCService,
CommandService,
FederationAgentService,
],
exports: [
FederationService,
CryptoService,
FederationAuditService,
SignatureService,
ConnectionService,
OIDCService,
CommandService,
FederationAgentService,
],
exports: [FederationService, CryptoService, SignatureService, ConnectionService, OIDCService],
})
export class FederationModule {}

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;
}