diff --git a/Tests/KeystoneTests/Tests/Login/URL+InsecureConnectionTests.swift b/Tests/KeystoneTests/Tests/Login/URL+InsecureConnectionTests.swift new file mode 100644 index 000000000000..841eba2a3ecb --- /dev/null +++ b/Tests/KeystoneTests/Tests/Login/URL+InsecureConnectionTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing + +@testable import WordPress + +struct URLInsecureConnectionTests { + @Test(arguments: [ + "https://example.com", + "https://example.com:8443/wp-json", + "HTTPS://EXAMPLE.COM" + ]) + func secureURLs(_ string: String) throws { + let url = try #require(URL(string: string)) + #expect(!url.isInsecureConnection) + } + + @Test(arguments: [ + "http://example.com", + "HTTP://EXAMPLE.COM", + "http://example.com:8080/wp-json", + "http://mymac.local", + "http://mysite.test", + "http://192.168.1.10:8881" + ]) + func insecureURLs(_ string: String) throws { + let url = try #require(URL(string: string)) + #expect(url.isInsecureConnection) + } + + @Test(arguments: [ + "http://localhost", + "http://localhost:8881/wp-admin", + "http://LOCALHOST:8881", + "http://127.0.0.1:8881", + "http://[::1]:8881" + ]) + func loopbackURLsAreExempt(_ string: String) throws { + let url = try #require(URL(string: string)) + #expect(!url.isInsecureConnection) + } +} diff --git a/Tests/KeystoneTests/Tests/Utility/ApplicationPasswordsRepositoryTests.swift b/Tests/KeystoneTests/Tests/Utility/ApplicationPasswordsRepositoryTests.swift index b621bbc9ee1d..292627c200eb 100644 --- a/Tests/KeystoneTests/Tests/Utility/ApplicationPasswordsRepositoryTests.swift +++ b/Tests/KeystoneTests/Tests/Utility/ApplicationPasswordsRepositoryTests.swift @@ -386,6 +386,146 @@ class ApplicationPasswordsRepositoryTests { let password = await password(of: blog) #expect(password == uuid) } + + @Test + func insecureSiteDoesNotTransmitCredentials() async throws { + defer { HTTPStubs.removeAllStubs() } + + let host = "insecure.example.com" + stub(condition: isHost(host)) { _ in + Issue.record("No request should be sent to an insecure site") + return HTTPStubsResponse(error: URLError(.notConnectedToInternet)) + } + + let blog = try await coreDataStack.performAndSave { context in + let blog = Blog(context: context) + blog.url = "http://\(host)" + blog.xmlrpc = "http://\(host)/xmlrpc.php" + blog.username = "demo" + blog.password = "pass" + return TaggedManagedObjectID(blog) + } + + let repository = ApplicationPasswordRepository.forTesting(coreDataStack: coreDataStack, keychain: keychain) + await #expect(throws: ApplicationPasswordRepositoryError.insecureConnection) { + try await repository.createPasswordIfNeeded(for: blog) + } + } + + @Test + func insecureRestApiRootDoesNotTransmitCredentials() async throws { + defer { HTTPStubs.removeAllStubs() } + + let secureHost = "secure.example.com" + let insecureHost = "insecure-root.example.com" + stub(condition: isHost(secureHost) || isHost(insecureHost)) { _ in + Issue.record("No request should be sent when a credential destination is insecure") + return HTTPStubsResponse(error: URLError(.notConnectedToInternet)) + } + + let blog = try await coreDataStack.performAndSave { context in + let blog = Blog(context: context) + blog.url = "https://\(secureHost)" + blog.xmlrpc = "https://\(secureHost)/xmlrpc.php" + blog.restApiRootURL = "http://\(insecureHost)/wp-json" + blog.username = "demo" + blog.password = "pass" + return TaggedManagedObjectID(blog) + } + + let repository = ApplicationPasswordRepository.forTesting(coreDataStack: coreDataStack, keychain: keychain) + await #expect(throws: ApplicationPasswordRepositoryError.insecureConnection) { + try await repository.createPasswordIfNeeded(for: blog) + } + } + + @Test + func insecureXMLRPCDerivedDestinationDoesNotTransmitCredentials() async throws { + defer { HTTPStubs.removeAllStubs() } + + // The REST base, login_url, and admin_url are derived from the http xmlrpc endpoint, so + // credentials must not be sent even though the site URL itself is https. + let insecureHost = "insecure-xmlrpc.example.com" + stub(condition: isHost(insecureHost)) { _ in + Issue.record("No request should be sent to an xmlrpc-derived insecure destination") + return HTTPStubsResponse(error: URLError(.notConnectedToInternet)) + } + + let blog = try await coreDataStack.performAndSave { context in + let blog = Blog(context: context) + blog.url = "https://secure-site.example.com" + blog.xmlrpc = "http://\(insecureHost)/xmlrpc.php" + blog.username = "demo" + blog.password = "pass" + return TaggedManagedObjectID(blog) + } + + let repository = ApplicationPasswordRepository.forTesting(coreDataStack: coreDataStack, keychain: keychain) + await #expect(throws: ApplicationPasswordRepositoryError.insecureConnection) { + try await repository.createPasswordIfNeeded(for: blog) + } + } + + @Test + func discoveredInsecureRestApiRootDoesNotTransmitCredentials() async throws { + defer { HTTPStubs.removeAllStubs() } + + let host = "discovered-insecure.example.com" + let blog = try await coreDataStack.performAndSave { context in + let blog = Blog(context: context) + blog.url = "https://\(host)" + blog.xmlrpc = "https://\(host)/xmlrpc.php" + blog.username = "demo" + blog.password = "pass" + return TaggedManagedObjectID(blog) + } + + // The blog has no stored REST root, so discovery resolves one. Discovery advertises an http + // root, and no credential-bearing request may follow. + stubApiDiscoveryWithInsecureRoot(siteHost: host) + stub( + condition: isPath("/wp-json/wp/v2/users/me") + || isPath("/wp-json/wp/v2/users/me/application-passwords") + ) { _ in + Issue.record("No credentials should be sent to a discovered insecure REST root") + return HTTPStubsResponse(error: URLError(.notConnectedToInternet)) + } + + let repository = ApplicationPasswordRepository.forTesting(coreDataStack: coreDataStack, keychain: keychain) + await #expect(throws: ApplicationPasswordRepositoryError.insecureConnection) { + try await repository.createPasswordIfNeeded(for: blog) + } + + // The rejected http root must not be persisted, or other consumers could later use it. + let storedRoot = await coreDataStack.performQuery { context in + (try? context.existingObject(with: blog))?.restApiRootURL + } + #expect(storedRoot == nil) + } + + @Test + func loopbackHttpSiteCreatesPassword() async throws { + defer { HTTPStubs.removeAllStubs() } + + let blog = try await coreDataStack.performAndSave { context in + let blog = Blog(context: context) + blog.url = "http://localhost:8881" + blog.xmlrpc = "http://localhost:8881/xmlrpc.php" + blog.username = "demo" + blog.password = "pass" + return TaggedManagedObjectID(blog) + } + + stubApiDiscovery(siteHost: "localhost") + stubSelfHostedSiteWpV2GetUser() + stubSelfHostedSiteCreateApplicationPassword(host: "localhost", password: "abcd efgh") + + let repository = ApplicationPasswordRepository.forTesting(coreDataStack: coreDataStack, keychain: keychain) + try await repository.createPasswordIfNeeded(for: blog) + + let password = await password(of: blog) + #expect(password == "abcd efgh") + } } // MARK: - Helpers @@ -758,6 +898,39 @@ private extension ApplicationPasswordsRepositoryTests { } } + func stubApiDiscoveryWithInsecureRoot(siteHost: String) { + stub(condition: isHost(siteHost) && isPath("/")) { _ in + HTTPStubsResponse( + data: "homepage".data(using: .utf8)!, + statusCode: 200, + headers: ["Link": "; rel=\"https://api.w.org/\""] + ) + } + stub(condition: isHost(siteHost) && isPath("/wp-json")) { _ in + let json = """ + { + "name": "Site", + "description": "", + "url": "http://\(siteHost)", + "home": "http://\(siteHost)", + "gmt_offset": "0", + "timezone_string": "", + "namespaces": ["wp/v2"], + "authentication": { + "application-passwords": { + "endpoints": { + "authorization": "http://\(siteHost)/wp-admin/authorize-application.php" + } + } + }, + "routes": {}, + "_links": {} + } + """ + return HTTPStubsResponse(data: json.data(using: .utf8)!, statusCode: 200, headers: nil) + } + } + func stubApiDiscoveryFailure(siteHost: String) { stub(condition: isHost(siteHost) && isPath("/")) { _ in HTTPStubsResponse(data: "homepage".data(using: .utf8)!, statusCode: 200, headers: nil) diff --git a/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift b/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift index 7e0029a0c859..c961065c7362 100644 --- a/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift +++ b/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift @@ -10,6 +10,7 @@ import WordPressShared import BuildSettingsKit import SVProgressHUD import WordPressSharedUI +import WordPressUI struct SelfHostedSiteAuthenticator { @@ -193,7 +194,12 @@ struct SelfHostedSiteAuthenticator { { credentials = parsed } else { - credentials = try await authenticate(details: details, from: viewController) + let authenticated = try await authenticate(details: details, from: viewController) + credentials = WpApiApplicationPasswordDetails( + siteUrl: details.sanitizedSiteUrl(authenticated.siteUrl), + userLogin: authenticated.userLogin, + password: authenticated.password + ) } let apiRootURL = details.apiRootUrl.asURL() @@ -211,6 +217,31 @@ struct SelfHostedSiteAuthenticator { } } + @MainActor + private func confirmInsecureConnection(host: String, from viewController: UIViewController) async -> Bool { + await withCheckedContinuation { continuation in + let alert = UIAlertController( + title: Strings.insecureConnectionTitle, + message: Strings.insecureConnectionMessage(host: host), + preferredStyle: .alert + ) + alert.addAction( + UIAlertAction(title: SharedStrings.Button.cancel, style: .cancel) { _ in + continuation.resume(returning: false) + } + ) + alert.addAction( + UIAlertAction(title: Strings.insecureConnectionContinue, style: .destructive) { _ in + continuation.resume(returning: true) + } + ) + // The sign-in entry points hand us a controller that is already presenting the SwiftUI + // login flow, so present from the topmost controller to avoid a no-op present that would + // leave the continuation suspended forever. + viewController.topmostPresentedViewController.present(alert, animated: true) + } + } + @MainActor private func authenticate( details: AutoDiscoveryAttemptSuccess, @@ -228,6 +259,14 @@ struct SelfHostedSiteAuthenticator { throw .authentication(failure) } + if let insecureDestination = details.insecureURL { + let host = insecureDestination.host(percentEncoded: false) ?? details.parsedSiteUrl.url() + let proceed = await confirmInsecureConnection(host: host, from: viewController) + guard proceed else { + throw .cancelled + } + } + let appId = Self.wordPressAppId let appName = Self.wordPressAppName @@ -679,3 +718,62 @@ private final class EmptyAppNotifier: WpAppNotifier { // Do nothing. } } + +private extension AutoDiscoveryAttemptSuccess { + /// The first pre-authorization destination that would receive credentials over an unencrypted + /// connection, or nil when the whole flow is secure. + /// + /// The API root is included because discovery takes it verbatim from the site's Link header, + /// and a misconfigured https site can advertise an http API root that would receive the + /// application password. + var insecureURL: URL? { + var destinations = [parsedSiteUrl.asURL(), apiRootUrl.asURL()] + if case let .applicationPasswords(authUrl) = authentication { + destinations.append(authUrl.asURL()) + } + return destinations.first(where: \.isInsecureConnection) + } + + /// Sanitizes the site URL returned by the authorization callback before it is persisted as the + /// blog URL and used for XML-RPC discovery. + /// + /// When the pre-authorization flow was fully secure (the user was never warned), an http value + /// here is site misconfiguration and must not silently downgrade later traffic, so its scheme is + /// upgraded to https. When the user consented to an insecure flow, the value is left alone. + func sanitizedSiteUrl(_ callbackSiteUrl: String) -> String { + guard insecureURL == nil, + let url = URL(string: callbackSiteUrl), + url.isInsecureConnection, + var components = URLComponents(string: callbackSiteUrl) + else { + return callbackSiteUrl + } + components.scheme = "https" + return components.string ?? callbackSiteUrl + } +} + +private enum Strings { + static let insecureConnectionTitle = NSLocalizedString( + "addSite.selfHosted.insecureConnectionAlert.title", + value: "This site doesn't use a secure connection", + comment: "Title of an alert warning the user that the self-hosted site uses an unencrypted HTTP connection" + ) + + static func insecureConnectionMessage(host: String) -> String { + let format = NSLocalizedString( + "addSite.selfHosted.insecureConnectionAlert.message", + value: + "%@ uses HTTP, which is not encrypted. Your username, password, and site data could be seen by others on the network. Do you want to continue?", + comment: + "Message of an alert warning the user that the self-hosted site uses an unencrypted HTTP connection. The first argument is the site's host name." + ) + return String(format: format, host) + } + + static let insecureConnectionContinue = NSLocalizedString( + "addSite.selfHosted.insecureConnectionAlert.continue", + value: "Continue Anyway", + comment: "Button to proceed with signing in to a self-hosted site over an unencrypted HTTP connection" + ) +} diff --git a/WordPress/Classes/Login/URL+InsecureConnection.swift b/WordPress/Classes/Login/URL+InsecureConnection.swift new file mode 100644 index 000000000000..3879dc6e517b --- /dev/null +++ b/WordPress/Classes/Login/URL+InsecureConnection.swift @@ -0,0 +1,21 @@ +import Foundation + +extension URL { + private static let loopbackHosts: Set = ["localhost", "127.0.0.1", "::1"] + + /// Whether sending credentials to this URL would use an unencrypted connection to a remote host. + /// + /// Only loopback destinations are exempt. Names like `*.local` (resolved over the LAN via mDNS) + /// and `*.test` (resolved by whatever DNS the network provides) do not guarantee a local + /// connection, so they are treated the same as any other remote host. + var isInsecureConnection: Bool { + guard scheme?.lowercased() == "http" else { + return false + } + guard let host = host(percentEncoded: false)?.lowercased() else { + // A scheme of "http" with no parseable host cannot be proven local. Treat it as insecure. + return true + } + return !Self.loopbackHosts.contains(host) + } +} diff --git a/WordPress/Classes/Services/ApplicationPasswordRepository.swift b/WordPress/Classes/Services/ApplicationPasswordRepository.swift index 829fcefd0c72..6b7237c8a7fb 100644 --- a/WordPress/Classes/Services/ApplicationPasswordRepository.swift +++ b/WordPress/Classes/Services/ApplicationPasswordRepository.swift @@ -89,11 +89,26 @@ actor ApplicationPasswordRepository { /// When returning true, a valid application password is guaranteed to be returned by the `Blog.getApplicationToken` function. /// + /// Non-loopback http destinations are rejected up front: this function must never transmit + /// credentials over an unencrypted connection on its own. Getting an application password for + /// such sites goes through the interactive sign-in flow, which shows an insecure-connection + /// warning. Checking the site URL alone is not enough: the REST root, `login_url`, and + /// `admin_url` can each use http independently, so every statically-known credential destination + /// is validated here, and the REST root discovered at runtime is validated in + /// `updateRestAPIURLIfNeeded`. + /// /// This function is safe to call multiple times, but every call performs real work, including /// HTTP requests (password validation, and REST API root rediscovery when the stored root is /// stale). Limit calls to once per "site launch" (app launch, switching site, etc.) to avoid /// that unnecessary work. func createPasswordIfNeeded(for blogId: TaggedManagedObjectID) async throws { + let destinations = try await coreDataStack.performQuery { context in + try context.existingObject(with: blogId).credentialDestinations() + } + if destinations.contains(where: \.isInsecureConnection) { + throw ApplicationPasswordRepositoryError.insecureConnection + } + if let _ = try await validatePasswords(in: blogId) { return } @@ -385,6 +400,13 @@ private extension ApplicationPasswordRepository { throw error } + // Discovery can resolve an http REST root even for an https site (e.g. an advertised http + // API root). Reject it before persisting, so an insecure value is never stored where other + // consumers (WordPressSite, EditorConfiguration, ...) could later send credentials to it. + if let url = URL(string: apiRootURL.url()), url.isInsecureConnection { + throw ApplicationPasswordRepositoryError.insecureConnection + } + if apiRootURL.url() != restApiRootUrl { try await coreDataStack.performAndSave { context in let blog = try context.existingObject(with: blogId) @@ -446,6 +468,28 @@ private extension Blog { } return owners } + + /// The statically-known URLs this repository may send credentials to when creating or validating + /// an application password on its own. The REST base, `login_url`, and `admin_url` derive from + /// `xmlrpc` (or their own options), so each can use http independently of the site URL, and all + /// must be secure before any credential-bearing request is made. The REST API root discovered at + /// runtime is validated separately in `updateRestAPIURLIfNeeded`. + func credentialDestinations() throws -> [URL] { + var destinations: [URL] = [try getUrl()] + if let restApiRootURL, let parsed = URL(string: restApiRootURL) { + destinations.append(parsed) + } + if let restBase = url(withPath: "wp-json/"), let parsed = URL(string: restBase) { + destinations.append(parsed) + } + if let loginURL { + destinations.append(loginURL) + } + if let adminURL = makeAdminURL() { + destinations.append(adminURL) + } + return destinations + } } // Since all application passwords are saved in one entry, it's very easy to overwrite them when multiple writes happen at the same time. @@ -506,6 +550,7 @@ extension ApplicationPasswordStorage { enum ApplicationPasswordRepositoryError: LocalizedError { case usernameNotFound case restApiInaccessible + case insecureConnection case unknown var errorDescription: String? { @@ -516,6 +561,13 @@ enum ApplicationPasswordRepositoryError: LocalizedError { value: "Unable to find username for the site", comment: "Error message when the username cannot be found for application password creation" ) + case .insecureConnection: + return NSLocalizedString( + "applicationPasswordRepository.error.insecureConnection", + value: "The site uses an unencrypted connection (HTTP).", + comment: + "Error message when application password creation is skipped because the site uses an insecure HTTP connection" + ) case .restApiInaccessible: return NSLocalizedString( "applicationPasswordRepository.error.restApiInaccessible",