fix(#279): Validate orchestrator URL configuration (SSRF risk)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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:
@@ -207,4 +207,18 @@ export class FederationAuditService {
|
|||||||
securityEvent: true,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { HttpService } from "@nestjs/axios";
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { FederationAgentService } from "./federation-agent.service";
|
import { FederationAgentService } from "./federation-agent.service";
|
||||||
import { CommandService } from "./command.service";
|
import { CommandService } from "./command.service";
|
||||||
|
import { FederationAuditService } from "./audit.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { FederationConnectionStatus } from "@prisma/client";
|
import { FederationConnectionStatus } from "@prisma/client";
|
||||||
import { of, throwError } from "rxjs";
|
import { of, throwError } from "rxjs";
|
||||||
@@ -55,10 +56,17 @@ describe("FederationAgentService", () => {
|
|||||||
if (key === "orchestrator.url") {
|
if (key === "orchestrator.url") {
|
||||||
return mockOrchestratorUrl;
|
return mockOrchestratorUrl;
|
||||||
}
|
}
|
||||||
|
if (key === "NODE_ENV") {
|
||||||
|
return "test";
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockAuditService = {
|
||||||
|
logInvalidOrchestratorUrl: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
FederationAgentService,
|
FederationAgentService,
|
||||||
@@ -66,6 +74,7 @@ describe("FederationAgentService", () => {
|
|||||||
{ provide: PrismaService, useValue: mockPrisma },
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
{ provide: HttpService, useValue: mockHttpService },
|
{ provide: HttpService, useValue: mockHttpService },
|
||||||
{ provide: ConfigService, useValue: mockConfigService },
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
{ provide: FederationAuditService, useValue: mockAuditService },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { CommandService } from "./command.service";
|
import { CommandService } from "./command.service";
|
||||||
|
import { FederationAuditService } from "./audit.service";
|
||||||
import { FederationConnectionStatus } from "@prisma/client";
|
import { FederationConnectionStatus } from "@prisma/client";
|
||||||
|
import { validateUrl } from "./utils/url-validator";
|
||||||
import type { CommandMessageDetails } from "./types/message.types";
|
import type { CommandMessageDetails } from "./types/message.types";
|
||||||
import type {
|
import type {
|
||||||
SpawnAgentCommandPayload,
|
SpawnAgentCommandPayload,
|
||||||
@@ -46,10 +48,24 @@ export class FederationAgentService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly commandService: CommandService,
|
private readonly commandService: CommandService,
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
private readonly configService: ConfigService
|
private readonly configService: ConfigService,
|
||||||
|
private readonly auditService: FederationAuditService
|
||||||
) {
|
) {
|
||||||
this.orchestratorUrl =
|
const url = this.configService.get<string>("orchestrator.url") ?? "";
|
||||||
this.configService.get<string>("orchestrator.url") ?? "http://localhost:3001";
|
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(
|
this.logger.log(
|
||||||
`FederationAgentService initialized with orchestrator URL: ${this.orchestratorUrl}`
|
`FederationAgentService initialized with orchestrator URL: ${this.orchestratorUrl}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { FederationAuditService } from "./audit.service";
|
|||||||
import { SignatureService } from "./signature.service";
|
import { SignatureService } from "./signature.service";
|
||||||
import { ConnectionService } from "./connection.service";
|
import { ConnectionService } from "./connection.service";
|
||||||
import { OIDCService } from "./oidc.service";
|
import { OIDCService } from "./oidc.service";
|
||||||
|
import { CommandService } from "./command.service";
|
||||||
|
import { FederationAgentService } from "./federation-agent.service";
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -56,7 +58,18 @@ import { PrismaModule } from "../prisma/prisma.module";
|
|||||||
SignatureService,
|
SignatureService,
|
||||||
ConnectionService,
|
ConnectionService,
|
||||||
OIDCService,
|
OIDCService,
|
||||||
|
CommandService,
|
||||||
|
FederationAgentService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
FederationService,
|
||||||
|
CryptoService,
|
||||||
|
FederationAuditService,
|
||||||
|
SignatureService,
|
||||||
|
ConnectionService,
|
||||||
|
OIDCService,
|
||||||
|
CommandService,
|
||||||
|
FederationAgentService,
|
||||||
],
|
],
|
||||||
exports: [FederationService, CryptoService, SignatureService, ConnectionService, OIDCService],
|
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
238
apps/api/src/federation/utils/url-validator.spec.ts
Normal file
238
apps/api/src/federation/utils/url-validator.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
149
apps/api/src/federation/utils/url-validator.ts
Normal file
149
apps/api/src/federation/utils/url-validator.ts
Normal 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;
|
||||||
|
}
|
||||||
46
docs/scratchpads/279-orchestrator-url-validation.md
Normal file
46
docs/scratchpads/279-orchestrator-url-validation.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Issue #279: Validate orchestrator URL configuration (SSRF risk)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Prevent SSRF vulnerability by validating orchestrator URL from environment variables. Ensure URL format is valid, protocol is whitelisted (http/https), and hostname is not malicious.
|
||||||
|
|
||||||
|
## Security Impact
|
||||||
|
|
||||||
|
- SSRF vulnerability - attacker could point URL to internal services
|
||||||
|
- Data exfiltration - agent spawn requests sent to attacker-controlled server
|
||||||
|
- All agent operations compromised
|
||||||
|
|
||||||
|
## Location
|
||||||
|
|
||||||
|
`apps/api/src/federation/federation-agent.service.ts:43-56`
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
1. Create URL validation utility function
|
||||||
|
2. Whitelist protocols (http, https only)
|
||||||
|
3. Validate hostname (reject localhost, private IPs, loopback)
|
||||||
|
4. Add structured logging for validation failures via audit service
|
||||||
|
5. Write comprehensive tests
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
- [ ] Write tests for URL validation (RED)
|
||||||
|
- [ ] Implement URL validation logic (GREEN)
|
||||||
|
- [ ] Integrate validation into FederationAgentService constructor
|
||||||
|
- [ ] Add audit logging for invalid URLs
|
||||||
|
- [ ] Refactor for clarity
|
||||||
|
- [ ] Run quality gates
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Valid URLs (http://example.com:3001, https://orchestrator.example.com)
|
||||||
|
- Invalid protocols (ftp://, file://, javascript:)
|
||||||
|
- Internal/private IPs (127.0.0.1, 192.168.x.x, 10.x.x.x)
|
||||||
|
- Localhost variants (localhost, 0.0.0.0)
|
||||||
|
- Malformed URLs
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Use Node's built-in URL class for parsing
|
||||||
|
- Consider environment-specific allowlists (dev can use localhost)
|
||||||
|
- Add security event logging via FederationAuditService
|
||||||
Reference in New Issue
Block a user