Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions ZShare/Views/RightButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@
import UIKit

class RightButton: UIButton {
override func layoutSubviews() {
super.layoutSubviews()
self.alignImageToRight()
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}

private func alignImageToRight() {
guard let imageView = self.imageView else { return }

let imageOffset = self.frame.width - imageView.frame.size.width

self.titleEdgeInsets = UIEdgeInsets(top: 0, left: -1 * imageView.frame.size.width, bottom: 0, right: imageView.frame.size.width)
self.imageEdgeInsets = UIEdgeInsets(top: 0, left: imageOffset, bottom: 0, right: -1 * imageOffset)

required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}

private func setup() {
var config = self.configuration ?? UIButton.Configuration.plain()
config.imagePlacement = .trailing
self.configuration = config
}
}
12 changes: 10 additions & 2 deletions Zotero.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1282,6 +1282,8 @@
B3FE4B9C268DDE6100CE123F /* CitationBibliographyExportCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3FE4B9B268DDE6100CE123F /* CitationBibliographyExportCoordinator.swift */; };
B3FE79E32B332E5E009FBDBD /* CreateEditDownloadDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30548F52B331B1A00853966 /* CreateEditDownloadDbRequest.swift */; };
B3FE79E42B333030009FBDBD /* DeleteDownloadDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30548F72B331B2500853966 /* DeleteDownloadDbRequest.swift */; };
FD801AC32F1CFDFB001771BC /* WebDavCertificatePinningSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD801AC22F1CFDFB001771BC /* WebDavCertificatePinningSpec.swift */; };
FD801ACB2F1D1836001771BC /* WebDavCertificatePinningIntegrationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD801ACA2F1D1836001771BC /* WebDavCertificatePinningIntegrationSpec.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -2458,6 +2460,8 @@
B3FE4B94268DDE4900CE123F /* CitationBibliographyExportState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CitationBibliographyExportState.swift; sourceTree = "<group>"; };
B3FE4B96268DDE4900CE123F /* CitationBibliographyExportView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CitationBibliographyExportView.swift; sourceTree = "<group>"; };
B3FE4B9B268DDE6100CE123F /* CitationBibliographyExportCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CitationBibliographyExportCoordinator.swift; sourceTree = "<group>"; };
FD801AC22F1CFDFB001771BC /* WebDavCertificatePinningSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDavCertificatePinningSpec.swift; sourceTree = "<group>"; };
FD801ACA2F1D1836001771BC /* WebDavCertificatePinningIntegrationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDavCertificatePinningIntegrationSpec.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -3180,6 +3184,7 @@
B30D596E2206F60500884C4A /* ZoteroTests */ = {
isa = PBXGroup;
children = (
FD801ACA2F1D1836001771BC /* WebDavCertificatePinningIntegrationSpec.swift */,
B3F47C04243B4BC0004F8B1E /* Bundled */,
B3202C662710486900485BE4 /* Extensions */,
B32A3C83248008A2009E2C5D /* JSONs */,
Expand All @@ -3205,6 +3210,7 @@
B3202C6B271048FF00485BE4 /* WebDavControllerSpec.swift */,
B31DDAA02729A7DC002CFA05 /* WebDavCredentials.swift */,
61AD977C2D67F42A000FDF45 /* PDFWorkerControllerSpec.swift */,
FD801AC22F1CFDFB001771BC /* WebDavCertificatePinningSpec.swift */,
);
path = ZoteroTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -5108,7 +5114,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "swiftgen config lint\nswiftgen config run\n";
shellScript = "export PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\nswiftgen config lint\nswiftgen config run\n";
};
6185A1A82D9C266100285385 /* Bundle Utilities */ = {
isa = PBXShellScriptBuildPhase;
Expand Down Expand Up @@ -5167,7 +5173,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "swiftlint\n";
shellScript = "export PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\nswiftlint\n";
};
B3052A912C412E12008C8596 /* Bundle Reader */ = {
isa = PBXShellScriptBuildPhase;
Expand Down Expand Up @@ -6156,8 +6162,10 @@
B3518DA72AEBCB5E00D983B4 /* SettingsResponseSpec.swift in Sources */,
6144B5D82A4ADDC400914B3C /* ItemTitleFormatterSpec.swift in Sources */,
61FB13D72EECBE0A00329563 /* bitcoin_pdf_page_0_text.json in Sources */,
FD801AC32F1CFDFB001771BC /* WebDavCertificatePinningSpec.swift in Sources */,
6144B5DF2A4AE48F00914B3C /* SyncControllerSpec.swift in Sources */,
B3243BC82A5EB2740033A7D6 /* HtmlAttributedStringConverterSpec.swift in Sources */,
FD801ACB2F1D1836001771BC /* WebDavCertificatePinningIntegrationSpec.swift in Sources */,
61AD977D2D67F42A000FDF45 /* PDFWorkerControllerSpec.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
11 changes: 11 additions & 0 deletions Zotero/Assets/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,17 @@
"settings.sync.directory_not_found.title" = "Directory not found";
"settings.sync.directory_not_found.message" = "%@ does not exist.\n\nDo you want to create it now?";
"settings.sync.verified" = "Verified";
"settings.sync.certificate.untrusted.title" = "Untrusted Certificate";
"settings.sync.certificate.untrusted.message" = "The certificate for %@ is not trusted by the system.";
"settings.sync.certificate.data_header" = "Certificate Data:";
"settings.sync.certificate.common_name" = "Common Name: %@";
"settings.sync.certificate.fingerprint" = "SHA-256 Fingerprint: %@";
"settings.sync.certificate.trust.question" = "Would you like to trust this certificate? This will allow connections to this server.";
"settings.sync.certificate.trust.action" = "Trust";
"settings.sync.certificate.changed.title" = "Certificate Changed";
"settings.sync.certificate.changed.message" = "The certificate for %@ has changed. This could indicate a security issue. Please verify the server and re-authenticate.";
"settings.sync.certificate.expired.title" = "Certificate Expired";
"settings.sync.certificate.expired.message" = "The certificate for %@ has expired. Please contact your server administrator.";
"settings.permission" = "User Permission";
"settings.permission_subtitle" = "Ask for user permission for each write action";
"settings.translators" = "Translators";
Expand Down
129 changes: 128 additions & 1 deletion Zotero/Controllers/API/ZoteroApiClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,22 @@ private enum ApiAuthType {
final class ZoteroApiClient: ApiClient {
private let url: URL
private let manager: Alamofire.Session
private let sessionDelegate = ZoteroSessionDelegate()

private var token: ApiAuthType?

var onTrustChallenge: ((SecTrust, String, @escaping (Bool) -> Void) -> Void)? {
get { sessionDelegate.onTrustChallenge }
set { sessionDelegate.onTrustChallenge = newValue }
}

init(baseUrl: String, configuration: URLSessionConfiguration) {
guard let url = URL(string: baseUrl) else {
fatalError("Incorrect base url provided for ZoteroApiClient")
}

self.url = url
self.manager = Alamofire.Session(configuration: configuration, delegate: SessionDelegate())
self.manager = Alamofire.Session(configuration: configuration, delegate: sessionDelegate)
}

func set(authToken: String?) {
Expand Down Expand Up @@ -193,3 +199,124 @@ extension ResponseHeaders {
return (self.value(forCaseInsensitive: "last-modified-version") as? String).flatMap(Int.init) ?? 0
}
}

/// Delegates URLSession challenges to handle server trust validation for WebDAV connections.
///
/// This delegate intercepts server trust challenges and allows the application to implement
/// certificate pinning for WebDAV servers with self-signed or untrusted certificates.
///
/// **Certificate Trust Flow:**
/// 1. Server presents certificate during TLS handshake
/// 2. System finds certificate untrusted (not in system trust store)
/// 3. URLSession calls this delegate with server trust challenge
/// 4. Delegate invokes `onTrustChallenge` callback (typically shows UI to user)
/// 5. User decides whether to trust the certificate
/// 6. If trusted, certificate is pinned for future validation
/// 7. Connection proceeds with accepted credential
///
/// **Thread Safety (@unchecked Sendable):**
/// This class is marked as @unchecked Sendable because:
/// - Inherits from Alamofire's SessionDelegate which manages internal synchronization
/// - `onTrustChallenge` closure access is protected by NSLock for thread-safe reads/writes
/// - URLSession guarantees delegate methods are called serially (not concurrently)
/// - Challenge timeout uses thread-safe DispatchQueue.global() + NSLock
/// - Completion handlers are designed for concurrent invocation (with lock protection)
///
/// **Timeout Protection:** 60-second timeout prevents indefinite hangs if UI doesn't respond.
class ZoteroSessionDelegate: SessionDelegate, @unchecked Sendable {
private var _onTrustChallenge: ((SecTrust, String, @escaping (Bool) -> Void) -> Void)?
private let trustChallengeLock = NSLock()
private let challengeTimeout: TimeInterval = 60.0

var onTrustChallenge: ((SecTrust, String, @escaping (Bool) -> Void) -> Void)? {
get {
trustChallengeLock.lock()
defer { trustChallengeLock.unlock() }
return _onTrustChallenge
}
set {
trustChallengeLock.lock()
defer { trustChallengeLock.unlock() }
_onTrustChallenge = newValue
}
}

// SESSION-LEVEL CHALLENGE: Handle challenges at the session level
// Some servers trigger session-level challenges before task-level challenges
// Note: Not overriding - implementing URLSessionDelegate protocol method directly
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust,
let host = challenge.protectionSpace.host as String?,
onTrustChallenge != nil {
handleTrustChallenge(trust: trust, host: host, completionHandler: completionHandler)
return
}

completionHandler(.performDefaultHandling, nil)
}

// TASK-LEVEL CHALLENGE: Handle challenges at the task level
// This is called for authentication challenges and is the primary entry point for certificate validation
override func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
// CERTIFICATE PINNING ENTRY POINT: Intercept server trust challenges
// This is the first point where we can validate server certificates
// before establishing the connection
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust,
let host = challenge.protectionSpace.host as String?,
onTrustChallenge != nil {
// Call the handler - it will decide whether to handle this host or reject
handleTrustChallenge(trust: trust, host: host, completionHandler: completionHandler)
return
}

// DELEGATE FORWARDING: Use default handling for all other cases
// This includes non-trust challenges and trust challenges when no handler is set
completionHandler(.performDefaultHandling, nil)
}

// SHARED CHALLENGE HANDLER: Common logic for both session and task-level challenges
// Handles certificate trust with timeout protection and user callback
// The handler itself decides whether to handle this host or reject
private func handleTrustChallenge(trust: SecTrust, host: String, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let onTrustChallenge = onTrustChallenge else {
completionHandler(.performDefaultHandling, nil)
return
}

var completed = false
let lock = NSLock()

// TIMEOUT PROTECTION: Prevent indefinite hangs if UI doesn't respond
// After 60 seconds, automatically reject the challenge to prevent resource leaks
DispatchQueue.global().asyncAfter(deadline: .now() + challengeTimeout) {
lock.lock()
defer { lock.unlock() }
if !completed {
completed = true
DDLogWarn("ZoteroSessionDelegate: trust challenge timed out for \(host)")
completionHandler(.cancelAuthenticationChallenge, nil)
}
}

// CALLBACK TO UI: Let application decide whether to trust this certificate
// The handler can reject by returning false if the host doesn't match its configuration
onTrustChallenge(trust, host) { shouldTrust in
lock.lock()
defer { lock.unlock() }
guard !completed else { return }
completed = true

if shouldTrust {
// HANDLER ACCEPTED: Certificate will be pinned by WebDavController
// Return credential to proceed with connection
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
// HANDLER REJECTED: Fall back to default handling
// This happens when host doesn't match WebDAV configuration or user rejected
completionHandler(.performDefaultHandling, nil)
}
}
}
}
Loading