diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e19da74..340e54f 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -12,3 +12,7 @@ **Vulnerability:** A process can block (deadlock) when its stdout/stderr pipe fills before the parent reads it, because the child blocks on `write()` while the parent blocks on `waitUntilExit()`. **Learning:** `pipe.fileHandleForReading.readDataToEndOfFile()` after `process.waitUntilExit()` is the deadlock pattern. Default macOS pipe buffer is ~64KB. **Prevention:** Read the pipe before/concurrently-with waiting for exit. The simplest pattern is to perform the read inside the same background queue that calls `waitUntilExit()`, capturing the bytes for the caller to use after the dispatch group resolves. +## 2024-05-16 - Webhook Insecure HTTP Scheme Allowed +**Vulnerability:** Autopilot configuration allowed `http` schemes for webhook URLs, risking insecure unencrypted transmission of sensitive daemon alerts. +**Learning:** Validation logic incorrectly whitelisted both `http` and `https`, likely copy-pasted from generic URL validation instead of enforcing secure-by-default for webhooks. +**Prevention:** Always enforce `https` scheme for outbound webhooks handling alert payloads and align validation logic with parsing logic. diff --git a/Sources/Cacheout/Headless/StatusSocket.swift b/Sources/Cacheout/Headless/StatusSocket.swift index 12c516e..cb084cd 100644 --- a/Sources/Cacheout/Headless/StatusSocket.swift +++ b/Sources/Cacheout/Headless/StatusSocket.swift @@ -708,8 +708,8 @@ public enum AutopilotConfigValidator { if let urlStr = webhook["url"] as? String { if let url = URL(string: urlStr) { let scheme = url.scheme?.lowercased() ?? "" - if scheme != "http" && scheme != "https" { - errors.append("webhook: url must use http or https scheme, got '\(scheme)'") + if scheme != "https" { + errors.append("webhook: url must use https scheme for security, got '\(scheme)'") } if url.host == nil || url.host?.isEmpty == true { errors.append("webhook: url must be an absolute URL with a host") diff --git a/Sources/Cacheout/Headless/WebhookAlerter.swift b/Sources/Cacheout/Headless/WebhookAlerter.swift index 8fa8a8e..1ddfb94 100644 --- a/Sources/Cacheout/Headless/WebhookAlerter.swift +++ b/Sources/Cacheout/Headless/WebhookAlerter.swift @@ -336,7 +336,8 @@ extension WebhookAlerter.WebhookConfig { public static func parse(from json: [String: Any]) -> WebhookAlerter.WebhookConfig? { guard let webhook = json["webhook"] as? [String: Any], let urlStr = webhook["url"] as? String, - let url = URL(string: urlStr) else { + let url = URL(string: urlStr), + url.scheme?.lowercased() == "https" else { return nil } let format = webhook["format"] as? String ?? "generic" diff --git a/Tests/CacheoutTests/HeadlessTests.swift b/Tests/CacheoutTests/HeadlessTests.swift index ca8ccae..49f1611 100644 --- a/Tests/CacheoutTests/HeadlessTests.swift +++ b/Tests/CacheoutTests/HeadlessTests.swift @@ -540,6 +540,18 @@ final class AutopilotConfigValidatorTests: XCTestCase { XCTAssertTrue(errors.contains { $0.contains("webhook: missing") && $0.contains("timeout_s") }) } + func testWebhookInsecureURLRejected() { + let json = """ + { + "version": 1, + "enabled": true, + "webhook": {"url": "http://x.com", "format": "generic", "timeout_s": 5} + } + """.data(using: .utf8)! + let errors = AutopilotConfigValidator.validate(data: json) + XCTAssertTrue(errors.contains { $0.contains("https scheme") }) + } + func testWebhookInvalidFormat() { let json = """ {