Commit 6f8a441b62

6f8a441b622987ffe102cf7153e1bcdd1b730cfc

parent: 77a42a8b4f

Unsigned

cmc <hello@cleberg.net> · 2026-07-20 17:05 UTC

fix: reject non-HTTPS webhook URLs at save time

Validating only at send time meant an http:// URL saved fine and then
failed silently on delivery. Validate in upsert so the integration editor
surfaces it, and give the failure its own error case rather than reusing
the generic invalid-URL message.

The send-time guard stays as defense in depth for URLs saved before this.

Layout: unified · split

DomainDig/IntegrationService.swift +18 −1
@@ -40,6 +40,7 @@ final class IntegrationService {
40 switch updatedTarget.configuration { 40 switch updatedTarget.configuration {
41 case .webhook(var configuration): 41 case .webhook(var configuration):
42 if let webhookURL { 42 if let webhookURL {
43 try Self.validateHTTPS(webhookURL)
43 let reference = configuration.credentialReference ?? Self.secretReference(for: updatedTarget.id, suffix: "webhook") 44 let reference = configuration.credentialReference ?? Self.secretReference(for: updatedTarget.id, suffix: "webhook")
44 try IntegrationSecretStore.save(secret: webhookURL, reference: reference) 45 try IntegrationSecretStore.save(secret: webhookURL, reference: reference)
45 configuration.credentialReference = reference 46 configuration.credentialReference = reference
@@ -48,6 +49,7 @@ final class IntegrationService {
48 } 49 }
49 case .slack(var configuration): 50 case .slack(var configuration):
50 if let slackWebhookURL { 51 if let slackWebhookURL {
52 try Self.validateHTTPS(slackWebhookURL)
51 let reference = configuration.credentialReference ?? Self.secretReference(for: updatedTarget.id, suffix: "slack") 53 let reference = configuration.credentialReference ?? Self.secretReference(for: updatedTarget.id, suffix: "slack")
52 try IntegrationSecretStore.save(secret: slackWebhookURL, reference: reference) 54 try IntegrationSecretStore.save(secret: slackWebhookURL, reference: reference)
53 configuration.credentialReference = reference 55 configuration.credentialReference = reference
@@ -429,6 +431,15 @@ final class IntegrationService {
429 "integration.\(integrationID.uuidString).\(suffix)" 431 "integration.\(integrationID.uuidString).\(suffix)"
430 } 432 }
431 433
434 private static func validateHTTPS(_ string: String) throws {
435 guard let url = URL(string: string) else {
436 throw IntegrationError.invalidURL
437 }
438 guard url.scheme?.lowercased() == "https" else {
439 throw IntegrationError.insecureURL
440 }
441 }
442
432 private static func loadTargets(defaults: UserDefaults) -> [IntegrationTarget] { 443 private static func loadTargets(defaults: UserDefaults) -> [IntegrationTarget] {
433 load([IntegrationTarget].self, key: StorageKey.targets, defaults: defaults) ?? [] 444 load([IntegrationTarget].self, key: StorageKey.targets, defaults: defaults) ?? []
434 } 445 }
@@ -524,6 +535,7 @@ private struct SlackText: Encodable {
524 535
525private enum IntegrationError: LocalizedError { 536private enum IntegrationError: LocalizedError {
526 case invalidURL 537 case invalidURL
538 case insecureURL
527 case missingSecret 539 case missingSecret
528 case invalidResponse(Int) 540 case invalidResponse(Int)
529 case invalidSMTPPort 541 case invalidSMTPPort
@@ -534,6 +546,8 @@ private enum IntegrationError: LocalizedError {
534 switch self { 546 switch self {
535 case .invalidURL: 547 case .invalidURL:
536 return "The integration URL is invalid." 548 return "The integration URL is invalid."
549 case .insecureURL:
550 return "The integration URL must use https. A webhook URL is itself a secret, so http would send it in cleartext."
537 case .missingSecret: 551 case .missingSecret:
538 return "This integration is missing a saved secret." 552 return "This integration is missing a saved secret."
539 case .invalidResponse(let statusCode): 553 case .invalidResponse(let statusCode):
@@ -555,9 +569,12 @@ private enum HTTPIntegrationClient {
555 headers: [String: String], 569 headers: [String: String],
556 timeoutSeconds: Double 570 timeoutSeconds: Double
557 ) async throws { 571 ) async throws {
558 guard let url = URL(string: urlString), url.scheme?.lowercased() == "https" else { 572 guard let url = URL(string: urlString) else {
559 throw IntegrationError.invalidURL 573 throw IntegrationError.invalidURL
560 } 574 }
575 guard url.scheme?.lowercased() == "https" else {
576 throw IntegrationError.insecureURL
577 }
561 578
562 var request = URLRequest(url: url, timeoutInterval: timeoutSeconds) 579 var request = URLRequest(url: url, timeoutInterval: timeoutSeconds)
563 request.httpMethod = "POST" 580 request.httpMethod = "POST"