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 {
4040 switch updatedTarget.configuration {
4141 case .webhook(var configuration):
4242 if let webhookURL {
43 try Self.validateHTTPS(webhookURL)
4344 let reference = configuration.credentialReference ?? Self.secretReference(for: updatedTarget.id, suffix: "webhook")
4445 try IntegrationSecretStore.save(secret: webhookURL, reference: reference)
4546 configuration.credentialReference = reference
@@ -48,6 +49,7 @@ final class IntegrationService {
4849 }
4950 case .slack(var configuration):
5051 if let slackWebhookURL {
52 try Self.validateHTTPS(slackWebhookURL)
5153 let reference = configuration.credentialReference ?? Self.secretReference(for: updatedTarget.id, suffix: "slack")
5254 try IntegrationSecretStore.save(secret: slackWebhookURL, reference: reference)
5355 configuration.credentialReference = reference
@@ -429,6 +431,15 @@ final class IntegrationService {
429431 "integration.\(integrationID.uuidString).\(suffix)"
430432 }
431433
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
432443 private static func loadTargets(defaults: UserDefaults) -> [IntegrationTarget] {
433444 load([IntegrationTarget].self, key: StorageKey.targets, defaults: defaults) ?? []
434445 }
@@ -524,6 +535,7 @@ private struct SlackText: Encodable {
524535
525536private enum IntegrationError: LocalizedError {
526537 case invalidURL
538 case insecureURL
527539 case missingSecret
528540 case invalidResponse(Int)
529541 case invalidSMTPPort
@@ -534,6 +546,8 @@ private enum IntegrationError: LocalizedError {
534546 switch self {
535547 case .invalidURL:
536548 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."
537551 case .missingSecret:
538552 return "This integration is missing a saved secret."
539553 case .invalidResponse(let statusCode):
@@ -555,9 +569,12 @@ private enum HTTPIntegrationClient {
555569 headers: [String: String],
556570 timeoutSeconds: Double
557571 ) async throws {
558 guard let url = URL(string: urlString), url.scheme?.lowercased() == "https" else {
572 guard let url = URL(string: urlString) else {
559573 throw IntegrationError.invalidURL
560574 }
575 guard url.scheme?.lowercased() == "https" else {
576 throw IntegrationError.insecureURL
577 }
561578
562579 var request = URLRequest(url: url, timeoutInterval: timeoutSeconds)
563580 request.httpMethod = "POST"