From 7fba8922e1540741240555560cc9504342bdb091 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 18 Mar 2026 19:16:02 -0500 Subject: Guard authenticated text fetches to sr.ht hosts and add regression test --- Hutch.xcodeproj/project.pbxproj | 130 +++++++++++++++++++++ .../xcshareddata/xcschemes/Hutch.xcscheme | 96 +++++++++++++++ .../xcshareddata/xcschemes/HutchTests.xcscheme | 69 +++++++++++ Hutch/Networking/SRHTClient.swift | 14 +++ Hutch/Networking/SRHTError.swift | 4 + HutchTests.xctestplan | 30 +++++ HutchTests/HutchTests.swift | 16 +++ HutchTests/SRHTClientTests.swift | 27 +++++ 8 files changed, 386 insertions(+) create mode 100644 Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme create mode 100644 Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme create mode 100644 HutchTests.xctestplan create mode 100644 HutchTests/HutchTests.swift create mode 100644 HutchTests/SRHTClientTests.swift diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 8aab8bb..3cd0646 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -13,12 +13,25 @@ 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8B4B28C92F6704280045FA19 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8B4B28D02F6704280045FA19; + remoteInfo = Hutch; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 8B2F89612F69DEA900FC0253 /* TODO.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = TODO.md; sourceTree = ""; }; 8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 8B2F89642F69DEB900FC0253 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 8B2F89652F69DEB900FC0253 /* SECURITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SECURITY.md; sourceTree = ""; }; 8B4B28D12F6704280045FA19 /* Hutch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hutch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HutchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = ""; }; + 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -40,6 +53,11 @@ path = Hutch; sourceTree = ""; }; + 8B8182B82F6B73F3000AE049 /* HutchTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = HutchTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -50,17 +68,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8B8182B42F6B73F3000AE049 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 8B4B28C82F6704280045FA19 = { isa = PBXGroup; children = ( + 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */, + 8B8182C22F6B742B000AE049 /* HutchTests */, 8B2F89632F69DEB900FC0253 /* LICENSE */, 8B2F89642F69DEB900FC0253 /* README.md */, 8B2F89652F69DEB900FC0253 /* SECURITY.md */, 8B2F89612F69DEA900FC0253 /* TODO.md */, 8B4B28D32F6704280045FA19 /* Hutch */, + 8B8182B82F6B73F3000AE049 /* HutchTests */, 8B4B28D22F6704280045FA19 /* Products */, ); sourceTree = ""; @@ -69,6 +97,7 @@ isa = PBXGroup; children = ( 8B4B28D12F6704280045FA19 /* Hutch.app */, + 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */, ); name = Products; sourceTree = ""; @@ -98,6 +127,29 @@ productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */; productType = "com.apple.product-type.application"; }; + 8B8182B62F6B73F3000AE049 /* HutchTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8B8182BF2F6B73F3000AE049 /* Build configuration list for PBXNativeTarget "HutchTests" */; + buildPhases = ( + 8B8182B32F6B73F3000AE049 /* Sources */, + 8B8182B42F6B73F3000AE049 /* Frameworks */, + 8B8182B52F6B73F3000AE049 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 8B8182BC2F6B73F3000AE049 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 8B8182B82F6B73F3000AE049 /* HutchTests */, + ); + name = HutchTests; + packageProductDependencies = ( + ); + productName = HutchTests; + productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -111,6 +163,10 @@ 8B4B28D02F6704280045FA19 = { CreatedOnToolsVersion = 26.3; }; + 8B8182B62F6B73F3000AE049 = { + CreatedOnToolsVersion = 26.3; + TestTargetID = 8B4B28D02F6704280045FA19; + }; }; }; buildConfigurationList = 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */; @@ -128,6 +184,7 @@ projectRoot = ""; targets = ( 8B4B28D02F6704280045FA19 /* Hutch */, + 8B8182B62F6B73F3000AE049 /* HutchTests */, ); }; /* End PBXProject section */ @@ -144,6 +201,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8B8182B52F6B73F3000AE049 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -154,8 +218,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8B8182B32F6B73F3000AE049 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 8B8182BC2F6B73F3000AE049 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8B4B28D02F6704280045FA19 /* Hutch */; + targetProxy = 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 8B4B28DA2F6704290045FA19 /* Debug */ = { isa = XCBuildConfiguration; @@ -350,6 +429,48 @@ }; name = Release; }; + 8B8182BD2F6B73F3000AE049 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.HutchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hutch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hutch"; + }; + name = Debug; + }; + 8B8182BE2F6B73F3000AE049 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.HutchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hutch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hutch"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -371,6 +492,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 8B8182BF2F6B73F3000AE049 /* Build configuration list for PBXNativeTarget "HutchTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B8182BD2F6B73F3000AE049 /* Debug */, + 8B8182BE2F6B73F3000AE049 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 8B4B28C92F6704280045FA19 /* Project object */; diff --git a/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme new file mode 100644 index 0000000..4bb83be --- /dev/null +++ b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme b/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme new file mode 100644 index 0000000..909ebd2 --- /dev/null +++ b/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift index a24fe24..6032cab 100644 --- a/Hutch/Networking/SRHTClient.swift +++ b/Hutch/Networking/SRHTClient.swift @@ -435,6 +435,9 @@ final class SRHTClient: Sendable { guard let token = _token.withLock({ $0 }), !token.isEmpty else { throw SRHTError.unauthorized } + guard Self.isTrustedAuthenticatedTextURL(url) else { + throw SRHTError.invalidAuthenticatedURL(url) + } var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") @@ -512,6 +515,17 @@ final class SRHTClient: Sendable { // MARK: - Data Helper +private extension SRHTClient { + static func isTrustedAuthenticatedTextURL(_ url: URL) -> Bool { + guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame, + let host = url.host?.lowercased() else { + return false + } + + return host.hasSuffix(".sr.ht") + } +} + private extension Data { mutating func append(_ string: String) { if let data = string.data(using: .utf8) { diff --git a/Hutch/Networking/SRHTError.swift b/Hutch/Networking/SRHTError.swift index f148c00..f2da408 100644 --- a/Hutch/Networking/SRHTError.swift +++ b/Hutch/Networking/SRHTError.swift @@ -6,6 +6,8 @@ enum SRHTError: LocalizedError, Sendable { case graphQLErrors([GraphQLError]) /// The HTTP response had a non-2xx status code. case httpError(Int) + /// The client refused to send credentials to an unexpected URL. + case invalidAuthenticatedURL(URL) /// The response data could not be decoded. case decodingError(any Error) /// A networking error from URLSession (timeout, DNS, connectivity, etc.). @@ -20,6 +22,8 @@ enum SRHTError: LocalizedError, Sendable { return "GraphQL error: \(messages)" case .httpError(let code): return "Server returned HTTP \(code)." + case .invalidAuthenticatedURL(let url): + return "Refused to authenticate request to unexpected URL: \(url.absoluteString)" case .decodingError(let error): return "Failed to decode response: \(error.localizedDescription)" case .networkError(let error): diff --git a/HutchTests.xctestplan b/HutchTests.xctestplan new file mode 100644 index 0000000..8af3d1b --- /dev/null +++ b/HutchTests.xctestplan @@ -0,0 +1,30 @@ +{ + "configurations" : [ + { + "id" : "06A589A5-80C7-41FE-911F-03518252CBDD", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "performanceAntipatternCheckerEnabled" : true, + "targetForVariableExpansion" : { + "containerPath" : "container:Hutch.xcodeproj", + "identifier" : "8B4B28D02F6704280045FA19", + "name" : "Hutch" + } + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:Hutch.xcodeproj", + "identifier" : "8B8182B62F6B73F3000AE049", + "name" : "HutchTests" + } + } + ], + "version" : 1 +} diff --git a/HutchTests/HutchTests.swift b/HutchTests/HutchTests.swift new file mode 100644 index 0000000..c681efa --- /dev/null +++ b/HutchTests/HutchTests.swift @@ -0,0 +1,16 @@ +// +// HutchTests.swift +// HutchTests +// +// Created by cmc on 2026-03-18. +// + +import Testing + +struct HutchTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/HutchTests/SRHTClientTests.swift b/HutchTests/SRHTClientTests.swift new file mode 100644 index 0000000..2ce2529 --- /dev/null +++ b/HutchTests/SRHTClientTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import Hutch + +struct SRHTClientTests { + + @Test + @MainActor + func fetchTextRejectsUnexpectedAuthenticatedURL() async throws { + let client = SRHTClient(token: "test-token") + let url = try #require(URL(string: "https://example.com/build-log")) + + do { + _ = try await client.fetchText(url: url) + Issue.record("Expected fetchText(url:) to reject non-sr.ht URLs.") + } catch let error as SRHTError { + guard case .invalidAuthenticatedURL(let rejectedURL) = error else { + Issue.record("Expected invalidAuthenticatedURL error, got \(error).") + return + } + + #expect(rejectedURL == url) + } catch { + Issue.record("Expected SRHTError.invalidAuthenticatedURL, got \(error).") + } + } +} -- cgit v1.2.3