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",