fix(#121): Remediate security issues from ORCH-121 review
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Priority Fixes (Required Before Production): H3: Add rate limiting to webhook endpoint - Added slowapi library for FastAPI rate limiting - Implemented per-IP rate limiting (100 req/min) on webhook endpoint - Added global rate limiting support via slowapi M4: Add subprocess timeouts to all gates - Added timeout=300 (5 minutes) to all subprocess.run() calls in gates - Implemented proper TimeoutExpired exception handling - Removed dead CalledProcessError handlers (check=False makes them unreachable) M2: Add input validation on QualityCheckRequest - Validate files array size (max 1000 files) - Validate file paths (no path traversal, no null bytes, no absolute paths) - Validate diff summary size (max 10KB) - Validate taskId and agentId format (non-empty) Additional Fixes: H1: Fix coverage.json path resolution - Use absolute paths resolved from project root - Validate path is within project boundaries (prevent path traversal) Code Review Cleanup: - Moved imports to module level in quality_orchestrator.py - Refactored mock detection logic into separate helper methods - Removed dead subprocess.CalledProcessError exception handlers from all gates Testing: - Added comprehensive tests for all security fixes - All 339 coordinator tests pass - All 447 orchestrator tests pass - Followed TDD principles (RED-GREEN-REFACTOR) Security Impact: - Prevents webhook DoS attacks via rate limiting - Prevents hung processes via subprocess timeouts - Prevents path traversal attacks via input validation - Prevents malformed input attacks via comprehensive validation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,14 @@ describe("CoordinatorClientService", () => {
|
||||
let mockConfigService: ConfigService;
|
||||
const mockCoordinatorUrl = "http://localhost:8000";
|
||||
|
||||
// Valid request for testing
|
||||
const validQualityCheckRequest = {
|
||||
taskId: "task-123",
|
||||
agentId: "agent-456",
|
||||
files: ["src/test.ts", "src/test.spec.ts"],
|
||||
diffSummary: "Added new test file",
|
||||
};
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
@@ -31,12 +39,7 @@ describe("CoordinatorClientService", () => {
|
||||
});
|
||||
|
||||
describe("checkQuality", () => {
|
||||
const qualityCheckRequest = {
|
||||
taskId: "task-123",
|
||||
agentId: "agent-456",
|
||||
files: ["src/test.ts", "src/test.spec.ts"],
|
||||
diffSummary: "Added new test file",
|
||||
};
|
||||
const qualityCheckRequest = validQualityCheckRequest;
|
||||
|
||||
it("should successfully call quality check endpoint and return approved result", async () => {
|
||||
const mockResponse = {
|
||||
@@ -260,4 +263,117 @@ describe("CoordinatorClientService", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("input validation", () => {
|
||||
it("should reject request with too many files (> 1000)", async () => {
|
||||
const files = Array(1001).fill("src/file.ts");
|
||||
const request = { ...validQualityCheckRequest, files };
|
||||
|
||||
await expect(service.checkQuality(request)).rejects.toThrow(
|
||||
"files array exceeds maximum size of 1000"
|
||||
);
|
||||
});
|
||||
|
||||
it("should accept request with exactly 1000 files", async () => {
|
||||
const files = Array(1000).fill("src/file.ts");
|
||||
const request = { ...validQualityCheckRequest, files };
|
||||
const mockResponse = {
|
||||
approved: true,
|
||||
gate: "all",
|
||||
message: "All quality gates passed",
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await service.checkQuality(request);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should reject file paths with path traversal attempts", async () => {
|
||||
const request = {
|
||||
...validQualityCheckRequest,
|
||||
files: ["src/test.ts", "../../../etc/passwd"],
|
||||
};
|
||||
|
||||
await expect(service.checkQuality(request)).rejects.toThrow(
|
||||
"file path contains path traversal"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject file paths with null bytes", async () => {
|
||||
const request = {
|
||||
...validQualityCheckRequest,
|
||||
files: ["src/test.ts", "src/file\0.ts"],
|
||||
};
|
||||
|
||||
await expect(service.checkQuality(request)).rejects.toThrow(
|
||||
"file path contains invalid characters"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject diff summary exceeding 10KB", async () => {
|
||||
const largeDiff = "x".repeat(10 * 1024 + 1); // 10KB + 1 byte
|
||||
const request = { ...validQualityCheckRequest, diffSummary: largeDiff };
|
||||
|
||||
await expect(service.checkQuality(request)).rejects.toThrow(
|
||||
"diffSummary exceeds maximum size of 10KB"
|
||||
);
|
||||
});
|
||||
|
||||
it("should accept diff summary of exactly 10KB", async () => {
|
||||
const largeDiff = "x".repeat(10 * 1024); // Exactly 10KB
|
||||
const request = { ...validQualityCheckRequest, diffSummary: largeDiff };
|
||||
const mockResponse = {
|
||||
approved: true,
|
||||
gate: "all",
|
||||
message: "All quality gates passed",
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await service.checkQuality(request);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should reject invalid taskId format", async () => {
|
||||
const request = { ...validQualityCheckRequest, taskId: "" };
|
||||
|
||||
await expect(service.checkQuality(request)).rejects.toThrow(
|
||||
"taskId cannot be empty"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject invalid agentId format", async () => {
|
||||
const request = { ...validQualityCheckRequest, agentId: "" };
|
||||
|
||||
await expect(service.checkQuality(request)).rejects.toThrow(
|
||||
"agentId cannot be empty"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject empty files array", async () => {
|
||||
const request = { ...validQualityCheckRequest, files: [] };
|
||||
|
||||
await expect(service.checkQuality(request)).rejects.toThrow(
|
||||
"files array cannot be empty"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject absolute file paths", async () => {
|
||||
const request = {
|
||||
...validQualityCheckRequest,
|
||||
files: ["/etc/passwd", "src/file.ts"],
|
||||
};
|
||||
|
||||
await expect(service.checkQuality(request)).rejects.toThrow(
|
||||
"file path must be relative"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,9 +50,12 @@ export class CoordinatorClientService {
|
||||
* Check quality gates via coordinator API
|
||||
* @param request Quality check request parameters
|
||||
* @returns Quality check response with approval status
|
||||
* @throws Error if request fails after all retries
|
||||
* @throws Error if request fails after all retries or validation fails
|
||||
*/
|
||||
async checkQuality(request: QualityCheckRequest): Promise<QualityCheckResponse> {
|
||||
// Validate request before sending
|
||||
this.validateRequest(request);
|
||||
|
||||
const url = `${this.coordinatorUrl}/api/quality/check`;
|
||||
|
||||
this.logger.debug(`Checking quality for task ${request.taskId} via coordinator`);
|
||||
@@ -197,4 +200,59 @@ export class CoordinatorClientService {
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate QualityCheckRequest to prevent security issues
|
||||
* @param request Request to validate
|
||||
* @throws Error if validation fails
|
||||
*/
|
||||
private validateRequest(request: QualityCheckRequest): void {
|
||||
// Validate taskId
|
||||
if (!request.taskId || request.taskId.trim() === "") {
|
||||
throw new Error("taskId cannot be empty");
|
||||
}
|
||||
|
||||
// Validate agentId
|
||||
if (!request.agentId || request.agentId.trim() === "") {
|
||||
throw new Error("agentId cannot be empty");
|
||||
}
|
||||
|
||||
// Validate files array
|
||||
if (request.files.length === 0) {
|
||||
throw new Error("files array cannot be empty");
|
||||
}
|
||||
|
||||
if (request.files.length > 1000) {
|
||||
throw new Error("files array exceeds maximum size of 1000");
|
||||
}
|
||||
|
||||
// Validate each file path
|
||||
for (const filePath of request.files) {
|
||||
// Check for path traversal attempts
|
||||
if (filePath.includes("..")) {
|
||||
throw new Error(`file path contains path traversal: ${filePath}`);
|
||||
}
|
||||
|
||||
// Check for null bytes
|
||||
if (filePath.includes("\0")) {
|
||||
throw new Error(`file path contains invalid characters: ${filePath}`);
|
||||
}
|
||||
|
||||
// Check for absolute paths (should be relative)
|
||||
if (filePath.startsWith("/") || filePath.startsWith("\\")) {
|
||||
throw new Error(`file path must be relative: ${filePath}`);
|
||||
}
|
||||
|
||||
// Check for Windows absolute paths (C:\, D:\, etc.)
|
||||
if (/^[a-zA-Z]:[/\\]/.test(filePath)) {
|
||||
throw new Error(`file path must be relative: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate diffSummary size (max 10KB)
|
||||
const diffSummaryBytes = new TextEncoder().encode(request.diffSummary).length;
|
||||
if (diffSummaryBytes > 10 * 1024) {
|
||||
throw new Error("diffSummary exceeds maximum size of 10KB");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user