diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt index 4901c6da4..3f57f69df 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.wordpress.gutenberg.model.EditorAuthorizationScope import org.wordpress.gutenberg.model.EditorConfiguration import java.io.File import java.net.HttpURLConnection @@ -48,8 +49,9 @@ class EditorAssetsLibrary( val defaultUserAgent = System.getProperty("http.agent") ?: "" connection.setRequestProperty("User-Agent", "$defaultUserAgent GutenbergKit/${GutenbergKitVersion.VERSION}") - // Set headers from configuration - if (configuration.authHeader.isNotEmpty()) { + // Set headers from configuration. The site's credentials go only where they + // may: a custom endpoint can be on another party's host. + if (configuration.authHeader.isNotEmpty() && EditorAuthorizationScope(configuration).allows(endpoint)) { connection.setRequestProperty("Authorization", configuration.authHeader) } diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt index d015ec94b..905f297fd 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt @@ -10,6 +10,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import org.wordpress.gutenberg.model.EditorAuthorizationScope import org.wordpress.gutenberg.model.http.EditorHTTPHeaders import org.wordpress.gutenberg.model.http.EditorHttpMethod import java.io.File @@ -120,10 +121,15 @@ sealed class EditorHTTPClientError : Exception() { * An HTTP client for making authenticated requests to the WordPress REST API. * * This class handles request signing, error parsing, and response validation. - * All requests are automatically authenticated using the provided authorization header. + * A request within the client's [EditorAuthorizationScope] is authenticated using the provided + * authorization header. A request anywhere else — an asset on another party's host, say — goes + * out without it. + * + * @param authorizationScope The requests that carry [authHeader]. */ class EditorHTTPClient( private val authHeader: String, + private val authorizationScope: EditorAuthorizationScope, private val delegate: EditorHTTPClientDelegate? = null, private val requestTimeoutSeconds: Long = 60, okHttpClient: OkHttpClient? = null @@ -139,11 +145,19 @@ class EditorHTTPClient( .writeTimeout(requestTimeoutSeconds, TimeUnit.SECONDS) .build() + /** + * Adds the site's credentials to a request for [url], if they may go there. The site's + * credentials are for the site, and a request can be for anywhere: an editor downloads assets + * from whichever hosts the site names. + */ + private fun Request.Builder.authorize(url: String): Request.Builder = + if (authorizationScope.allows(url)) addHeader("Authorization", authHeader) else this + override suspend fun download(url: String, destination: File): EditorHTTPClientDownloadResponse = withContext(Dispatchers.IO) { val request = Request.Builder() .url(url) - .addHeader("Authorization", authHeader) + .authorize(url) .get() .build() @@ -186,7 +200,7 @@ class EditorHTTPClient( val request = Request.Builder() .url(url) - .addHeader("Authorization", authHeader) + .authorize(url) .method(method.toString(), requestBody) .build() diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorAuthorizationScope.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorAuthorizationScope.kt new file mode 100644 index 000000000..3aa3e86b8 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorAuthorizationScope.kt @@ -0,0 +1,102 @@ +package org.wordpress.gutenberg.model + +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** + * The requests that may carry a site's credentials. + * + * Credentials go to the site and to its REST API, and nowhere else. An editor also downloads + * plugin and theme assets from whichever hosts the site names, and one of those can be a third + * party's — a script on a vendor's CDN, say — which has no business receiving them. + * + * An app that knows of other places its credentials belong names them: a site reached through + * WordPress.com has assets on `wp.com` and files on `files.wordpress.com`, run by the same party + * as its API. See [EditorConfiguration.Builder.setAuthHeaderDomains] for how they're named. + * + * @param siteURL The site's address. + * @param siteApiRoot The root of the site's REST API. + * @param domains Other places that may receive the site's credentials, over HTTPS only. A name is + * one host, exactly: `s0.wp.com`. A name that starts with `*.` is the domain that follows and + * every subdomain of it: `*.wp.com`. A name that isn't one of the two is ignored. + */ +class EditorAuthorizationScope( + siteURL: String, + siteApiRoot: String, + domains: Collection = emptySet() +) { + + /** Creates the scope for the site in [configuration]. */ + constructor(configuration: EditorConfiguration) : this( + siteURL = configuration.siteURL, + siteApiRoot = configuration.siteApiRoot, + domains = configuration.authHeaderDomains + ) + + /** The origins that may receive credentials: the site's, and its REST API's. */ + private val origins: Set = setOfNotNull(Origin.of(siteURL), Origin.of(siteApiRoot)) + + private val names: List = domains.mapNotNull(Name::of) + + /** The hosts that may receive credentials over HTTPS, each named in full. */ + private val hosts: Set = names.filterIsInstance().map { it.host }.toSet() + + /** + * The domains that may receive credentials over HTTPS along with their every subdomain: + * `wp.com` for `*.wp.com`. + */ + private val wildcardDomains: Set = + names.filterIsInstance().map { it.domain }.toSet() + + /** Whether a request to [url] may carry the site's credentials. */ + fun allows(url: String): Boolean { + val origin = Origin.of(url) ?: return false + + if (origin in origins) { + return true + } + + return origin.scheme == "https" && + (origin.host in hosts || wildcardDomains.any { origin.host == it || origin.host.endsWith(".$it") }) + } + + /** What an app names as a place for the site's credentials. */ + private sealed interface Name { + /** One host, exactly. */ + data class Host(val host: String) : Name + + /** A domain and every subdomain of it. */ + data class DomainAndSubdomains(val domain: String) : Name + + companion object { + private const val WILDCARD = "*." + + /** + * `null` for a name that is neither: an empty one, or one with a wildcard anywhere + * but in front. What a wildcard is over is the app's business: `*.com` is every + * `.com` site. + */ + fun of(name: String): Name? { + // Without the dot that ends a fully qualified name + val trimmed = name.trim().lowercase().removeSuffix(".") + val isWildcard = trimmed.startsWith(WILDCARD) + val domain = trimmed.removePrefix(WILDCARD) + val labels = domain.split(".") + + return when { + labels.any { it.isEmpty() || it.contains("*") } -> null + isWildcard -> DomainAndSubdomains(domain) + else -> Host(domain) + } + } + } + } + + /** A URL's scheme, host and port: what has to match for two URLs to be the same place. */ + private data class Origin(val scheme: String, val host: String, val port: Int) { + companion object { + /** `null` for anything but an absolute `http` or `https` URL. */ + fun of(url: String): Origin? = + url.toHttpUrlOrNull()?.let { Origin(scheme = it.scheme, host = it.host, port = it.port) } + } + } +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 054a219ab..218d6e8d7 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -27,6 +27,13 @@ data class EditorConfiguration( val cookies: Map, val enableAssetCaching: Boolean = false, val cachedAssetHosts: Set = emptySet(), + /** + * Places that [authHeader] may be sent to over HTTPS, besides the site and its API, which + * always receive it. A name is one host, exactly: `s0.wp.com`. A name that starts with `*.` + * is the domain that follows and every subdomain of it: `*.wp.com`. See + * [Builder.setAuthHeaderDomains]. + */ + val authHeaderDomains: Set = emptySet(), val editorAssetsEndpoint: String? = null, val enableNetworkLogging: Boolean = false, var enableOfflineMode: Boolean = false, @@ -85,6 +92,7 @@ data class EditorConfiguration( private var cookies: Map = mapOf() private var enableAssetCaching: Boolean = false private var cachedAssetHosts: Set = emptySet() + private var authHeaderDomains: Set = emptySet() private var editorAssetsEndpoint: String? = null private var enableNetworkLogging: Boolean = false private var enableOfflineMode: Boolean = false @@ -104,6 +112,24 @@ data class EditorConfiguration( fun setSiteApiNamespace(siteApiNamespace: Array) = apply { this.siteApiNamespace = siteApiNamespace } fun setNamespaceExcludedPaths(namespaceExcludedPaths: Array) = apply { this.namespaceExcludedPaths = namespaceExcludedPaths } fun setAuthHeader(authHeader: String) = apply { this.authHeader = authHeader } + + /** + * Sets the places the auth header may be sent to, besides the site and its API, which + * always receive it. Only requests over HTTPS qualify. + * + * Each name is taken exactly as written: + * + * - `s0.wp.com` is that one host. It isn't its subdomains, and it isn't `s1.wp.com`. + * - `*.wp.com` is `wp.com` and every subdomain of it, however deep. + * + * A wildcard is only ever the whole first label, and is taken at its word: `*.com` is + * every `.com` site. Name the narrowest domain that will do. + * + * A site reached through WordPress.com is served from more than its own address — its + * assets from `wp.com` and its files from `files.wordpress.com`, say — and can name those + * here: `setOf("*.wp.com", "*.files.wordpress.com")`. + */ + fun setAuthHeaderDomains(authHeaderDomains: Set) = apply { this.authHeaderDomains = authHeaderDomains } fun setEditorSettings(editorSettings: String?) = apply { this.editorSettings = editorSettings } /** * Stores [locale] verbatim without running the resolver. Reserved for @@ -168,6 +194,7 @@ data class EditorConfiguration( cookies = cookies, enableAssetCaching = enableAssetCaching, cachedAssetHosts = cachedAssetHosts, + authHeaderDomains = authHeaderDomains, editorAssetsEndpoint = editorAssetsEndpoint, enableNetworkLogging = enableNetworkLogging, enableOfflineMode = enableOfflineMode, @@ -197,6 +224,7 @@ data class EditorConfiguration( .setCookies(cookies) .setEnableAssetCaching(enableAssetCaching) .setCachedAssetHosts(cachedAssetHosts) + .setAuthHeaderDomains(authHeaderDomains) .setEditorAssetsEndpoint(editorAssetsEndpoint) .setEnableNetworkLogging(enableNetworkLogging) .setEnableOfflineMode(enableOfflineMode) @@ -227,6 +255,7 @@ data class EditorConfiguration( if (cookies != other.cookies) return false if (enableAssetCaching != other.enableAssetCaching) return false if (cachedAssetHosts != other.cachedAssetHosts) return false + if (authHeaderDomains != other.authHeaderDomains) return false if (editorAssetsEndpoint != other.editorAssetsEndpoint) return false if (enableNetworkLogging != other.enableNetworkLogging) return false if (enableOfflineMode != other.enableOfflineMode) return false @@ -256,6 +285,7 @@ data class EditorConfiguration( result = 31 * result + cookies.hashCode() result = 31 * result + enableAssetCaching.hashCode() result = 31 * result + cachedAssetHosts.hashCode() + result = 31 * result + authHeaderDomains.hashCode() result = 31 * result + (editorAssetsEndpoint?.hashCode() ?: 0) result = 31 * result + enableNetworkLogging.hashCode() result = 31 * result + enableOfflineMode.hashCode() diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt index 76c051dbc..d36c37474 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt @@ -42,6 +42,8 @@ data class GBKitGlobal( val namespaceExcludedPaths: List, /** The authorization header value for authenticated API requests. */ val authHeader: String, + /** Domains that [authHeader] may be sent to, besides the site and its API. */ + val authHeaderDomains: List, /** Whether to apply theme styles to the editor. */ val themeStyles: Boolean, /** Whether to load plugin assets. */ @@ -115,6 +117,7 @@ data class GBKitGlobal( siteApiNamespace = configuration.siteApiNamespace.toList(), namespaceExcludedPaths = configuration.namespaceExcludedPaths.toList(), authHeader = configuration.authHeader, + authHeaderDomains = configuration.authHeaderDomains.toList(), themeStyles = configuration.themeStyles, plugins = configuration.plugins, enableNativeBlockInserter = configuration.enableNativeBlockInserter, diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt index 283d3990d..c4cae83c4 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt @@ -12,6 +12,7 @@ import org.wordpress.gutenberg.EditorHTTPClientProtocol import org.wordpress.gutenberg.Paths import org.wordpress.gutenberg.RESTAPIRepository import org.wordpress.gutenberg.model.EditorAssetBundle +import org.wordpress.gutenberg.model.EditorAuthorizationScope import org.wordpress.gutenberg.model.EditorCachePolicy import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies @@ -95,7 +96,10 @@ class EditorService( tempStorageRoot: File? = null, cacheRoot: File? = null ): EditorService { - val client = httpClient ?: EditorHTTPClient(authHeader = configuration.authHeader) + val client = httpClient ?: EditorHTTPClient( + authHeader = configuration.authHeader, + authorizationScope = EditorAuthorizationScope(configuration) + ) val restRepository = RESTAPIRepository( configuration = configuration, diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsManifestAuthorizationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsManifestAuthorizationTest.kt new file mode 100644 index 000000000..e550a2735 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsManifestAuthorizationTest.kt @@ -0,0 +1,68 @@ +package org.wordpress.gutenberg + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wordpress.gutenberg.model.EditorConfiguration + +/** + * The manifest request of [org.wordpress.gutenberg.EditorAssetsLibrary] — the one in this + * package, which makes its own connection rather than going through [EditorHTTPClient] — sends + * the site's credentials to the site, and not to a custom endpoint on another party's host. + */ +@RunWith(RobolectricTestRunner::class) +class EditorAssetsManifestAuthorizationTest { + + private lateinit var mockWebServer: MockWebServer + private lateinit var baseUrl: String + + companion object { + private const val TEST_AUTH_HEADER = "Bearer test-token-12345" + } + + @Before + fun setUp() { + mockWebServer = MockWebServer() + mockWebServer.start() + baseUrl = mockWebServer.url("/").toString() + } + + @After + fun tearDown() { + mockWebServer.shutdown() + } + + private fun makeLibrary(configuration: EditorConfiguration.Builder) = EditorAssetsLibrary( + RuntimeEnvironment.getApplication(), + configuration.setAuthHeader(TEST_AUTH_HEADER).build() + ) + + @Test + fun `the manifest request sends the Authorization header to the site's API`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + makeLibrary(EditorConfiguration.builder(baseUrl, baseUrl)).loadManifestContent() + + assertEquals(TEST_AUTH_HEADER, mockWebServer.takeRequest().getHeader("Authorization")) + } + + @Test + fun `the manifest request sends no Authorization header to an endpoint on another party's host`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + makeLibrary( + EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/") + .setEditorAssetsEndpoint("${baseUrl}editor-assets") + ).loadManifestContent() + + assertNull(mockWebServer.takeRequest().getHeader("Authorization")) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientAuthorizationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientAuthorizationTest.kt new file mode 100644 index 000000000..49c0bf201 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientAuthorizationTest.kt @@ -0,0 +1,92 @@ +package org.wordpress.gutenberg + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.wordpress.gutenberg.model.EditorAuthorizationScope +import org.wordpress.gutenberg.model.http.EditorHttpMethod +import java.io.File + +/** The site's credentials go to the site, and to no other host a request names. */ +class EditorHTTPClientAuthorizationTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var mockWebServer: MockWebServer + private lateinit var baseUrl: String + + companion object { + private const val TEST_AUTH_HEADER = "Bearer test-token-12345" + } + + @Before + fun setUp() { + mockWebServer = MockWebServer() + mockWebServer.start() + baseUrl = mockWebServer.url("/").toString() + } + + @After + fun tearDown() { + mockWebServer.shutdown() + } + + /** A client for a site the mock web server serves. */ + private fun makeClientForSite() = EditorHTTPClient( + authHeader = TEST_AUTH_HEADER, + authorizationScope = EditorAuthorizationScope(siteURL = baseUrl, siteApiRoot = baseUrl) + ) + + /** A client for a site somewhere else, to which the mock web server is another party's host. */ + private fun makeClientForAnotherSite() = EditorHTTPClient( + authHeader = TEST_AUTH_HEADER, + authorizationScope = EditorAuthorizationScope( + siteURL = "https://example.com", + siteApiRoot = "https://example.com/wp-json/" + ) + ) + + @Test + fun `perform sends the Authorization header to the site`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + makeClientForSite().perform(EditorHttpMethod.GET, "${baseUrl}wp/v2/posts") + + assertEquals(TEST_AUTH_HEADER, mockWebServer.takeRequest().getHeader("Authorization")) + } + + @Test + fun `perform sends no Authorization header to another party's host`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + makeClientForAnotherSite().perform(EditorHttpMethod.GET, "${baseUrl}v1/things") + + assertNull(mockWebServer.takeRequest().getHeader("Authorization")) + } + + @Test + fun `download sends the Authorization header to the site`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("content")) + + makeClientForSite().download("${baseUrl}wp-content/script.js", File(tempFolder.root, "script.js")) + + assertEquals(TEST_AUTH_HEADER, mockWebServer.takeRequest().getHeader("Authorization")) + } + + @Test + fun `download sends no Authorization header to another party's host`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("content")) + + makeClientForAnotherSite().download("${baseUrl}integration.js", File(tempFolder.root, "integration.js")) + + assertNull(mockWebServer.takeRequest().getHeader("Authorization")) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt index 667e00ea8..1402d98b3 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt @@ -14,6 +14,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import org.wordpress.gutenberg.model.EditorAuthorizationScope import org.wordpress.gutenberg.model.http.EditorHttpMethod import java.io.File import java.util.concurrent.TimeUnit @@ -44,16 +45,21 @@ class EditorHTTPClientTest { private fun makeClient( authHeader: String = TEST_AUTH_HEADER, + authorizationScope: EditorAuthorizationScope = siteScope(), delegate: EditorHTTPClientDelegate? = null, timeoutSeconds: Long = 60 ): EditorHTTPClient { return EditorHTTPClient( authHeader = authHeader, + authorizationScope = authorizationScope, delegate = delegate, requestTimeoutSeconds = timeoutSeconds ) } + /** The scope of a site served by the mock web server. */ + private fun siteScope() = EditorAuthorizationScope(siteURL = baseUrl, siteApiRoot = baseUrl) + // MARK: - EditorHTTPClientResponse Tests @Test @@ -414,6 +420,7 @@ class EditorHTTPClientTest { val client = EditorHTTPClient( authHeader = TEST_AUTH_HEADER, + authorizationScope = siteScope(), okHttpClient = customOkHttpClient ) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorAuthorizationScopeTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorAuthorizationScopeTest.kt new file mode 100644 index 000000000..98b63b060 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorAuthorizationScopeTest.kt @@ -0,0 +1,212 @@ +package org.wordpress.gutenberg.model + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class EditorAuthorizationScopeTest { + + private val site = EditorAuthorizationScope(siteURL = SITE_URL, siteApiRoot = SITE_API_ROOT) + + /** A scope for the test site that names [domains] as well. */ + private fun scope(vararg domains: String) = EditorAuthorizationScope( + siteURL = SITE_URL, + siteApiRoot = SITE_API_ROOT, + domains = domains.toSet() + ) + + // MARK: - The site and its API + + @Test + fun `a site's credentials may go to the site and its REST API`() { + listOf( + "https://example.com/wp-json/wp/v2/posts/1?context=edit", + "https://example.com/wp-content/plugins/plugin/script.js?ver=1", + "https://EXAMPLE.com/wp-content/themes/theme/style.css", + "https://example.com:443/wp-json/" + ).forEach { assertTrue(it, site.allows(it)) } + } + + @Test + fun `a site's credentials go nowhere else, unless the app names the place`() { + listOf( + // Another party's host + "https://cdn.vendor.net/script.js", + // Hosts that only look like the site's + "https://example.com.vendor.net/script.js", + "https://notexample.com/script.js", + "https://cdn.example.com/script.js", + // The site's host, but not the place it was configured with + "http://example.com/wp-content/plugins/plugin/script.js", + "https://example.com:8443/script.js", + // Places an app could name, and this one hasn't + "https://s0.wp.com/wp-content/plugins/plugin/script.js", + "https://example.files.wordpress.com/2026/10/image.png", + // Nowhere at all + "/wp-json/wp/v2/posts", + "file:///wp-content/script.js", + "" + ).forEach { assertFalse(it, site.allows(it)) } + } + + @Test + fun `a site served in the clear is allowed its own credentials`() { + val scope = EditorAuthorizationScope( + siteURL = "http://localhost:8881", + siteApiRoot = "http://localhost:8881/wp-json/" + ) + + assertTrue(scope.allows("http://localhost:8881/wp-json/wp/v2/posts")) + assertFalse(scope.allows("http://localhost:9999/script.js")) + } + + @Test + fun `a site whose API is on another host is allowed its credentials at both`() { + val scope = EditorAuthorizationScope( + siteURL = "https://example.com", + siteApiRoot = "https://api.example.com/wp-json/" + ) + + assertTrue(scope.allows("https://example.com/wp-content/script.js")) + assertTrue(scope.allows("https://api.example.com/wp-json/wp/v2/posts")) + } + + // MARK: - A named host + + @Test + fun `a named host is that host, in any case, over HTTPS`() { + val scope = scope("S0.wp.com") + + assertTrue(scope.allows("https://s0.wp.com/wp-content/plugins/plugin/script.js?m=1")) + assertTrue(scope.allows("https://S0.WP.com/script.js")) + } + + @Test + fun `a named host is no other host`() { + val scope = scope("s0.wp.com") + + listOf( + // Its siblings, its subdomains, and the domain it's under + "https://s1.wp.com/script.js", + "https://cdn.s0.wp.com/script.js", + "https://wp.com/script.js", + // Hosts that only look like it + "https://s0.wp.com.vendor.net/script.js", + "https://nots0.wp.com/script.js", + // The host itself, in the clear + "http://s0.wp.com/script.js" + ).forEach { assertFalse(it, scope.allows(it)) } + } + + /** Naming a host never reaches past it, so even a top-level domain names only itself. */ + @Test + fun `a top-level domain named as a host covers nothing under it`() { + listOf("com", "net", "cool", "uk").forEach { domain -> + val scope = scope(domain) + + assertFalse(domain, scope.allows("https://vendor.$domain/script.js")) + assertFalse(domain, scope.allows("https://cdn.vendor.$domain/script.js")) + } + } + + // MARK: - A wildcard + + @Test + fun `a wildcard is the domain it's over and every subdomain of it, however deep`() { + val scope = scope("*.wp.com", "*.files.wordpress.com") + + listOf( + "https://wp.com/script.js", + "https://WP.com/script.js", + "https://files.wordpress.com/image.png", + "https://s0.wp.com/wp-content/plugins/plugin/script.js?m=1", + "https://S1.WP.com/_static/??-eJx9jk", + "https://i0.wp.com/example.com/image.png", + "https://a.b.wp.com/script.js", + "https://example.files.wordpress.com/2026/10/image.png", + "https://Another.Files.WordPress.com/2026/10/image.png" + ).forEach { assertTrue(it, scope.allows(it)) } + } + + @Test + fun `a wildcard is nothing else`() { + val scope = scope("*.wp.com", "*.files.wordpress.com") + + listOf( + // Hosts that only look like the domain or its subdomains + "https://notwp.com/script.js", + "https://wp.com.vendor.net/script.js", + "https://files.wordpress.com.vendor.net/image.png", + "https://notfiles.wordpress.com/image.png", + // A domain above it + "https://another.wordpress.com/script.js", + // The domain and its subdomains, in the clear + "http://wp.com/script.js", + "http://s0.wp.com/script.js", + "http://example.files.wordpress.com/2026/10/image.png", + // Another party's host + "https://cdn.vendor.net/script.js" + ).forEach { assertFalse(it, scope.allows(it)) } + } + + /** What a wildcard is over is the app's business: nothing here second-guesses it. */ + @Test + fun `a wildcard is taken at its word, however much it covers`() { + val overTopLevelDomain = scope("*.com") + assertTrue(overTopLevelDomain.allows("https://vendor.com/script.js")) + assertTrue(overTopLevelDomain.allows("https://cdn.vendor.com/script.js")) + assertFalse(overTopLevelDomain.allows("https://vendor.net/script.js")) + assertFalse(overTopLevelDomain.allows("http://vendor.com/script.js")) + + val overRegistry = scope("*.co.uk") + assertTrue(overRegistry.allows("https://vendor.co.uk/script.js")) + assertFalse(overRegistry.allows("https://vendor.org.uk/script.js")) + } + + // MARK: - Names + + @Test + fun `a name can end with the dot that ends a fully qualified one`() { + listOf("*.wp.com.", " *.wp.com ").forEach { + assertTrue(it, scope(it).allows("https://s0.wp.com/script.js")) + } + } + + @Test + fun `a name that is neither a host nor a wildcard over a domain names nowhere`() { + listOf( + "", " ", "*", "*.", ".", + // Only `*.` in front is a wildcard + ".wp.com", "s*.wp.com", "*wp.com", "wp.*", "s0.*.com", "*.*.com", "*.*.wp.com", + // Not a host + "wp..com", "https://s0.wp.com" + ).forEach { domain -> + val scope = scope(domain) + + assertFalse("'$domain'", scope.allows("https://s0.wp.com/script.js")) + assertFalse("'$domain'", scope.allows("https://wp.com/script.js")) + assertFalse("'$domain'", scope.allows("https://cdn.vendor.net/script.js")) + } + } + + // MARK: - Configuration + + @Test + fun `the scope for a configuration takes the places the configuration names`() { + val builder = EditorConfiguration.builder( + siteURL = "https://example.com", + siteApiRoot = "https://public-api.wordpress.com/", + postType = PostTypeDetails.post + ) + val asset = "https://s0.wp.com/script.js" + + // Where a site's API is says nothing about where else its credentials may go + assertFalse(EditorAuthorizationScope(builder.build()).allows(asset)) + assertTrue(EditorAuthorizationScope(builder.setAuthHeaderDomains(setOf("*.wp.com")).build()).allows(asset)) + } + + private companion object { + const val SITE_URL = "https://example.com" + const val SITE_API_ROOT = "https://example.com/wp-json/" + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt index 8aa9c9961..774326627 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -44,6 +44,7 @@ class EditorConfigurationBuilderTest { assertEquals(emptyMap(), config.cookies) assertFalse(config.enableAssetCaching) assertEquals(emptySet(), config.cachedAssetHosts) + assertEquals(emptySet(), config.authHeaderDomains) assertNull(config.editorAssetsEndpoint) assertFalse(config.enableNetworkLogging) assertFalse(config.enableOfflineMode) @@ -268,6 +269,25 @@ class EditorConfigurationBuilderTest { assertEquals(hosts, config.cachedAssetHosts) } + @Test + fun `setAuthHeaderDomains updates authHeaderDomains, and toBuilder keeps them`() { + val domains = setOf("*.wp.com", "*.files.wordpress.com") + val config = builder() + .setAuthHeaderDomains(domains) + .build() + + assertEquals(domains, config.authHeaderDomains) + assertEquals(domains, config.toBuilder().build().authHeaderDomains) + } + + @Test + fun `Configurations with different authHeaderDomains are not equal`() { + val config1 = builder().setAuthHeaderDomains(setOf("*.wp.com")).build() + val config2 = builder().setAuthHeaderDomains(setOf("*.files.wordpress.com")).build() + + assertNotEquals(config1, config2) + } + @Test fun `setEditorAssetsEndpoint updates editorAssetsEndpoint`() { val endpoint = "https://example.com/assets" diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt index c1f4b50e2..f0f001706 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt @@ -178,6 +178,18 @@ class GBKitGlobalTest { assertEquals("Bearer my-token", global.authHeader) } + @Test + fun `maps authHeaderDomains from configuration, for the editor's own requests`() { + val configuration = makeConfiguration().toBuilder() + .setAuthHeaderDomains(setOf("*.wp.com", "*.files.wordpress.com")) + .build() + + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + + assertEquals(listOf("*.wp.com", "*.files.wordpress.com"), global.authHeaderDomains) + assertTrue(global.toJsonString().contains(""""authHeaderDomains":["*.wp.com","*.files.wordpress.com"]""")) + } + @Test fun `maps postStatus from configuration`() { val configuration = makeConfiguration(postStatus = "publish") diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt index 794492726..b285c66ff 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -254,6 +254,14 @@ class SitePreparationViewModel( } else { arrayOf() } + // A WordPress.com token is good across WordPress.com, which serves a site's assets from + // wp.com and its files from files.wordpress.com. An application password is good for + // the one site. + val authHeaderDomains = if (wpComSiteId != null) { + setOf("*.wp.com", "*.files.wordpress.com") + } else { + emptySet() + } // Fetch the site's post types. Default the picker to `post` when it's // available (the typical case); otherwise pick the first type in the @@ -278,6 +286,7 @@ class SitePreparationViewModel( .setSiteApiNamespace(siteApiNamespace) .setNamespaceExcludedPaths(arrayOf()) .setAuthHeader(config.authHeader) + .setAuthHeaderDomains(authHeaderDomains) .setTitle("") .setContent("") .setHideTitle(false) diff --git a/docs/code/README.md b/docs/code/README.md index fb13097dc..8106ce55d 100644 --- a/docs/code/README.md +++ b/docs/code/README.md @@ -15,6 +15,7 @@ This guide is for developers who want to contribute code to GutenbergKit. - [Architecture](./architecture.md) - Project structure and communication patterns - [Plugins](./plugins.md) - Plugin loading and custom blocks - [Preloading](./preloading.md) - Asset preloading +- [Authorization Header Scope](./authorization.md) - Which requests carry the site's credentials - [Local WordPress](./local-wordpress.md) - Local WordPress environment for testing - [Physical Device Setup](./physical-device-setup.md) - Running on physical devices - [WordPress.com OAuth](./wpcom-oauth.md) - Connecting demo apps to WordPress.com sites diff --git a/docs/code/authorization.md b/docs/code/authorization.md new file mode 100644 index 000000000..7a19ab0cd --- /dev/null +++ b/docs/code/authorization.md @@ -0,0 +1,106 @@ +# Authorization Header Scope + +## Overview + +The host app hands GutenbergKit a site's credentials as `authHeader`. The editor makes requests to more places than the site: it downloads plugin and theme assets from whichever hosts the site names, and a block can call another party's service through `apiFetch( { url } )`. Those hosts have no business receiving the site's credentials. + +GutenbergKit therefore sends the `Authorization` header only to requests within the site's **authorization scope**. The same rule is implemented once on each platform, and all three implementations must agree. + +## The Rule + +A request carries the `Authorization` header when either of these holds: + +1. **It is for the site or its API.** The request's origin — scheme, host, and port — is the origin of `siteURL` or of `siteApiRoot`. This always applies, so an app that names nothing else keeps working. +2. **The app named its host.** The request is over HTTPS and its host matches an entry in `authHeaderDomains`. + +Every other request goes out without the header. + +Because the first case compares origins, a lookalike host (`https://example.com.vendor.net`), another port, and the site's own host over `http` when the site is `https` are all outside the scope. + +## Naming Other Hosts + +`authHeaderDomains` is empty by default. Each entry is taken exactly as written: + +| Entry | Matches | +| ----------- | ----------------------------------------------------- | +| `s0.wp.com` | `s0.wp.com` only — not its subdomains, not `wp.com` | +| `*.wp.com` | `wp.com` and every subdomain of it, at any depth | +| `*.com` | Every `.com` host — a wildcard is not checked further | + +GutenbergKit does not judge what a wildcard covers: no public-suffix check, no minimum number of labels. Name the narrowest domain that will do. + +Entries are trimmed and lowercased, and may end with the trailing dot of a fully qualified name. A named entry matches its host on any port, over HTTPS only. An entry is ignored when it is empty, or when it has a `*` anywhere but as the whole first label (`.wp.com`, `s*.wp.com`, `*.*.com`). + +**Swift** + +```swift +let configuration = EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: "https://example.wordpress.com")!, + siteApiRoot: URL(string: "https://public-api.wordpress.com/")! +) + .setAuthHeader("Bearer your-token") + .setAuthHeaderDomains(["*.wp.com", "*.files.wordpress.com"]) + .build() +``` + +**Kotlin** + +```kotlin +val configuration = EditorConfiguration.builder( + siteURL = "https://example.wordpress.com", + siteApiRoot = "https://public-api.wordpress.com/" +) + .setAuthHeader("Bearer your-token") + .setAuthHeaderDomains(setOf("*.wp.com", "*.files.wordpress.com")) + .build() +``` + +### WordPress.com + +GutenbergKit infers nothing about WordPress.com: no `wp.com` or `wordpress.com` host is built into the library. A site reached through WordPress.com is served from more than its own address — its assets from `wp.com`, its files from `files.wordpress.com` — so the app names them, as above. Both demo apps do this for WordPress.com accounts and name nothing for self-hosted sites, where an application password is good for the one site. + +### Custom Editor Assets Endpoint + +`editorAssetsEndpoint` is subject to the same rule. An endpoint on a host other than the site's or its API's is fetched without credentials unless that host is named in `authHeaderDomains`. + +## Where It Is Enforced + +| Platform | Rule | Applied by | +| -------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| iOS | `EditorAuthorizationScope` | `EditorHTTPClient`, for REST requests, asset downloads, and media uploads | +| Android | `EditorAuthorizationScope` | `EditorHTTPClient.perform` and `download`, and the manifest request of the `EditorAssetsLibrary` in the `org.wordpress.gutenberg` root | +| Web | `isWithinAuthorizationScope` in `src/utils/authorization-scope.js` | `tokenAuthMiddleware` in `src/utils/api-fetch.js` | + +`GBKitGlobal` carries `authHeaderDomains` to the web editor, so it applies the list the native side was configured with. + +On the web side, a request made by `path` is for the site's API — the root URL middleware joins the path to `siteApiRoot` — and always carries the header. A request made by `url` alone is checked against the scope. `credentials: 'omit'` is set only when the header is attached. + +jQuery AJAX requests are scoped separately, in `src/utils/ajax.js`, to the origin of `siteURL`. They do not consult `authHeaderDomains`. + +Android's native media upload relay (`DefaultMediaUploader`) builds its URL from `siteApiRoot`, so it is within the scope by construction. + +### Constructing a Client + +Both native `EditorHTTPClient` constructors require the scope. Derive it from the configuration rather than assembling it by hand: + +**Swift** + +```swift +let client = EditorHTTPClient(configuration: configuration) +``` + +**Kotlin** + +```kotlin +val client = EditorHTTPClient( + authHeader = configuration.authHeader, + authorizationScope = EditorAuthorizationScope(configuration) +) +``` + +## Limits + +- **A host-supplied HTTP client is not covered.** An app's own `EditorHTTPClientProtocol` implementation adds whichever headers it likes, to whichever requests it likes. +- **Requests the web view makes itself never carry the header.** Images, scripts, and stylesheets loaded by the document are outside `apiFetch` and the native clients. Naming `*.files.wordpress.com` makes those hosts eligible for requests GutenbergKit makes; it does not by itself make a private site's media display. +- **The scope is decided from the request's URL.** Where a redirect then leads, and which headers follow it there, is up to the platform's HTTP stack. diff --git a/docs/integration.md b/docs/integration.md index 676c3642a..3b949fafa 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -300,6 +300,32 @@ val configuration = EditorConfiguration.builder() .build() ``` +### Authentication Header Scope + +GutenbergKit sends `authHeader` only to the site and its API — the origins of `siteURL` and `siteApiRoot`. Assets and requests on any other host go out without it. + +If the site's credentials belong on other hosts, name them with `setAuthHeaderDomains`. An entry is one host exactly (`s0.wp.com`), or with a leading `*.`, a domain and every subdomain of it (`*.wp.com`). Named hosts receive the header over HTTPS only. + +A site reached through WordPress.com is served from more than its own address, and GutenbergKit infers none of it: + +```swift +// iOS +let configuration = EditorConfigurationBuilder(...) + .setAuthHeader("Bearer your-token") + .setAuthHeaderDomains(["*.wp.com", "*.files.wordpress.com"]) + .build() +``` + +```kotlin +// Android +val configuration = EditorConfiguration.builder(...) + .setAuthHeader("Bearer your-token") + .setAuthHeaderDomains(setOf("*.wp.com", "*.files.wordpress.com")) + .build() +``` + +See [Authorization Header Scope](./code/authorization.md) for the full rule and its limits. + ### AJAX Support Some Gutenberg blocks and features use WordPress AJAX (`admin-ajax.php`) for functionality like form submissions. GutenbergKit supports AJAX requests when properly configured. diff --git a/ios/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift index e98392ae2..5540cdfaa 100644 --- a/ios/Demo-iOS/Sources/ConfigurationItem.swift +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -105,6 +105,20 @@ extension Account { } } + /// The places the account's credentials may go to, besides the site and its API. + /// + /// A WordPress.com token is good across WordPress.com, which serves a site's assets from + /// `wp.com` and its files from `files.wordpress.com`. An application password is good for the + /// one site. + var authHeaderDomains: [String] { + switch self { + case .selfHostedSite: + return [] + case .wpCom: + return ["*.wp.com", "*.files.wordpress.com"] + } + } + var siteApiRoot: String { switch self { case .selfHostedSite(_, _, _, _, let siteApiRoot): diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index 070954c10..64ad34433 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -341,6 +341,7 @@ class SitePreparationViewModel { .setShouldUsePlugins(true) .setNetworkFallbackMode(.automatic) .setAuthHeader(account.authHeader) + .setAuthHeaderDomains(account.authHeaderDomains) .setLogLevel(.debug) .build() } @@ -461,6 +462,7 @@ class SitePreparationViewModel { .setShouldUsePlugins(canUsePlugins) .setSiteApiNamespace(siteApiNamespace) .setAuthHeader(account.authHeader) + .setAuthHeaderDomains(account.authHeaderDomains) .setLogLevel(.debug) .build() } diff --git a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift index e27d66f8a..9669409ca 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift @@ -56,7 +56,9 @@ public struct WPError: Decodable, Sendable { /// An HTTP client for making authenticated requests to the WordPress REST API. /// /// This actor handles request signing, error parsing, and response validation. -/// All requests are automatically authenticated using the provided authorization header. +/// A request within the client's ``EditorAuthorizationScope`` is authenticated using the provided +/// authorization header. A request anywhere else — an asset on another party's host, say — goes +/// out without it. public actor EditorHTTPClient: EditorHTTPClientProtocol { /// Errors that can occur during HTTP requests. @@ -91,17 +93,39 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { private let urlSession: URLSessionProtocol private let authHeader: String + private let authorizationScope: EditorAuthorizationScope private let delegate: EditorHTTPClientDelegate? private let requestTimeout: TimeInterval? + /// Creates a client for the site in `configuration`, which sends the site's credentials only + /// to the site. + public init( + configuration: EditorConfiguration, + urlSession: URLSessionProtocol = URLSession.shared, + delegate: EditorHTTPClientDelegate? = nil, + requestTimeout: TimeInterval? = nil + ) { + self.init( + urlSession: urlSession, + authHeader: configuration.authHeader, + authorizationScope: EditorAuthorizationScope(configuration: configuration), + delegate: delegate, + requestTimeout: requestTimeout + ) + } + + /// - Parameter authorizationScope: The requests that carry `authHeader`. A request outside it + /// goes out without credentials. public init( urlSession: URLSessionProtocol, authHeader: String, + authorizationScope: EditorAuthorizationScope, delegate: EditorHTTPClientDelegate? = nil, requestTimeout: TimeInterval? = nil ) { self.urlSession = urlSession self.authHeader = authHeader + self.authorizationScope = authorizationScope self.delegate = delegate self.requestTimeout = requestTimeout } @@ -175,12 +199,23 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { /// the REST `requestTimeout` is dropped. Sharing the observer across both /// clients is sound because `EditorHTTPClientDelegate` is `Sendable`. public nonisolated func uploadClient() -> any EditorHTTPClientProtocol { - EditorHTTPClient(urlSession: urlSession, authHeader: authHeader, delegate: delegate) + EditorHTTPClient( + urlSession: urlSession, + authHeader: authHeader, + authorizationScope: authorizationScope, + delegate: delegate + ) } private func configureRequest(_ request: URLRequest) -> URLRequest { var mutableRequest = request - mutableRequest.addValue(self.authHeader, forHTTPHeaderField: "Authorization") + + // The site's credentials are for the site. A request can be for anywhere: an editor + // downloads assets from whichever hosts the site names. + if let url = request.url, self.authorizationScope.allows(url) { + mutableRequest.addValue(self.authHeader, forHTTPHeaderField: "Authorization") + } + mutableRequest.addValue("\(Self.baseUserAgent) GutenbergKit/\(GutenbergKitVersion.version)", forHTTPHeaderField: "User-Agent") if let requestTimeout { diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index a3d8be1d8..c28d8aaba 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -224,10 +224,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro httpClient: EditorHTTPClient? = nil, isWarmupMode: Bool = false ) { - let httpClient = httpClient ?? EditorHTTPClient( - urlSession: URLSession.shared, - authHeader: configuration.authHeader - ) + let httpClient = httpClient ?? EditorHTTPClient(configuration: configuration) self.configuration = configuration self.dependencies = dependencies diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorAuthorizationScope.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorAuthorizationScope.swift new file mode 100644 index 000000000..e8bc5d753 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorAuthorizationScope.swift @@ -0,0 +1,120 @@ +import Foundation + +/// The requests that may carry a site's credentials. +/// +/// Credentials go to the site and to its REST API, and nowhere else. An editor also downloads +/// plugin and theme assets from whichever hosts the site names, and one of those can be a third +/// party's — a script on a vendor's CDN, say — which has no business receiving them. +/// +/// An app that knows of other places its credentials belong names them: a site reached through +/// WordPress.com has assets on `wp.com` and files on `files.wordpress.com`, run by the same party +/// as its API. See ``EditorConfigurationBuilder/setAuthHeaderDomains(_:)`` for how they're named. +public struct EditorAuthorizationScope: Sendable, Hashable { + + /// The origins that may receive credentials: the site's, and its REST API's. + private let origins: Set + + /// The hosts that may receive credentials over HTTPS, each named in full. + private let hosts: Set + + /// The domains that may receive credentials over HTTPS along with their every subdomain: + /// `wp.com` for `*.wp.com`. + private let wildcardDomains: Set + + /// Creates the scope for the site in `configuration`. + public init(configuration: EditorConfiguration) { + self.init( + siteURL: configuration.siteURL, + siteApiRoot: configuration.siteApiRoot, + domains: configuration.authHeaderDomains + ) + } + + /// Creates the scope for a site. + /// + /// - Parameters: + /// - siteURL: The site's address. + /// - siteApiRoot: The root of the site's REST API. + /// - domains: Other places that may receive the site's credentials, over HTTPS only. A name + /// is one host, exactly: `s0.wp.com`. A name that starts with `*.` is the domain that + /// follows and every subdomain of it: `*.wp.com`. A name that isn't one of the two is + /// ignored. + public init(siteURL: URL, siteApiRoot: URL, domains: [String] = []) { + let names = domains.compactMap(Name.init) + + self.origins = Set([Origin(siteURL), Origin(siteApiRoot)].compactMap { $0 }) + self.hosts = Set(names.compactMap { if case .host(let host) = $0 { host } else { nil } }) + self.wildcardDomains = Set( + names.compactMap { if case .domainAndSubdomains(let domain) = $0 { domain } else { nil } } + ) + } + + /// Whether a request to `url` may carry the site's credentials. + public func allows(_ url: URL) -> Bool { + guard let origin = Origin(url) else { + return false + } + + if self.origins.contains(origin) { + return true + } + + return origin.scheme == "https" + && (self.hosts.contains(origin.host) + || self.wildcardDomains.contains { origin.host == $0 || origin.host.hasSuffix("." + $0) }) + } + + /// What an app names as a place for the site's credentials. + private enum Name { + /// One host, exactly. + case host(String) + + /// A domain and every subdomain of it. + case domainAndSubdomains(String) + + /// `nil` for a name that is neither: an empty one, or one with a wildcard anywhere but + /// in front. What a wildcard is over is the app's business: `*.com` is every `.com` site. + init?(_ name: String) { + var name = name.trimmingCharacters(in: .whitespaces).lowercased() + + // The dot that ends a fully qualified name + if name.hasSuffix(".") { + name.removeLast() + } + + let isWildcard = name.hasPrefix("*.") + let domain = isWildcard ? String(name.dropFirst(2)) : name + let labels = domain.split(separator: ".", omittingEmptySubsequences: false) + + guard labels.allSatisfy({ !$0.isEmpty && !$0.contains("*") }) else { + return nil + } + + self = isWildcard ? .domainAndSubdomains(domain) : .host(domain) + } + } + + /// A URL's scheme, host and port: what has to match for two URLs to be the same place. + private struct Origin: Sendable, Hashable { + let scheme: String + let host: String + let port: Int + + /// `nil` for a URL with no host, or with a scheme whose default port isn't known. + init?(_ url: URL) { + guard + let scheme = url.scheme?.lowercased(), + let host = url.host()?.lowercased(), + let port = url.port ?? Self.defaultPorts[scheme] + else { + return nil + } + + self.scheme = scheme + self.host = host + self.port = port + } + + private static let defaultPorts = ["http": 80, "https": 443] + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift index 69661facd..e3e3a10c4 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift @@ -45,6 +45,11 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { public let namespaceExcludedPaths: [String] /// Authorization header public let authHeader: String + /// Places that `authHeader` may be sent to over HTTPS, besides the site and its API, which always receive it. + /// + /// A name is one host, exactly: `s0.wp.com`. A name that starts with `*.` is the domain that follows and + /// every subdomain of it: `*.wp.com`. See ``EditorConfigurationBuilder/setAuthHeaderDomains(_:)``. + public let authHeaderDomains: [String] /// Raw block editor settings from the WordPress REST API public let editorSettings: String /// Locale used for translations @@ -81,6 +86,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { siteApiNamespace: [String], namespaceExcludedPaths: [String], authHeader: String, + authHeaderDomains: [String], editorSettings: String, locale: String, isNativeInserterEnabled: Bool, @@ -104,6 +110,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { self.siteApiNamespace = siteApiNamespace self.namespaceExcludedPaths = namespaceExcludedPaths self.authHeader = authHeader + self.authHeaderDomains = authHeaderDomains self.editorSettings = editorSettings self.locale = locale self.isNativeInserterEnabled = isNativeInserterEnabled @@ -136,6 +143,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { siteApiNamespace: siteApiNamespace, namespaceExcludedPaths: namespaceExcludedPaths, authHeader: authHeader, + authHeaderDomains: authHeaderDomains, editorSettings: editorSettings, locale: locale, isNativeInserterEnabled: isNativeInserterEnabled, @@ -182,6 +190,7 @@ public struct EditorConfigurationBuilder { private var siteApiNamespace: [String] private var namespaceExcludedPaths: [String] private var authHeader: String + private var authHeaderDomains: [String] private var editorSettings: String private var locale: String private var isNativeInserterEnabled: Bool @@ -206,6 +215,7 @@ public struct EditorConfigurationBuilder { siteApiNamespace: [String] = [], namespaceExcludedPaths: [String] = [], authHeader: String = "", + authHeaderDomains: [String] = [], editorSettings: String = "undefined", locale: String = "en", isNativeInserterEnabled: Bool = false, @@ -229,6 +239,7 @@ public struct EditorConfigurationBuilder { self.siteApiNamespace = siteApiNamespace self.namespaceExcludedPaths = namespaceExcludedPaths self.authHeader = authHeader + self.authHeaderDomains = authHeaderDomains self.editorSettings = editorSettings self.locale = locale self.isNativeInserterEnabled = isNativeInserterEnabled @@ -319,6 +330,26 @@ public struct EditorConfigurationBuilder { return copy } + /// Sets the places the auth header may be sent to, besides the site and its API, which always + /// receive it. Only requests over HTTPS qualify. + /// + /// Each name is taken exactly as written: + /// + /// - `s0.wp.com` is that one host. It isn't its subdomains, and it isn't `s1.wp.com`. + /// - `*.wp.com` is `wp.com` and every subdomain of it, however deep. + /// + /// A wildcard is only ever the whole first label, and is taken at its word: `*.com` is every + /// `.com` site. Name the narrowest domain that will do. + /// + /// A site reached through WordPress.com is served from more than its own address — its assets + /// from `wp.com` and its files from `files.wordpress.com`, say — and can name those here: + /// `["*.wp.com", "*.files.wordpress.com"]`. + public func setAuthHeaderDomains(_ authHeaderDomains: [String]) -> EditorConfigurationBuilder { + var copy = self + copy.authHeaderDomains = authHeaderDomains + return copy + } + public func setEditorSettings(_ editorSettings: String) -> EditorConfigurationBuilder { var copy = self copy.editorSettings = editorSettings @@ -416,6 +447,7 @@ public struct EditorConfigurationBuilder { siteApiNamespace: siteApiNamespace, namespaceExcludedPaths: namespaceExcludedPaths, authHeader: authHeader, + authHeaderDomains: authHeaderDomains, editorSettings: editorSettings, locale: locale, isNativeInserterEnabled: isNativeInserterEnabled, diff --git a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift index a06c793b2..a6b093161 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift @@ -56,6 +56,9 @@ public struct GBKitGlobal: Sendable, Codable { /// The authorization header value for authenticated API requests. let authHeader: String + /// Domains that `authHeader` may be sent to, besides the site and its API. + let authHeaderDomains: [String] + /// Whether to apply theme styles to the editor. let themeStyles: Bool @@ -111,6 +114,7 @@ public struct GBKitGlobal: Sendable, Codable { self.siteApiNamespace = configuration.siteApiNamespace self.namespaceExcludedPaths = configuration.namespaceExcludedPaths self.authHeader = configuration.authHeader + self.authHeaderDomains = configuration.authHeaderDomains self.themeStyles = configuration.shouldUseThemeStyles self.plugins = configuration.shouldUsePlugins self.enableNativeBlockInserter = configuration.isNativeInserterEnabled diff --git a/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift b/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift index d870c197a..bca4fd8d4 100644 --- a/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift +++ b/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift @@ -92,11 +92,7 @@ public actor EditorService { ) { self.configuration = configuration - let httpClient: any EditorHTTPClientProtocol = httpClient ?? EditorHTTPClient( - urlSession: URLSession.shared, - authHeader: configuration.authHeader, - delegate: nil - ) + let httpClient: any EditorHTTPClientProtocol = httpClient ?? EditorHTTPClient(configuration: configuration) self.restRepository = RESTAPIRepository( configuration: configuration, diff --git a/ios/Tests/GutenbergKitTests/EditorHTTPClientTests.swift b/ios/Tests/GutenbergKitTests/EditorHTTPClientTests.swift index 0d49d9bfe..27df1df31 100644 --- a/ios/Tests/GutenbergKitTests/EditorHTTPClientTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorHTTPClientTests.swift @@ -2,6 +2,14 @@ import Foundation import Testing @testable import GutenbergKit +extension EditorAuthorizationScope { + /// The scope of the site every test here makes requests to. + fileprivate static let example = EditorAuthorizationScope( + siteURL: URL(string: "https://example.com")!, + siteApiRoot: URL(string: "https://example.com/wp-json/")! + ) +} + /// A spy mock that captures requests for inspection private final class SpyURLSession: URLSessionProtocol, @unchecked Sendable { private let lock = NSLock() @@ -88,7 +96,8 @@ struct EditorHTTPClientTests { let authHeader = "Bearer test-token-12345" let client = EditorHTTPClient( urlSession: spySession, - authHeader: authHeader + authHeader: authHeader, + authorizationScope: .example ) let request = URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/posts")!) @@ -104,7 +113,8 @@ struct EditorHTTPClientTests { let authHeader = "Bearer test-token-12345" let client = EditorHTTPClient( urlSession: spySession, - authHeader: authHeader + authHeader: authHeader, + authorizationScope: .example ) let request = URLRequest(url: URL(string: "https://example.com/wp-content/file.js")!) @@ -114,6 +124,97 @@ struct EditorHTTPClientTests { #expect(capturedRequest.value(forHTTPHeaderField: "Authorization") == authHeader) } + // MARK: - Authorization Scope Tests + + @Test( + "a request to another party's host goes out without the Authorization header", + arguments: [ + "https://cdn.vendor.net/integration.js", + "https://example.com.vendor.net/script.js", + "http://example.com/wp-content/plugins/plugin/script.js", + ] + ) + func requestOutsideScopeHasNoAuthorizationHeader(url: String) async throws { + let spySession = SpyURLSession() + let client = EditorHTTPClient( + urlSession: spySession, + authHeader: "Bearer test-token-12345", + authorizationScope: .example + ) + let request = URLRequest(url: URL(string: url)!) + + _ = try await client.download(request) + _ = try await client.perform(request) + _ = try await client.performRaw(request) + + #expect(spySession.capturedRequests.count == 3) + #expect(spySession.capturedRequests.allSatisfy { $0.value(forHTTPHeaderField: "Authorization") == nil }) + // It's still this library's request + #expect(spySession.capturedRequests.allSatisfy { $0.value(forHTTPHeaderField: "User-Agent") != nil }) + } + + @Test("a client made for a configuration sends the site's credentials only to the site") + func clientForConfigurationScopesAuthorizationHeader() async throws { + let spySession = SpyURLSession() + let configuration = EditorConfigurationBuilder( + postType: .post, + siteURL: URL(string: "https://example.com")!, + siteApiRoot: URL(string: "https://example.com/wp-json/")! + ) + .setAuthHeader("Bearer test-token-12345") + .build() + let client = EditorHTTPClient(configuration: configuration, urlSession: spySession) + + _ = try await client.download(URLRequest(url: URL(string: "https://example.com/wp-content/script.js")!)) + _ = try await client.download(URLRequest(url: URL(string: "https://cdn.vendor.net/integration.js")!)) + + #expect(spySession.capturedRequests.map { $0.value(forHTTPHeaderField: "Authorization") } == [ + "Bearer test-token-12345", + nil, + ]) + } + + @Test("a client made for a configuration sends the site's credentials to the places it names as well") + func clientForConfigurationAuthorizesNamedDomains() async throws { + let spySession = SpyURLSession() + let configuration = EditorConfigurationBuilder( + postType: .post, + siteURL: URL(string: "https://example.wordpress.com")!, + siteApiRoot: URL(string: "https://public-api.wordpress.com/")! + ) + .setAuthHeader("Bearer test-token-12345") + .setAuthHeaderDomains(["*.wp.com"]) + .build() + let client = EditorHTTPClient(configuration: configuration, urlSession: spySession) + + _ = try await client.download(URLRequest(url: URL(string: "https://s0.wp.com/wp-content/script.js")!)) + _ = try await client.download(URLRequest(url: URL(string: "https://cdn.vendor.net/integration.js")!)) + + #expect(spySession.capturedRequests.map { $0.value(forHTTPHeaderField: "Authorization") } == [ + "Bearer test-token-12345", + nil, + ]) + } + + @Test("the upload client keeps the client's authorization scope") + func uploadClientKeepsAuthorizationScope() async throws { + let spySession = SpyURLSession() + let client = EditorHTTPClient( + urlSession: spySession, + authHeader: "Bearer test-token-12345", + authorizationScope: .example + ) + let uploadClient = client.uploadClient() + + _ = try await uploadClient.perform(URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/media")!)) + _ = try await uploadClient.perform(URLRequest(url: URL(string: "https://cdn.vendor.net/upload")!)) + + #expect(spySession.capturedRequests.map { $0.value(forHTTPHeaderField: "Authorization") } == [ + "Bearer test-token-12345", + nil, + ]) + } + // MARK: - Timeout Tests @Test("perform() uses custom timeout") @@ -123,6 +224,7 @@ struct EditorHTTPClientTests { let client = EditorHTTPClient( urlSession: spySession, authHeader: "Bearer token", + authorizationScope: .example, requestTimeout: customTimeout ) @@ -140,6 +242,7 @@ struct EditorHTTPClientTests { let client = EditorHTTPClient( urlSession: spySession, authHeader: "Bearer token", + authorizationScope: .example, requestTimeout: customTimeout ) @@ -155,7 +258,8 @@ struct EditorHTTPClientTests { let spySession = SpyURLSession() let client = EditorHTTPClient( urlSession: spySession, - authHeader: "Bearer token" + authHeader: "Bearer token", + authorizationScope: .example ) var request = URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/posts")!) @@ -176,6 +280,7 @@ struct EditorHTTPClientTests { let client = EditorHTTPClient( urlSession: spySession, authHeader: authHeader, + authorizationScope: .example, requestTimeout: restTimeout ) @@ -198,6 +303,7 @@ struct EditorHTTPClientTests { let client = EditorHTTPClient( urlSession: spySession, authHeader: "Bearer token", + authorizationScope: .example, requestTimeout: 15 ) @@ -220,6 +326,7 @@ struct EditorHTTPClientTests { let client = EditorHTTPClient( urlSession: spySession, authHeader: "Bearer token", + authorizationScope: .example, delegate: spyDelegate ) @@ -242,7 +349,8 @@ struct EditorHTTPClientTests { let spySession = SpyURLSession() let client = EditorHTTPClient( urlSession: spySession, - authHeader: "Bearer token" + authHeader: "Bearer token", + authorizationScope: .example ) let request = URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/posts")!) @@ -257,7 +365,8 @@ struct EditorHTTPClientTests { let spySession = SpyURLSession() let client = EditorHTTPClient( urlSession: spySession, - authHeader: "Bearer token" + authHeader: "Bearer token", + authorizationScope: .example ) let request = URLRequest(url: URL(string: "https://example.com/wp-content/file.js")!) @@ -277,6 +386,7 @@ struct EditorHTTPClientTests { let client = EditorHTTPClient( urlSession: spySession, authHeader: authHeader, + authorizationScope: .example, requestTimeout: customTimeout ) @@ -297,6 +407,7 @@ struct EditorHTTPClientTests { let client = EditorHTTPClient( urlSession: spySession, authHeader: authHeader, + authorizationScope: .example, requestTimeout: customTimeout ) @@ -321,6 +432,7 @@ struct EditorHTTPClientTests { let client = EditorHTTPClient( urlSession: spySession, authHeader: "Bearer token", + authorizationScope: .example, delegate: spyDelegate ) @@ -346,6 +458,7 @@ struct EditorHTTPClientTests { let client = EditorHTTPClient( urlSession: spySession, authHeader: "Bearer token", + authorizationScope: .example, delegate: spyDelegate ) @@ -371,6 +484,7 @@ struct EditorHTTPClientTests { let client = EditorHTTPClient( urlSession: spySession, authHeader: "Bearer token", + authorizationScope: .example, delegate: spyDelegate ) @@ -393,6 +507,7 @@ struct EditorHTTPClientTests { let client = EditorHTTPClient( urlSession: spySession, authHeader: authHeader, + authorizationScope: .example, delegate: spyDelegate ) @@ -411,7 +526,8 @@ struct EditorHTTPClientTests { let spySession = SpyURLSession() let client = EditorHTTPClient( urlSession: spySession, - authHeader: "Bearer token" + authHeader: "Bearer token", + authorizationScope: .example ) let request = URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/posts")!) @@ -428,7 +544,8 @@ struct EditorHTTPClientTests { let spySession = SpyURLSession() let client = EditorHTTPClient( urlSession: spySession, - authHeader: "Bearer token" + authHeader: "Bearer token", + authorizationScope: .example ) let request = URLRequest(url: URL(string: "https://example.com/wp-content/file.js")!) @@ -445,7 +562,8 @@ struct EditorHTTPClientTests { let spySession = SpyURLSession() let client = EditorHTTPClient( urlSession: spySession, - authHeader: "Bearer token" + authHeader: "Bearer token", + authorizationScope: .example ) let request = URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/posts")!) diff --git a/ios/Tests/GutenbergKitTests/Model/EditorAuthorizationScopeTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorAuthorizationScopeTests.swift new file mode 100644 index 000000000..c364d39d4 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Model/EditorAuthorizationScopeTests.swift @@ -0,0 +1,219 @@ +import Foundation +import Testing + +@testable import GutenbergKit + +@Suite +struct EditorAuthorizationScopeTests { + + private static let siteURL = URL(string: "https://example.com")! + private static let siteApiRoot = URL(string: "https://example.com/wp-json/")! + + private let site = EditorAuthorizationScope(siteURL: siteURL, siteApiRoot: siteApiRoot) + + /// A scope for the test site that names `domains` as well. + private func scope(naming domains: [String]) -> EditorAuthorizationScope { + EditorAuthorizationScope(siteURL: Self.siteURL, siteApiRoot: Self.siteApiRoot, domains: domains) + } + + private func url(_ string: String) -> URL { + URL(string: string)! + } + + // MARK: - The site and its API + + @Test( + "a site's credentials may go to the site and its REST API", + arguments: [ + "https://example.com/wp-json/wp/v2/posts/1?context=edit", + "https://example.com/wp-content/plugins/plugin/script.js?ver=1", + "https://EXAMPLE.com/wp-content/themes/theme/style.css", + "https://example.com:443/wp-json/", + ] + ) + func allowsSite(url: String) { + #expect(site.allows(self.url(url))) + } + + @Test( + "a site's credentials go nowhere else, unless the app names the place", + arguments: [ + // Another party's host + "https://cdn.vendor.net/script.js", + // Hosts that only look like the site's + "https://example.com.vendor.net/script.js", + "https://notexample.com/script.js", + "https://cdn.example.com/script.js", + // The site's host, but not the place it was configured with + "http://example.com/wp-content/plugins/plugin/script.js", + "https://example.com:8443/script.js", + // Places an app could name, and this one hasn't + "https://s0.wp.com/wp-content/plugins/plugin/script.js", + "https://example.files.wordpress.com/2026/10/image.png", + // Nowhere at all + "file:///wp-content/script.js", + "data:text/javascript,", + ] + ) + func deniesEverywhereElse(url: String) { + #expect(!site.allows(self.url(url))) + } + + @Test("a site served in the clear is allowed its own credentials") + func allowsSiteServedInTheClear() { + let scope = EditorAuthorizationScope( + siteURL: url("http://localhost:8881"), + siteApiRoot: url("http://localhost:8881/wp-json/") + ) + + #expect(scope.allows(url("http://localhost:8881/wp-json/wp/v2/posts"))) + #expect(!scope.allows(url("http://localhost:9999/script.js"))) + } + + @Test("a site whose API is on another host is allowed its credentials at both") + func allowsSiteAndApiOnDifferentHosts() { + let scope = EditorAuthorizationScope( + siteURL: url("https://example.com"), + siteApiRoot: url("https://api.example.com/wp-json/") + ) + + #expect(scope.allows(url("https://example.com/wp-content/script.js"))) + #expect(scope.allows(url("https://api.example.com/wp-json/wp/v2/posts"))) + } + + // MARK: - A named host + + @Test("a named host is that host, in any case, over HTTPS") + func allowsNamedHost() { + let scope = scope(naming: ["S0.wp.com"]) + + #expect(scope.allows(url("https://s0.wp.com/wp-content/plugins/plugin/script.js?m=1"))) + #expect(scope.allows(url("https://S0.WP.com/script.js"))) + } + + @Test( + "a named host is no other host", + arguments: [ + // Its siblings, its subdomains, and the domain it's under + "https://s1.wp.com/script.js", + "https://cdn.s0.wp.com/script.js", + "https://wp.com/script.js", + // Hosts that only look like it + "https://s0.wp.com.vendor.net/script.js", + "https://nots0.wp.com/script.js", + // The host itself, in the clear + "http://s0.wp.com/script.js", + ] + ) + func deniesAllButNamedHost(url: String) { + #expect(!scope(naming: ["s0.wp.com"]).allows(self.url(url))) + } + + /// Naming a host never reaches past it, so even a top-level domain names only itself. + @Test("a top-level domain named as a host covers nothing under it", arguments: ["com", "net", "cool", "uk"]) + func namedTopLevelDomainCoversNothingUnderIt(domain: String) { + let scope = scope(naming: [domain]) + + #expect(!scope.allows(url("https://vendor.\(domain)/script.js"))) + #expect(!scope.allows(url("https://cdn.vendor.\(domain)/script.js"))) + } + + // MARK: - A wildcard + + @Test( + "a wildcard is the domain it's over and every subdomain of it, however deep", + arguments: [ + "https://wp.com/script.js", + "https://WP.com/script.js", + "https://files.wordpress.com/image.png", + "https://s0.wp.com/wp-content/plugins/plugin/script.js?m=1", + "https://S1.WP.com/_static/??-eJx9jk", + "https://i0.wp.com/example.com/image.png", + "https://a.b.wp.com/script.js", + "https://example.files.wordpress.com/2026/10/image.png", + "https://Another.Files.WordPress.com/2026/10/image.png", + ] + ) + func allowsDomainAndSubdomainsOfWildcard(url: String) { + #expect(scope(naming: ["*.wp.com", "*.files.wordpress.com"]).allows(self.url(url))) + } + + @Test( + "a wildcard is nothing else", + arguments: [ + // Hosts that only look like the domain or its subdomains + "https://notwp.com/script.js", + "https://wp.com.vendor.net/script.js", + "https://files.wordpress.com.vendor.net/image.png", + "https://notfiles.wordpress.com/image.png", + // A domain above it + "https://another.wordpress.com/script.js", + // The domain and its subdomains, in the clear + "http://wp.com/script.js", + "http://s0.wp.com/script.js", + "http://example.files.wordpress.com/2026/10/image.png", + // Another party's host + "https://cdn.vendor.net/script.js", + ] + ) + func deniesAllButDomainAndSubdomainsOfWildcard(url: String) { + #expect(!scope(naming: ["*.wp.com", "*.files.wordpress.com"]).allows(self.url(url))) + } + + /// What a wildcard is over is the app's business: nothing here second-guesses it. + @Test("a wildcard is taken at its word, however much it covers") + func takesWildcardAtItsWord() { + let overTopLevelDomain = scope(naming: ["*.com"]) + #expect(overTopLevelDomain.allows(url("https://vendor.com/script.js"))) + #expect(overTopLevelDomain.allows(url("https://cdn.vendor.com/script.js"))) + #expect(!overTopLevelDomain.allows(url("https://vendor.net/script.js"))) + #expect(!overTopLevelDomain.allows(url("http://vendor.com/script.js"))) + + let overRegistry = scope(naming: ["*.co.uk"]) + #expect(overRegistry.allows(url("https://vendor.co.uk/script.js"))) + #expect(!overRegistry.allows(url("https://vendor.org.uk/script.js"))) + } + + // MARK: - Names + + @Test("a name can end with the dot that ends a fully qualified one", arguments: ["*.wp.com.", " *.wp.com "]) + func allowsFullyQualifiedName(domain: String) { + #expect(scope(naming: [domain]).allows(url("https://s0.wp.com/script.js"))) + } + + @Test( + "a name that is neither a host nor a wildcard over a domain names nowhere", + arguments: [ + "", " ", "*", "*.", ".", + // Only `*.` in front is a wildcard + ".wp.com", "s*.wp.com", "*wp.com", "wp.*", "s0.*.com", "*.*.com", "*.*.wp.com", + // Not a host + "wp..com", "https://s0.wp.com", + ] + ) + func ignoresMalformedName(domain: String) { + let scope = scope(naming: [domain]) + + #expect(!scope.allows(url("https://s0.wp.com/script.js"))) + #expect(!scope.allows(url("https://wp.com/script.js"))) + #expect(!scope.allows(url("https://cdn.vendor.net/script.js"))) + } + + // MARK: - Configuration + + @Test("the scope for a configuration takes the places the configuration names") + func takesNamesFromConfiguration() { + let builder = EditorConfigurationBuilder( + postType: .post, + siteURL: url("https://example.com"), + siteApiRoot: url("https://public-api.wordpress.com/") + ) + let asset = url("https://s0.wp.com/script.js") + + // Where a site's API is says nothing about where else its credentials may go + #expect(!EditorAuthorizationScope(configuration: builder.build()).allows(asset)) + #expect( + EditorAuthorizationScope(configuration: builder.setAuthHeaderDomains(["*.wp.com"]).build()).allows(asset) + ) + } +} diff --git a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift index a497a0dda..bee71b878 100644 --- a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift @@ -169,6 +169,17 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures { #expect(config.authHeader == "Bearer token123") } + @Test("setAuthHeaderDomains updates authHeaderDomains, which names none by default") + func setAuthHeaderDomainsUpdatesAuthHeaderDomains() { + #expect(makeConfigurationBuilder().build().authHeaderDomains.isEmpty) + + let config = makeConfigurationBuilder() + .setAuthHeaderDomains(["*.wp.com", "*.files.wordpress.com"]) + .build() + + #expect(config.authHeaderDomains == ["*.wp.com", "*.files.wordpress.com"]) + } + @Test("setEditorSettings updates editorSettings") func setEditorSettingsUpdatesEditorSettings() { let settings = #"{"colors":[]}"# @@ -341,6 +352,7 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures { .setSiteApiNamespace(["wp/v2"]) .setNamespaceExcludedPaths(["/oembed"]) .setAuthHeader("Bearer abc") + .setAuthHeaderDomains(["*.wp.com"]) .setEditorSettings("{}") .setLocale("ja_JP") .setNativeInserterEnabled(true) diff --git a/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift b/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift index 573c1b6a2..08761751a 100644 --- a/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift @@ -55,6 +55,18 @@ struct GBKitGlobalTests: MakesTestFixtures { #expect(global.siteApiRoot == Self.testApiRoot) } + @Test("maps authHeaderDomains from configuration, for the editor's own requests") + func mapsAuthHeaderDomains() throws { + let configuration = makeConfiguration().toBuilder() + .setAuthHeaderDomains(["*.wp.com", "*.files.wordpress.com"]) + .build() + + let global = try GBKitGlobal(configuration: configuration, dependencies: makeDependencies()) + + #expect(global.authHeaderDomains == ["*.wp.com", "*.files.wordpress.com"]) + #expect(try global.toString().contains(#""authHeaderDomains":["*.wp.com","*.files.wordpress.com"]"#)) + } + @Test("maps themeStyles from configuration") func mapsThemeStyles() throws { let withThemeStyles = makeConfiguration(shouldUseThemeStyles: true) diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index ce6470856..624f64d91 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -1,6 +1,7 @@ import apiFetch from '@wordpress/api-fetch'; import { getQueryArg } from '@wordpress/url'; import { __ } from '@wordpress/i18n'; +import { isWithinAuthorizationScope } from './authorization-scope'; import { getGBKit, POST_FALLBACKS } from './bridge'; import { info, error as logError } from './logger'; import { ensureTrailingSlash, stripTrailingSlash } from './url'; @@ -94,20 +95,24 @@ function apiPathModifierMiddleware( options, next ) { /** * Middleware that handles token-based authentication. * - * When an auth header is present, this middleware: + * When an auth header is present, and the request is one the site's + * credentials may go with, this middleware: * 1. Adds the Authorization header to the request * 2. Sets credentials to 'omit' to prevent cookies from interfering with token authentication * * This prevents authentication conflicts where browser cookies could disrupt * token-based authentication by being sent alongside the Authorization header. * + * A request to anywhere but the site goes out without the header: the site's + * credentials are not for another party's service. + * * @type {APIFetchMiddleware} */ function tokenAuthMiddleware( options, next ) { const { authHeader } = getGBKit(); options.headers = options.headers || {}; - if ( authHeader ) { + if ( authHeader && isForSite( options ) ) { options.headers.Authorization = authHeader; options.credentials = 'omit'; // Avoid cookies disrupting token authentication } @@ -115,6 +120,24 @@ function tokenAuthMiddleware( options, next ) { return next( options ); } +/** + * Whether a request is one the site's credentials may go with. + * + * A request by `path` is for the site's API: the root URL middleware, which + * runs later, joins the path to the API root and replaces any `url`. A request + * by `url` alone can be for anywhere. + * + * @param {Object} options The api-fetch options. + * @return {boolean} Whether the request may carry the site's credentials. + */ +function isForSite( options ) { + if ( typeof options.path === 'string' ) { + return true; + } + + return isWithinAuthorizationScope( options.url, getGBKit() ); +} + /** * Middleware to filter out requests to specific endpoints. * diff --git a/src/utils/api-fetch.test.js b/src/utils/api-fetch.test.js index 5de83e53c..32fb745b2 100644 --- a/src/utils/api-fetch.test.js +++ b/src/utils/api-fetch.test.js @@ -113,6 +113,89 @@ describe( 'api-fetch credentials handling', () => { expect( options.headers.Authorization ).toBe( 'Bearer override-token' ); } ); + it.each( [ + [ 'the site', 'https://example.com/wp-admin/admin-ajax.php' ], + [ "the site's API", 'https://example.com/wp-json/wp/v2/posts' ], + ] )( + 'should send the auth header with a request by URL to %s', + async ( _, url ) => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + siteApiRoot: 'https://example.com/wp-json/', + authHeader: 'Bearer test-token', + siteApiNamespace: [ 'wp/v2' ], + namespaceExcludedPaths: [], + } ); + + try { + await apiFetch( { url } ); + } catch { + // Ignore errors from the actual fetch + } + + const [ requestUrl, options ] = global.fetch.mock.calls[ 0 ]; + + // api-fetch adds its own query to the URL + expect( requestUrl ).toContain( url ); + expect( options.headers.Authorization ).toBe( 'Bearer test-token' ); + expect( options.credentials ).toBe( 'omit' ); + } + ); + + it.each( [ + [ "another party's host", 'https://api.vendor.net/v1/things' ], + [ 'a lookalike host', 'https://example.com.vendor.net/v1/things' ], + [ "the site's host in the clear", 'http://example.com/wp-json/' ], + ] )( + 'should not send the auth header with a request by URL to %s', + async ( _, url ) => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + siteApiRoot: 'https://example.com/wp-json/', + authHeader: 'Bearer test-token', + siteApiNamespace: [ 'wp/v2' ], + namespaceExcludedPaths: [], + } ); + + try { + await apiFetch( { url } ); + } catch { + // Ignore errors from the actual fetch + } + + const [ requestUrl, options ] = global.fetch.mock.calls[ 0 ]; + + // api-fetch adds its own query to the URL + expect( requestUrl ).toContain( url ); + expect( options.headers?.Authorization ).toBeUndefined(); + expect( options.credentials ).not.toBe( 'omit' ); + } + ); + + it( 'should send the auth header to a place the app names', async () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.wordpress.com', + siteApiRoot: 'https://public-api.wordpress.com/', + authHeader: 'Bearer test-token', + authHeaderDomains: [ '*.wp.com' ], + siteApiNamespace: [ 'sites/123/' ], + namespaceExcludedPaths: [], + } ); + + try { + await apiFetch( { url: 'https://widgets.wp.com/things' } ); + await apiFetch( { url: 'https://api.vendor.net/v1/things' } ); + } catch { + // Ignore errors from the actual fetch + } + + const headers = global.fetch.mock.calls.map( + ( [ , options ] ) => options.headers?.Authorization + ); + + expect( headers ).toEqual( [ 'Bearer test-token', undefined ] ); + } ); + describe( 'filterEndpointsMiddleware', () => { it( 'filters the post endpoint when restBase and restNamespace are provided', async () => { bridge.getGBKit.mockReturnValue( { diff --git a/src/utils/authorization-scope.js b/src/utils/authorization-scope.js new file mode 100644 index 000000000..252239472 --- /dev/null +++ b/src/utils/authorization-scope.js @@ -0,0 +1,103 @@ +/** + * Whether a request to `requestUrl` may carry the site's credentials. + * + * Credentials go to the site and to its REST API, and nowhere else. A request + * can be for anywhere — a plugin's block can call another party's service — + * and that party has no business receiving them. + * + * An app that knows of other places its credentials belong names them in + * `authHeaderDomains`: a site reached through WordPress.com has assets on + * `wp.com` and files on `files.wordpress.com`, run by the same party as its + * API. Only requests over HTTPS qualify, and each name is taken exactly as + * written: + * + * - `s0.wp.com` is that one host, and not its subdomains. + * - `*.wp.com` is `wp.com` and every subdomain of it. + * + * A wildcard is taken at its word: `*.com` is every `.com` site. + * + * For the site and its API, scheme, host, and port must all match, so that a + * lookalike host (e.g. `https://example.com.evil.com`) or the site's host in + * the clear is refused. + * + * @param {string} requestUrl The URL of the outgoing request. + * @param {Object} site The site the credentials belong to. + * @param {string} [site.siteURL] The site's home URL. + * @param {string} [site.siteApiRoot] The root URL of the site's API. + * @param {string[]} [site.authHeaderDomains] Other places that may receive the credentials. + * @return {boolean} Whether the request may carry the site's credentials. + */ +export function isWithinAuthorizationScope( + requestUrl, + { siteURL, siteApiRoot, authHeaderDomains = [] } = {} +) { + const request = parseUrl( requestUrl ); + + if ( ! request ) { + return false; + } + + if ( + request.origin === parseUrl( siteURL )?.origin || + request.origin === parseUrl( siteApiRoot )?.origin + ) { + return true; + } + + return ( + request.protocol === 'https:' && + authHeaderDomains + .map( parseName ) + .some( ( name ) => name?.matches( request.hostname ) ) + ); +} + +/** + * Parses an absolute `http` or `https` URL. + * + * @param {string} [value] The URL to parse. + * @return {URL|undefined} The parsed URL, or `undefined` for anything else: + * a relative URL, or a scheme with no origin of its + * own, belongs to no site. + */ +function parseUrl( value ) { + try { + const url = new URL( value ); + return [ 'http:', 'https:' ].includes( url.protocol ) ? url : undefined; + } catch { + return undefined; + } +} + +/** + * Parses what an app names as a place for the site's credentials: one host, + * or with `*.` in front, a domain and every subdomain of it. + * + * @param {string} name The name as the app wrote it. + * @return {{matches: function(string): boolean}|undefined} What the name + * covers, or `undefined` for a name that is neither: an empty one, + * or one with a wildcard anywhere but in front. + */ +function parseName( name ) { + // Without the dot that ends a fully qualified name + const trimmed = String( name ?? '' ) + .trim() + .toLowerCase() + .replace( /\.$/, '' ); + const isWildcard = trimmed.startsWith( '*.' ); + const domain = isWildcard ? trimmed.slice( 2 ) : trimmed; + const labels = domain.split( '.' ); + + if ( labels.some( ( label ) => label === '' || label.includes( '*' ) ) ) { + return undefined; + } + + if ( ! isWildcard ) { + return { matches: ( hostname ) => hostname === domain }; + } + + return { + matches: ( hostname ) => + hostname === domain || hostname.endsWith( `.${ domain }` ), + }; +} diff --git a/src/utils/authorization-scope.test.js b/src/utils/authorization-scope.test.js new file mode 100644 index 000000000..8533a3eac --- /dev/null +++ b/src/utils/authorization-scope.test.js @@ -0,0 +1,250 @@ +import { describe, it, expect } from 'vitest'; +import { isWithinAuthorizationScope } from './authorization-scope'; + +const site = { + siteURL: 'https://example.com', + siteApiRoot: 'https://example.com/wp-json/', +}; + +/** + * Whether the test site's credentials may go with a request to `url`, when + * the app names `authHeaderDomains` as well. + * + * @param {string} url The URL of the request. + * @param {string[]} authHeaderDomains The places the app names. + * @return {boolean} Whether the request may carry the credentials. + */ +function allows( url, authHeaderDomains = [] ) { + return isWithinAuthorizationScope( url, { ...site, authHeaderDomains } ); +} + +describe( 'isWithinAuthorizationScope', () => { + describe( 'the site and its API', () => { + it.each( [ + 'https://example.com/wp-json/wp/v2/posts/1?context=edit', + 'https://example.com/wp-admin/admin-ajax.php', + 'https://EXAMPLE.com/wp-content/themes/theme/style.css', + 'https://example.com:443/wp-json/', + ] )( 'allows the site and its REST API: %s', ( url ) => { + expect( allows( url ) ).toBe( true ); + } ); + + it.each( [ + // Another party's host + 'https://api.vendor.net/v1/things', + // Hosts that only look like the site's + 'https://example.com.vendor.net/v1/things', + 'https://notexample.com/v1/things', + 'https://cdn.example.com/script.js', + // The site's host, but not the place it was configured with + 'http://example.com/wp-json/wp/v2/posts', + 'https://example.com:8443/wp-json/', + // Places an app could name, and this one hasn't + 'https://s0.wp.com/wp-content/plugins/plugin/script.js', + 'https://example.files.wordpress.com/2026/10/image.png', + // No site at all + '/wp-json/wp/v2/posts', + 'file:///wp-content/script.js', + 'data:text/javascript,', + '', + undefined, + ] )( + 'refuses everywhere else, unless the app names the place: %s', + ( url ) => { + expect( allows( url ) ).toBe( false ); + } + ); + + it( 'allows a site served in the clear its own credentials', () => { + const local = { + siteURL: 'http://localhost:8881', + siteApiRoot: 'http://localhost:8881/wp-json/', + }; + + expect( + isWithinAuthorizationScope( + 'http://localhost:8881/wp-json/wp/v2/posts', + local + ) + ).toBe( true ); + expect( + isWithinAuthorizationScope( + 'http://localhost:9999/script.js', + local + ) + ).toBe( false ); + } ); + + it( 'refuses every request when no site is configured', () => { + expect( + isWithinAuthorizationScope( 'https://example.com/wp-json/', {} ) + ).toBe( false ); + expect( + isWithinAuthorizationScope( 'https://example.com/wp-json/' ) + ).toBe( false ); + } ); + + it( "infers nothing from where a site's API is", () => { + const reachedThroughWordPressDotCom = { + siteURL: 'https://example.com', + siteApiRoot: 'https://public-api.wordpress.com/', + }; + + expect( + isWithinAuthorizationScope( + 'https://s0.wp.com/script.js', + reachedThroughWordPressDotCom + ) + ).toBe( false ); + } ); + } ); + + describe( 'a named host', () => { + it.each( [ + 'https://s0.wp.com/wp-content/plugins/plugin/script.js?m=1', + 'https://S0.WP.com/script.js', + ] )( 'is that host, in any case, over HTTPS: %s', ( url ) => { + expect( allows( url, [ 'S0.wp.com' ] ) ).toBe( true ); + } ); + + it.each( [ + // Its siblings, its subdomains, and the domain it's under + 'https://s1.wp.com/script.js', + 'https://cdn.s0.wp.com/script.js', + 'https://wp.com/script.js', + // Hosts that only look like it + 'https://s0.wp.com.vendor.net/script.js', + 'https://nots0.wp.com/script.js', + // The host itself, in the clear + 'http://s0.wp.com/script.js', + ] )( 'is no other host: %s', ( url ) => { + expect( allows( url, [ 's0.wp.com' ] ) ).toBe( false ); + } ); + + // Naming a host never reaches past it, so even a top-level domain + // names only itself. + it.each( [ 'com', 'net', 'cool', 'uk' ] )( + 'covers nothing under it, even a top-level domain: %s', + ( domain ) => { + expect( + allows( `https://vendor.${ domain }/script.js`, [ domain ] ) + ).toBe( false ); + expect( + allows( `https://cdn.vendor.${ domain }/script.js`, [ + domain, + ] ) + ).toBe( false ); + } + ); + } ); + + describe( 'a wildcard', () => { + const named = [ '*.wp.com', '*.files.wordpress.com' ]; + + it.each( [ + 'https://wp.com/script.js', + 'https://WP.com/script.js', + 'https://files.wordpress.com/image.png', + 'https://s0.wp.com/wp-content/plugins/plugin/script.js?m=1', + 'https://S1.WP.com/_static/??-eJx9jk', + 'https://i0.wp.com/example.com/image.png', + 'https://a.b.wp.com/script.js', + 'https://example.files.wordpress.com/2026/10/image.png', + 'https://Another.Files.WordPress.com/2026/10/image.png', + ] )( + "is the domain it's over and every subdomain of it, however deep: %s", + ( url ) => { + expect( allows( url, named ) ).toBe( true ); + } + ); + + it.each( [ + // Hosts that only look like the domain or its subdomains + 'https://notwp.com/script.js', + 'https://wp.com.vendor.net/script.js', + 'https://files.wordpress.com.vendor.net/image.png', + 'https://notfiles.wordpress.com/image.png', + // A domain above it + 'https://another.wordpress.com/script.js', + // The domain and its subdomains, in the clear + 'http://wp.com/script.js', + 'http://s0.wp.com/script.js', + 'http://example.files.wordpress.com/2026/10/image.png', + // Another party's host + 'https://api.vendor.net/v1/things', + ] )( 'is nothing else: %s', ( url ) => { + expect( allows( url, named ) ).toBe( false ); + } ); + + // What a wildcard is over is the app's business: nothing here + // second-guesses it. + it( 'is taken at its word, however much it covers', () => { + const overTopLevelDomain = [ '*.com' ]; + + expect( + allows( 'https://vendor.com/script.js', overTopLevelDomain ) + ).toBe( true ); + expect( + allows( 'https://cdn.vendor.com/script.js', overTopLevelDomain ) + ).toBe( true ); + expect( + allows( 'https://vendor.net/script.js', overTopLevelDomain ) + ).toBe( false ); + expect( + allows( 'http://vendor.com/script.js', overTopLevelDomain ) + ).toBe( false ); + + const overRegistry = [ '*.co.uk' ]; + + expect( + allows( 'https://vendor.co.uk/script.js', overRegistry ) + ).toBe( true ); + expect( + allows( 'https://vendor.org.uk/script.js', overRegistry ) + ).toBe( false ); + } ); + } ); + + describe( 'names', () => { + it.each( [ '*.wp.com.', ' *.wp.com ' ] )( + 'can end with the dot that ends a fully qualified one: %s', + ( domain ) => { + expect( + allows( 'https://s0.wp.com/script.js', [ domain ] ) + ).toBe( true ); + } + ); + + it.each( [ + '', + ' ', + '*', + '*.', + '.', + // Only `*.` in front is a wildcard + '.wp.com', + 's*.wp.com', + '*wp.com', + 'wp.*', + 's0.*.com', + '*.*.com', + '*.*.wp.com', + // Not a host + 'wp..com', + 'https://s0.wp.com', + null, + undefined, + ] )( + 'name nowhere when neither a host nor a wildcard over a domain: %s', + ( domain ) => { + for ( const url of [ + 'https://s0.wp.com/script.js', + 'https://wp.com/script.js', + 'https://api.vendor.net/v1/things', + ] ) { + expect( allows( url, [ domain ] ) ).toBe( false ); + } + } + ); + } ); +} ); diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 6b2462c8d..ba59f4ca5 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -240,6 +240,7 @@ export function onNetworkRequest( requestData ) { * @property {string[]} [siteApiNamespace] The namespace of the site's API; if multiple namespaces are provided, the first one is used as the default. * @property {string[]} [namespaceExcludedPaths] The paths that should not be namespaced. * @property {string} [authHeader] The authentication header. + * @property {string[]} [authHeaderDomains] Places the authentication header may be sent to, besides the site and its API: a host, or `*.` and a domain for it and its subdomains. * @property {string} [hideTitle] Whether to hide the title. * @property {Post} [post] The post data. * @property {boolean} [enableNetworkLogging] Enables logging of all network requests/responses to the native host via onNetworkRequest bridge method.