Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Add OrchestratorApiKeyGuard to protect agent management endpoints (spawn, kill, kill-all, status) from unauthorized access. Uses X-API-Key header with constant-time comparison to prevent timing attacks. - Create apps/orchestrator/src/common/guards/api-key.guard.ts - Add comprehensive tests for all guard scenarios - Apply guard to AgentsController (controller-level protection) - Document ORCHESTRATOR_API_KEY in .env.example files - Health endpoints remain unauthenticated for monitoring Security: Prevents unauthorized users from draining API credits or killing all agents via unprotected endpoints. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
170 lines
5.5 KiB
TypeScript
170 lines
5.5 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { OrchestratorApiKeyGuard } from "./api-key.guard";
|
|
|
|
describe("OrchestratorApiKeyGuard", () => {
|
|
let guard: OrchestratorApiKeyGuard;
|
|
let mockConfigService: ConfigService;
|
|
|
|
beforeEach(() => {
|
|
mockConfigService = {
|
|
get: vi.fn(),
|
|
} as unknown as ConfigService;
|
|
|
|
guard = new OrchestratorApiKeyGuard(mockConfigService);
|
|
});
|
|
|
|
const createMockExecutionContext = (headers: Record<string, string>): ExecutionContext => {
|
|
return {
|
|
switchToHttp: () => ({
|
|
getRequest: () => ({
|
|
headers,
|
|
}),
|
|
}),
|
|
} as ExecutionContext;
|
|
};
|
|
|
|
describe("canActivate", () => {
|
|
it("should return true when valid API key is provided", () => {
|
|
const validApiKey = "test-orchestrator-api-key-12345";
|
|
vi.mocked(mockConfigService.get).mockReturnValue(validApiKey);
|
|
|
|
const context = createMockExecutionContext({
|
|
"x-api-key": validApiKey,
|
|
});
|
|
|
|
const result = guard.canActivate(context);
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockConfigService.get).toHaveBeenCalledWith("ORCHESTRATOR_API_KEY");
|
|
});
|
|
|
|
it("should throw UnauthorizedException when no API key is provided", () => {
|
|
const context = createMockExecutionContext({});
|
|
|
|
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
|
|
expect(() => guard.canActivate(context)).toThrow("No API key provided");
|
|
});
|
|
|
|
it("should throw UnauthorizedException when API key is invalid", () => {
|
|
const validApiKey = "correct-orchestrator-api-key";
|
|
const invalidApiKey = "wrong-api-key";
|
|
|
|
vi.mocked(mockConfigService.get).mockReturnValue(validApiKey);
|
|
|
|
const context = createMockExecutionContext({
|
|
"x-api-key": invalidApiKey,
|
|
});
|
|
|
|
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
|
|
expect(() => guard.canActivate(context)).toThrow("Invalid API key");
|
|
});
|
|
|
|
it("should throw UnauthorizedException when ORCHESTRATOR_API_KEY is not configured", () => {
|
|
vi.mocked(mockConfigService.get).mockReturnValue(undefined);
|
|
|
|
const context = createMockExecutionContext({
|
|
"x-api-key": "some-key",
|
|
});
|
|
|
|
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
|
|
expect(() => guard.canActivate(context)).toThrow("API key authentication not configured");
|
|
});
|
|
|
|
it("should handle uppercase header name (X-API-Key)", () => {
|
|
const validApiKey = "test-orchestrator-api-key-12345";
|
|
vi.mocked(mockConfigService.get).mockReturnValue(validApiKey);
|
|
|
|
const context = createMockExecutionContext({
|
|
"X-API-Key": validApiKey,
|
|
});
|
|
|
|
const result = guard.canActivate(context);
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should handle mixed case header name (X-Api-Key)", () => {
|
|
const validApiKey = "test-orchestrator-api-key-12345";
|
|
vi.mocked(mockConfigService.get).mockReturnValue(validApiKey);
|
|
|
|
const context = createMockExecutionContext({
|
|
"X-Api-Key": validApiKey,
|
|
});
|
|
|
|
const result = guard.canActivate(context);
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should reject empty string API key", () => {
|
|
vi.mocked(mockConfigService.get).mockReturnValue("valid-key");
|
|
|
|
const context = createMockExecutionContext({
|
|
"x-api-key": "",
|
|
});
|
|
|
|
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
|
|
expect(() => guard.canActivate(context)).toThrow("No API key provided");
|
|
});
|
|
|
|
it("should reject whitespace-only API key", () => {
|
|
vi.mocked(mockConfigService.get).mockReturnValue("valid-key");
|
|
|
|
const context = createMockExecutionContext({
|
|
"x-api-key": " ",
|
|
});
|
|
|
|
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
|
|
expect(() => guard.canActivate(context)).toThrow("No API key provided");
|
|
});
|
|
|
|
it("should use constant-time comparison to prevent timing attacks", () => {
|
|
const validApiKey = "test-api-key-12345";
|
|
vi.mocked(mockConfigService.get).mockReturnValue(validApiKey);
|
|
|
|
const startTime = Date.now();
|
|
const context1 = createMockExecutionContext({
|
|
"x-api-key": "wrong-key-short",
|
|
});
|
|
|
|
try {
|
|
guard.canActivate(context1);
|
|
} catch {
|
|
// Expected to fail
|
|
}
|
|
const shortKeyTime = Date.now() - startTime;
|
|
|
|
const startTime2 = Date.now();
|
|
const context2 = createMockExecutionContext({
|
|
"x-api-key": "test-api-key-12344", // Very close to correct key
|
|
});
|
|
|
|
try {
|
|
guard.canActivate(context2);
|
|
} catch {
|
|
// Expected to fail
|
|
}
|
|
const longKeyTime = Date.now() - startTime2;
|
|
|
|
// Times should be similar (within 10ms) to prevent timing attacks
|
|
// Note: This is a simplified test; real timing attack prevention
|
|
// is handled by crypto.timingSafeEqual
|
|
expect(Math.abs(shortKeyTime - longKeyTime)).toBeLessThan(10);
|
|
});
|
|
|
|
it("should reject keys with different lengths even if prefix matches", () => {
|
|
const validApiKey = "orchestrator-secret-key-abc123";
|
|
vi.mocked(mockConfigService.get).mockReturnValue(validApiKey);
|
|
|
|
const context = createMockExecutionContext({
|
|
"x-api-key": "orchestrator-secret-key-abc123-extra",
|
|
});
|
|
|
|
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
|
|
expect(() => guard.canActivate(context)).toThrow("Invalid API key");
|
|
});
|
|
});
|
|
});
|