From ebc16167ce1388fe5184f13fa8e11d6bfe9d767f Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 5 Aug 2026 19:45:10 +1200 Subject: [PATCH 1/3] Add insecure connection predicate for self-hosted sign-in --- .../Login/URL+InsecureConnectionTests.swift | 41 +++++++++++++++++++ .../Login/URL+InsecureConnection.swift | 21 ++++++++++ 2 files changed, 62 insertions(+) create mode 100644 Tests/KeystoneTests/Tests/Login/URL+InsecureConnectionTests.swift create mode 100644 WordPress/Classes/Login/URL+InsecureConnection.swift 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/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) + } +} From 69a1d6e7835d3bbb4b3d0686f489fe7530a68e02 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 5 Aug 2026 19:45:10 +1200 Subject: [PATCH 2/3] Warn before self-hosted sign-in over an insecure connection Present a confirmation alert at the top of the authenticate choke point when any pre-authorization credential destination (the site URL, REST API root, or application-password authorization URL) uses non-loopback http. Cancel reuses the existing SignInError.cancelled, and the debug launch-argument path never reaches this gate. The alert is presented from the topmost controller because the sign-in entry points already present the SwiftUI login flow. When the pre-authorization flow was fully secure, coerce an unexpectedly-http callback site URL to https, and skip the sign-in-time XML-RPC options fetch if discovery resolves an insecure endpoint, so a site that proved secure end-to-end never has its credentials sent over an unencrypted connection. --- .../Login/SelfHostedSiteAuthenticator.swift | 100 +++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) 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" + ) +} From 8b44c1bc55377c9e418bbfb78b07fec54e6f90e6 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 5 Aug 2026 19:45:10 +1200 Subject: [PATCH 3/3] Skip automatic application password creation for insecure sites ApplicationPasswordRepository must never transmit credentials to a non-loopback http destination on its own. createPasswordIfNeeded now throws a new insecureConnection error before any network activity when any statically-known credential destination is insecure: the site URL, the stored REST API root, the xmlrpc-derived wp-json base, login_url, or admin_url, each of which can use http independently of the others. The REST API root resolved by discovery is validated before it is persisted, so an insecure value is never written to Blog.restApiRootURL where other consumers could later send credentials to it. Getting an application password for such a site goes through the interactive sign-in flow, which shows the insecure-connection warning; the existing repository callers already catch the error and degrade gracefully. --- .../ApplicationPasswordsRepositoryTests.swift | 173 ++++++++++++++++++ .../ApplicationPasswordRepository.swift | 52 ++++++ 2 files changed, 225 insertions(+) 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/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",