From 76c032eb2def8a24147f5315ea0c4b4d014c87fe Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 24 Sep 2026 13:31:29 -0400 Subject: [PATCH 01/19] fix(android): inject the editor globals into the editor document only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `onPageStarted` received the loaded URL and discarded it, so any page reaching the main frame was handed `window.GBKit` — the site credential and the upload server's port and token. `shouldOverrideUrlLoading` admits several site URLs into that frame, and since #181 the editor shares an origin with the site, so those pages are served by the site's own theme and plugins. Check the destination before advertising the globals or starting the upload server, matching the dev server by authority so a local site on another port of the same host is not mistaken for the editor. Readiness still resets for any page, since navigating away from the editor leaves it unusable either way. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016zAv5Tqtqr1bcYWVDJHGAh --- .../org/wordpress/gutenberg/GutenbergView.kt | 35 ++++++++-- .../gutenberg/GutenbergViewNavigationTest.kt | 70 +++++++++++++++++++ .../GutenbergViewUploadServerTest.kt | 20 +++++- 3 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 22168f5b2..3d1577522 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -449,7 +449,7 @@ class GutenbergView : FrameLayout { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) - onEditorPageStarted() + onEditorPageStarted(url) } override fun shouldInterceptRequest( @@ -678,20 +678,28 @@ class GutenbergView : FrameLayout { } /** - * Invoked when the editor page begins loading. Starts the upload server once — - * capturing the [mediaUploadDelegate] provided before load — then advertises - * the editor globals (including the server's port and token) to the page. + * Invoked when a page begins loading in the main frame. Starts the upload server + * once — capturing the [mediaUploadDelegate] provided before load — then + * advertises the editor globals (including the server's port and token) to the + * page. * * Starting the server here, on the UI thread, rather than from the * [mediaUploadDelegate] setter keeps its whole lifecycle — start here, stop in * [onDetachedFromWindow] — on the UI thread, so it can't race a * background-thread delegate assignment. */ - private fun onEditorPageStarted() { + private fun onEditorPageStarted(url: String?) { // Readiness belongs to the page: a new page, including one a reload starts, // is not ready until it reports `onEditorLoaded`. isEditorLoaded = false didFireEditorLoaded = false + + // The globals carry the site credential and the upload server's token, so + // they go to the editor document alone. `shouldOverrideUrlLoading` admits + // other pages into this frame, and on Android the editor shares an origin + // with the site, so the destination is checked rather than assumed. + if (!isEditorUrl(url)) return + if (!hasStartedLoading) { hasStartedLoading = true startUploadServer() @@ -699,6 +707,23 @@ class GutenbergView : FrameLayout { setGlobalJavaScriptVariables() } + /** + * Whether [url] is the editor document this view loaded. + * + * A configured dev server replaces the bundled assets as the editor, mirroring + * the URL [loadEditor] chooses, so only one of the two can match. + */ + private fun isEditorUrl(url: String?): Boolean { + if (url.isNullOrEmpty()) return false + val uri = Uri.parse(url) + + if (BuildConfig.GUTENBERG_EDITOR_URL.isNotEmpty()) { + return isDevServerUrl(uri, BuildConfig.GUTENBERG_EDITOR_URL) + } + + return uri.authority == assetAuthority && uri.path?.startsWith("/assets/") == true + } + private fun setGlobalJavaScriptVariables() { val gbKit = GBKitGlobal.fromConfiguration( configuration, diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt new file mode 100644 index 000000000..6432e5488 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -0,0 +1,70 @@ +package org.wordpress.gutenberg + +import kotlinx.coroutines.test.TestScope +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies + +/** + * What the editor's main frame admits, and which document the editor globals reach. + * + * On Android the editor loads from the site's own origin, so the site's ordinary + * pages are one navigation away from the frame holding the site credential. + */ +@RunWith(RobolectricTestRunner::class) +class GutenbergViewNavigationTest { + + private val testScope = TestScope() + + /** A view whose configuration carries a recognizable credential. */ + private fun configuredSiteView() = GutenbergView( + EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/") + .setAuthHeader("Bearer secret-credential") + .build(), + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + + /** + * The editor document for [siteUrl], mirroring the fallback in `loadEditor` so + * this holds whether or not a local `GUTENBERG_EDITOR_URL` dev server is set. + */ + private fun editorUrlFor(siteUrl: String) = BuildConfig.GUTENBERG_EDITOR_URL + .ifEmpty { "$siteUrl/assets/index.html" } + + @Test + fun `onPageStarted injects the configuration into the editor document`() { + val siteView = configuredSiteView() + val webView = siteView.editorWebView + + webView.webViewClient.onPageStarted(webView, editorUrlFor("https://example.com"), null) + + assertTrue( + "the editor document should receive the globals it boots from", + shadowOf(webView).lastEvaluatedJavascript.orEmpty().contains("window.GBKit") + ) + } + + @Test + fun `onPageStarted withholds the configuration from a non-editor page`() { + // `shouldOverrideUrlLoading` admits some site URLs into this frame, and on + // Android the editor shares an origin with the site, so a page served by the + // site's theme and plugins can load here. It must not receive the credential. + val siteView = configuredSiteView() + val webView = siteView.editorWebView + + webView.webViewClient.onPageStarted(webView, "https://example.com/wp-json/wp/v2/posts", null) + + assertFalse( + "a non-editor page must not receive the site credential", + shadowOf(webView).lastEvaluatedJavascript.orEmpty().contains("secret-credential") + ) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt index 293c08778..2baf66055 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt @@ -20,6 +20,17 @@ import org.wordpress.gutenberg.model.EditorDependencies @Config(manifest = Config.NONE) class GutenbergViewUploadServerTest { + private companion object { + /** + * The editor document for [makeView]'s site, which the globals are scoped to. + * + * Mirrors the fallback in `loadEditor`, so this holds whether or not a local + * `GUTENBERG_EDITOR_URL` dev server is configured. + */ + val EDITOR_URL = BuildConfig.GUTENBERG_EDITOR_URL + .ifEmpty { "https://example.com/assets/index.html" } + } + private val testScope = TestScope() private fun makeView(): GutenbergView { @@ -46,10 +57,13 @@ class GutenbergViewUploadServerTest { * `onPageStarted`) to simulate the editor page beginning to load — the point at * which the delegate is captured and the upload server starts. */ - private fun startLoading(view: GutenbergView) { - val method = GutenbergView::class.java.getDeclaredMethod("onEditorPageStarted") + private fun startLoading(view: GutenbergView, url: String = EDITOR_URL) { + val method = GutenbergView::class.java.getDeclaredMethod( + "onEditorPageStarted", + String::class.java + ) method.isAccessible = true - method.invoke(view) + method.invoke(view, url) } /** Invokes the protected `onDetachedFromWindow` lifecycle callback. */ From 80a1b4b079cbe6a71e373a67eac0bcb58b9e90ae Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 25 Sep 2026 11:16:56 -0400 Subject: [PATCH 02/19] fix(android): match the REST API by its configured root, not by substring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navigation policy admitted any site URL whose path contained `/wp-json/` or whose query contained `rest_route=`. Both are satisfied by ordinary pages — `/blog/wp-json/a-post`, or any URL carrying `?utm_campaign=rest_route=x` — which WordPress serves with the site's theme and plugins, inside the editor's own frame. Compare against the configured `siteApiRoot` instead: a path under its path root, or `rest_route` as an actual query parameter. Reading the root also settles the cases the characters cannot, so the same path is the API on a subdirectory install and a page on a root install. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016zAv5Tqtqr1bcYWVDJHGAh --- .../org/wordpress/gutenberg/GutenbergView.kt | 27 ++++++-- .../gutenberg/GutenbergViewNavigationTest.kt | 64 +++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 3d1577522..ed29954e4 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -511,10 +511,8 @@ class GutenbergView : FrameLayout { } // Allow WordPress REST API - if (url.authority == originAuthority(configuration.siteApiRoot)) { - if (url.path?.contains("/wp-json/") == true || url.query?.contains("rest_route=") == true) { - return false - } + if (isSiteRestApiUrl(url)) { + return false } // Allow local development server if configured @@ -611,6 +609,27 @@ class GutenbergView : FrameLayout { } } + /** + * Whether [url] addresses this site's REST API, by either root a host can + * configure: a path root such as `/wp-json/`, or the `rest_route` query form + * used when pretty permalinks are unavailable. + * + * Matched against the configured root rather than by substring. The site serves + * ordinary pages from the same origin, and a path or query that merely contains + * `/wp-json/` or `rest_route=` belongs to one of those, not to the API. + */ + private fun isSiteRestApiUrl(url: Uri): Boolean { + val apiAuthority = originAuthority(configuration.siteApiRoot) ?: return false + if (url.authority != apiAuthority) return false + + if (url.getQueryParameter("rest_route") != null) return true + + // A root of `/` would match every path on the site, so it is no evidence. + val apiRootPath = Uri.parse(configuration.siteApiRoot).path + ?.takeIf { it.length > 1 } ?: return false + return url.path?.startsWith(apiRootPath) == true + } + /** * Loads the editor with the given dependencies. * diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt index 6432e5488..07b6667f8 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -1,10 +1,14 @@ package org.wordpress.gutenberg +import android.net.Uri +import android.webkit.WebResourceRequest import kotlinx.coroutines.test.TestScope import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf @@ -39,6 +43,66 @@ class GutenbergViewNavigationTest { private fun editorUrlFor(siteUrl: String) = BuildConfig.GUTENBERG_EDITOR_URL .ifEmpty { "$siteUrl/assets/index.html" } + private fun opensExternally(view: GutenbergView, url: String): Boolean { + val request = mock(WebResourceRequest::class.java) + `when`(request.url).thenReturn(Uri.parse(url)) + return view.editorWebView.webViewClient.shouldOverrideUrlLoading(view.editorWebView, request) + } + + @Test + fun `shouldOverrideUrlLoading blocks a site page whose path merely contains the API root path`() { + // This site's API root is `/wp-json/`, so `/blog/` here is a page path and + // WordPress serves it with the site's theme and plugins. + val result = opensExternally(configuredSiteView(), "https://example.com/blog/wp-json/a-post") + + assertTrue("a page whose path merely contains /wp-json/ should open externally", result) + } + + @Test + fun `shouldOverrideUrlLoading allows REST API URLs for a subdirectory install`() { + // The same path the previous test blocks is the API when the site lives in a + // subdirectory, so the root the host configured decides, not the characters. + val siteView = GutenbergView( + EditorConfiguration.builder("https://example.com/blog", "https://example.com/blog/wp-json/") + .build(), + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + + val result = opensExternally(siteView, "https://example.com/blog/wp-json/a-post") + + assertFalse("a subdirectory install's API URLs should load in the WebView", result) + } + + @Test + fun `shouldOverrideUrlLoading blocks a site page whose query merely contains rest_route`() { + // `rest_route=` appears inside another parameter's value, not as a parameter. + val result = opensExternally( + configuredSiteView(), + "https://example.com/a-page/?utm_campaign=rest_route=x" + ) + + assertTrue("a page whose query merely contains rest_route= should open externally", result) + } + + @Test + fun `shouldOverrideUrlLoading blocks site pages when the API root has no path`() { + // A root of `/` prefixes every path on the site, so it is no evidence that a + // URL is the API. Such a root is matched by its `rest_route` form alone. + val siteView = GutenbergView( + EditorConfiguration.builder("https://example.com", "https://example.com/") + .build(), + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + + val result = opensExternally(siteView, "https://example.com/any-page") + + assertTrue("a pathless API root must not admit the whole site", result) + } + @Test fun `onPageStarted injects the configuration into the editor document`() { val siteView = configuredSiteView() From 521f100e3c6bd684bb245007d181acfff0a62559 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 25 Sep 2026 14:16:20 -0400 Subject: [PATCH 03/19] fix(android): match a rest_route API root by its query alone Without pretty permalinks the API root is `/index.php?rest_route=/`, and `/index.php` also serves ordinary pages, so matching its path admitted them into the editor frame. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../org/wordpress/gutenberg/GutenbergView.kt | 12 ++++++++---- .../gutenberg/GutenbergViewNavigationTest.kt | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index ed29954e4..bbc91a7b0 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -624,10 +624,14 @@ class GutenbergView : FrameLayout { if (url.getQueryParameter("rest_route") != null) return true - // A root of `/` would match every path on the site, so it is no evidence. - val apiRootPath = Uri.parse(configuration.siteApiRoot).path - ?.takeIf { it.length > 1 } ?: return false - return url.path?.startsWith(apiRootPath) == true + val apiRoot = Uri.parse(configuration.siteApiRoot) + val apiRootPath = apiRoot.path.orEmpty() + // A `rest_route` root reaches the API through its query alone; its path, such + // as `/index.php`, also serves the site's ordinary pages. A root of `/` would + // match every path on the site, so it is no evidence either. + return apiRoot.getQueryParameter("rest_route") == null && + apiRootPath.length > 1 && + url.path?.startsWith(apiRootPath) == true } /** diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt index 07b6667f8..f7252992a 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -103,6 +103,23 @@ class GutenbergViewNavigationTest { assertTrue("a pathless API root must not admit the whole site", result) } + @Test + fun `shouldOverrideUrlLoading blocks site pages under a rest_route API root's path`() { + // Without pretty permalinks the root is `/index.php?rest_route=/`, and + // `/index.php` also serves the site's ordinary pages. + val siteView = GutenbergView( + EditorConfiguration.builder("https://example.com", "https://example.com/index.php?rest_route=/") + .build(), + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + + val result = opensExternally(siteView, "https://example.com/index.php?p=1") + + assertTrue("a page under a rest_route root's path should open externally", result) + } + @Test fun `onPageStarted injects the configuration into the editor document`() { val siteView = configuredSiteView() From b8e30a9ca47bb336cc6efe0cd6f7b99b8c7fd5b4 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 25 Sep 2026 14:16:41 -0400 Subject: [PATCH 04/19] fix(android): match the API root path by whole path segments A root without a trailing slash, such as `/wp-json`, otherwise prefixes page slugs like `/wp-json-tutorial/`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../org/wordpress/gutenberg/GutenbergView.kt | 7 ++++--- .../gutenberg/GutenbergViewNavigationTest.kt | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index bbc91a7b0..1ccc8fb12 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -625,13 +625,14 @@ class GutenbergView : FrameLayout { if (url.getQueryParameter("rest_route") != null) return true val apiRoot = Uri.parse(configuration.siteApiRoot) - val apiRootPath = apiRoot.path.orEmpty() + val apiRootPath = apiRoot.path.orEmpty().trimEnd('/') + val path = url.path.orEmpty() // A `rest_route` root reaches the API through its query alone; its path, such // as `/index.php`, also serves the site's ordinary pages. A root of `/` would // match every path on the site, so it is no evidence either. return apiRoot.getQueryParameter("rest_route") == null && - apiRootPath.length > 1 && - url.path?.startsWith(apiRootPath) == true + apiRootPath.isNotEmpty() && + (path == apiRootPath || path.startsWith("$apiRootPath/")) } /** diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt index f7252992a..5aacb43a7 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -120,6 +120,26 @@ class GutenbergViewNavigationTest { assertTrue("a page under a rest_route root's path should open externally", result) } + @Test + fun `shouldOverrideUrlLoading matches an API root without a trailing slash by whole path segments`() { + val siteView = GutenbergView( + EditorConfiguration.builder("https://example.com", "https://example.com/wp-json") + .build(), + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + + assertFalse( + "the API should load in the WebView", + opensExternally(siteView, "https://example.com/wp-json/wp/v2/posts") + ) + assertTrue( + "a page whose slug merely starts with the root should open externally", + opensExternally(siteView, "https://example.com/wp-json-tutorial/") + ) + } + @Test fun `onPageStarted injects the configuration into the editor document`() { val siteView = configuredSiteView() From 9283d5dad2a4169ee978dbb49123e416ab84e4b2 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 25 Sep 2026 14:17:03 -0400 Subject: [PATCH 05/19] fix(android): ignore an empty rest_route when matching the REST API WordPress skips an empty route, including `0`, and renders the requested page with the site's theme instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../java/org/wordpress/gutenberg/GutenbergView.kt | 4 +++- .../gutenberg/GutenbergViewNavigationTest.kt | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 1ccc8fb12..78622cd28 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -622,7 +622,9 @@ class GutenbergView : FrameLayout { val apiAuthority = originAuthority(configuration.siteApiRoot) ?: return false if (url.authority != apiAuthority) return false - if (url.getQueryParameter("rest_route") != null) return true + // WordPress ignores an empty route, including `0`, and serves an ordinary page. + val restRoute = url.getQueryParameter("rest_route") + if (!restRoute.isNullOrEmpty() && restRoute != "0") return true val apiRoot = Uri.parse(configuration.siteApiRoot) val apiRootPath = apiRoot.path.orEmpty().trimEnd('/') diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt index 5aacb43a7..aaab87c1b 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -86,6 +86,18 @@ class GutenbergViewNavigationTest { assertTrue("a page whose query merely contains rest_route= should open externally", result) } + @Test + fun `shouldOverrideUrlLoading blocks a site page with an empty rest_route`() { + // WordPress ignores an empty route and serves the page with the site's theme. + listOf( + "https://example.com/a-page/?rest_route", + "https://example.com/a-page/?rest_route=", + "https://example.com/a-page/?rest_route=0" + ).forEach { url -> + assertTrue("$url should open externally", opensExternally(configuredSiteView(), url)) + } + } + @Test fun `shouldOverrideUrlLoading blocks site pages when the API root has no path`() { // A root of `/` prefixes every path on the site, so it is no evidence that a From 26b903d2790648a22d9c14928c9ed7a66d34abec Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 25 Sep 2026 14:17:22 -0400 Subject: [PATCH 06/19] test(android): assert nothing reaches a non-editor page on start Checking only the last evaluated script would pass if another script ran after an injection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../org/wordpress/gutenberg/GutenbergViewNavigationTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt index aaab87c1b..04d386003 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -4,6 +4,7 @@ import android.net.Uri import android.webkit.WebResourceRequest import kotlinx.coroutines.test.TestScope import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -175,9 +176,10 @@ class GutenbergViewNavigationTest { webView.webViewClient.onPageStarted(webView, "https://example.com/wp-json/wp/v2/posts", null) - assertFalse( + // Nothing evaluated at all, so an injection followed by another script still fails. + assertNull( "a non-editor page must not receive the site credential", - shadowOf(webView).lastEvaluatedJavascript.orEmpty().contains("secret-credential") + shadowOf(webView).lastEvaluatedJavascript ) } } From 2a238d44c0db45d411f172389ac66bbb44a3163c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 25 Sep 2026 14:18:05 -0400 Subject: [PATCH 07/19] fix(android): match editor assets by the scheme the asset loader serves The asset loader serves one scheme, so the same path over the other reaches the site over the network, yet it was admitted and handed the editor globals. One helper now backs both checks so they can't drift apart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../org/wordpress/gutenberg/GutenbergView.kt | 18 +++++++++++---- .../gutenberg/GutenbergViewNavigationTest.kt | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 78622cd28..7ad82ece9 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -111,6 +111,7 @@ class GutenbergView : FrameLayout { private var hasAutofocused = false private lateinit var assetLoader: WebViewAssetLoader private lateinit var assetAuthority: String + private lateinit var assetScheme: String private val configuration: EditorConfiguration private lateinit var dependencies: EditorDependencies @@ -501,7 +502,7 @@ class GutenbergView : FrameLayout { // Allow asset URLs (restrict to the asset path prefix so that // arbitrary site pages don't load inside the WebView when the // asset authority matches the site authority) - if (url.authority == assetAuthority && url.path?.startsWith("/assets/") == true) { + if (isAssetUrl(url)) { return false } @@ -609,6 +610,15 @@ class GutenbergView : FrameLayout { } } + /** + * Whether [url] is served by [assetLoader]. Only the scheme it serves counts, as + * the other scheme on the same authority reaches the site over the network. + */ + private fun isAssetUrl(url: Uri): Boolean = + url.scheme == assetScheme && + url.authority == assetAuthority && + url.path?.startsWith("/assets/") == true + /** * Whether [url] addresses this site's REST API, by either root a host can * configure: a path root such as `/wp-json/`, or the `rest_route` query form @@ -678,8 +688,8 @@ class GutenbergView : FrameLayout { initializeWebView() - val scheme = if (isLocalHttpSite) "http" else "https" - val assetUrl = "$scheme://$assetAuthority$ASSET_PATH_INDEX" + assetScheme = if (isLocalHttpSite) "http" else "https" + val assetUrl = "$assetScheme://$assetAuthority$ASSET_PATH_INDEX" val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL.ifEmpty { assetUrl } @@ -747,7 +757,7 @@ class GutenbergView : FrameLayout { return isDevServerUrl(uri, BuildConfig.GUTENBERG_EDITOR_URL) } - return uri.authority == assetAuthority && uri.path?.startsWith("/assets/") == true + return isAssetUrl(uri) } private fun setGlobalJavaScriptVariables() { diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt index 04d386003..ef82fb662 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -153,6 +153,15 @@ class GutenbergViewNavigationTest { ) } + @Test + fun `shouldOverrideUrlLoading blocks asset paths over a scheme the asset loader does not serve`() { + // An https site's assets are served over https alone, so the same path over + // http goes to the site over the network. + val result = opensExternally(configuredSiteView(), "http://example.com/assets/index.html") + + assertTrue("an asset path over the other scheme should open externally", result) + } + @Test fun `onPageStarted injects the configuration into the editor document`() { val siteView = configuredSiteView() @@ -182,4 +191,17 @@ class GutenbergViewNavigationTest { shadowOf(webView).lastEvaluatedJavascript ) } + + @Test + fun `onPageStarted withholds the configuration from an asset path the network served`() { + val siteView = configuredSiteView() + val webView = siteView.editorWebView + + webView.webViewClient.onPageStarted(webView, "http://example.com/assets/index.html", null) + + assertNull( + "a network-served page must not receive the site credential", + shadowOf(webView).lastEvaluatedJavascript + ) + } } From 9b4074635709342833a78bbe74f818a5b28698c8 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 25 Sep 2026 14:18:44 -0400 Subject: [PATCH 08/19] docs(android): note that only the editor document receives the globals Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../main/java/org/wordpress/gutenberg/GutenbergView.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 7ad82ece9..88494285d 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -714,10 +714,10 @@ class GutenbergView : FrameLayout { } /** - * Invoked when a page begins loading in the main frame. Starts the upload server - * once — capturing the [mediaUploadDelegate] provided before load — then - * advertises the editor globals (including the server's port and token) to the - * page. + * Invoked when any page begins loading in the main frame. Resets readiness for + * every page; for the editor document alone, starts the upload server once — + * capturing the [mediaUploadDelegate] provided before load — then advertises the + * editor globals (including the server's port and token). * * Starting the server here, on the UI thread, rather than from the * [mediaUploadDelegate] setter keeps its whole lifecycle — start here, stop in From 4a3ee13688ea40163cd297c30fbe06cadd5f1cd0 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 25 Sep 2026 14:50:09 -0400 Subject: [PATCH 09/19] fix(android): match the editor document by its exact asset path The asset loader also serves the host app's other bundled pages, some of which load third-party scripts, and those received the editor globals. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../java/org/wordpress/gutenberg/GutenbergView.kt | 3 ++- .../gutenberg/GutenbergViewNavigationTest.kt | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 88494285d..7c67b4e06 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -757,7 +757,8 @@ class GutenbergView : FrameLayout { return isDevServerUrl(uri, BuildConfig.GUTENBERG_EDITOR_URL) } - return isAssetUrl(uri) + // The host app's own bundled pages are asset URLs too, but not the editor. + return isAssetUrl(uri) && uri.path == ASSET_PATH_INDEX } private fun setGlobalJavaScriptVariables() { diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt index ef82fb662..ef4397727 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -192,6 +192,20 @@ class GutenbergViewNavigationTest { ) } + @Test + fun `onPageStarted withholds the configuration from another bundled asset page`() { + // The asset loader serves every page the host app bundles, not only the editor. + val siteView = configuredSiteView() + val webView = siteView.editorWebView + + webView.webViewClient.onPageStarted(webView, "https://example.com/assets/support.html", null) + + assertNull( + "a bundled page other than the editor must not receive the site credential", + shadowOf(webView).lastEvaluatedJavascript + ) + } + @Test fun `onPageStarted withholds the configuration from an asset path the network served`() { val siteView = configuredSiteView() From baa02f5f2fa1165a69f408bd80a5eca230bdac5e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 25 Sep 2026 14:50:36 -0400 Subject: [PATCH 10/19] fix(android): let a rest_route parameter decide over the API root path WordPress lets the parameter override the route a `/wp-json/` path sets, so `/wp-json/?rest_route=` serves the themed front page, yet it passed the path check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../src/main/java/org/wordpress/gutenberg/GutenbergView.kt | 5 +++-- .../org/wordpress/gutenberg/GutenbergViewNavigationTest.kt | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 7c67b4e06..5ff1d1f1e 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -632,9 +632,10 @@ class GutenbergView : FrameLayout { val apiAuthority = originAuthority(configuration.siteApiRoot) ?: return false if (url.authority != apiAuthority) return false - // WordPress ignores an empty route, including `0`, and serves an ordinary page. + // A `rest_route` parameter overrides the route a path root sets, and WordPress + // ignores an empty one, including `0`, to serve an ordinary page. val restRoute = url.getQueryParameter("rest_route") - if (!restRoute.isNullOrEmpty() && restRoute != "0") return true + if (restRoute != null) return restRoute.isNotEmpty() && restRoute != "0" val apiRoot = Uri.parse(configuration.siteApiRoot) val apiRootPath = apiRoot.path.orEmpty().trimEnd('/') diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt index ef4397727..a9005cd47 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -93,7 +93,10 @@ class GutenbergViewNavigationTest { listOf( "https://example.com/a-page/?rest_route", "https://example.com/a-page/?rest_route=", - "https://example.com/a-page/?rest_route=0" + "https://example.com/a-page/?rest_route=0", + // The parameter overrides the route the API root's path would set. + "https://example.com/wp-json/?rest_route=", + "https://example.com/wp-json/wp/v2/posts?rest_route=0" ).forEach { url -> assertTrue("$url should open externally", opensExternally(configuredSiteView(), url)) } From 39894ad9b4d5bd542c2b1b9e696ac95e99053279 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 25 Sep 2026 15:46:37 -0400 Subject: [PATCH 11/19] fix(android): open REST API navigations in the browser The editor reaches the REST API by fetch, which never passes through `shouldOverrideUrlLoading`, so the allowlist only admitted navigations. Those let site pages whose URLs WordPress reads differently from Android, and http API URLs on https sites, replace the editor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../org/wordpress/gutenberg/GutenbergView.kt | 42 +------ .../gutenberg/GutenbergViewNavigationTest.kt | 111 +++--------------- .../wordpress/gutenberg/GutenbergViewTest.kt | 76 ------------ 3 files changed, 17 insertions(+), 212 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 5ff1d1f1e..e45d08008 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -506,22 +506,13 @@ class GutenbergView : FrameLayout { return false } - // Allow WordPress.com REST API - if (url.host == "public-api.wordpress.com") { - return false - } - - // Allow WordPress REST API - if (isSiteRestApiUrl(url)) { - return false - } - // Allow local development server if configured if (isDevServerUrl(url, BuildConfig.GUTENBERG_EDITOR_URL)) { return false } - // For all other URLs, open in external browser + // For all other URLs, open in external browser. This includes the site's + // REST API: the editor reaches it by fetch, which never passes through here. val intent = Intent(Intent.ACTION_VIEW, url) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) view?.context?.startActivity(intent) @@ -619,35 +610,6 @@ class GutenbergView : FrameLayout { url.authority == assetAuthority && url.path?.startsWith("/assets/") == true - /** - * Whether [url] addresses this site's REST API, by either root a host can - * configure: a path root such as `/wp-json/`, or the `rest_route` query form - * used when pretty permalinks are unavailable. - * - * Matched against the configured root rather than by substring. The site serves - * ordinary pages from the same origin, and a path or query that merely contains - * `/wp-json/` or `rest_route=` belongs to one of those, not to the API. - */ - private fun isSiteRestApiUrl(url: Uri): Boolean { - val apiAuthority = originAuthority(configuration.siteApiRoot) ?: return false - if (url.authority != apiAuthority) return false - - // A `rest_route` parameter overrides the route a path root sets, and WordPress - // ignores an empty one, including `0`, to serve an ordinary page. - val restRoute = url.getQueryParameter("rest_route") - if (restRoute != null) return restRoute.isNotEmpty() && restRoute != "0" - - val apiRoot = Uri.parse(configuration.siteApiRoot) - val apiRootPath = apiRoot.path.orEmpty().trimEnd('/') - val path = url.path.orEmpty() - // A `rest_route` root reaches the API through its query alone; its path, such - // as `/index.php`, also serves the site's ordinary pages. A root of `/` would - // match every path on the site, so it is no evidence either. - return apiRoot.getQueryParameter("rest_route") == null && - apiRootPath.isNotEmpty() && - (path == apiRootPath || path.startsWith("$apiRootPath/")) - } - /** * Loads the editor with the given dependencies. * diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt index a9005cd47..bbfd4a264 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -3,7 +3,6 @@ package org.wordpress.gutenberg import android.net.Uri import android.webkit.WebResourceRequest import kotlinx.coroutines.test.TestScope -import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -51,111 +50,31 @@ class GutenbergViewNavigationTest { } @Test - fun `shouldOverrideUrlLoading blocks a site page whose path merely contains the API root path`() { - // This site's API root is `/wp-json/`, so `/blog/` here is a page path and - // WordPress serves it with the site's theme and plugins. - val result = opensExternally(configuredSiteView(), "https://example.com/blog/wp-json/a-post") - - assertTrue("a page whose path merely contains /wp-json/ should open externally", result) - } - - @Test - fun `shouldOverrideUrlLoading allows REST API URLs for a subdirectory install`() { - // The same path the previous test blocks is the API when the site lives in a - // subdirectory, so the root the host configured decides, not the characters. - val siteView = GutenbergView( - EditorConfiguration.builder("https://example.com/blog", "https://example.com/blog/wp-json/") - .build(), - EditorDependencies.empty, - testScope, - RuntimeEnvironment.getApplication() - ) - - val result = opensExternally(siteView, "https://example.com/blog/wp-json/a-post") - - assertFalse("a subdirectory install's API URLs should load in the WebView", result) - } - - @Test - fun `shouldOverrideUrlLoading blocks a site page whose query merely contains rest_route`() { - // `rest_route=` appears inside another parameter's value, not as a parameter. - val result = opensExternally( - configuredSiteView(), - "https://example.com/a-page/?utm_campaign=rest_route=x" - ) - - assertTrue("a page whose query merely contains rest_route= should open externally", result) + fun `shouldOverrideUrlLoading opens REST API URLs externally`() { + // The editor reaches the API by fetch, which never navigates the frame. + listOf( + "https://example.com/wp-json/wp/v2/posts", + "https://example.com/?rest_route=/wp/v2/posts", + "https://public-api.wordpress.com/wp/v2/sites/123/posts" + ).forEach { url -> + assertTrue("$url should open externally", opensExternally(configuredSiteView(), url)) + } } @Test - fun `shouldOverrideUrlLoading blocks a site page with an empty rest_route`() { - // WordPress ignores an empty route and serves the page with the site's theme. + fun `shouldOverrideUrlLoading opens site pages that resemble the REST API externally`() { + // WordPress serves each of these with the site's theme and plugins. listOf( - "https://example.com/a-page/?rest_route", - "https://example.com/a-page/?rest_route=", - "https://example.com/a-page/?rest_route=0", - // The parameter overrides the route the API root's path would set. + "https://example.com/blog/wp-json/a-post", + "https://example.com/a-page/?utm_campaign=rest_route=x", "https://example.com/wp-json/?rest_route=", - "https://example.com/wp-json/wp/v2/posts?rest_route=0" + "https://example.com/a-page/?rest_route=/wp/v2/posts&rest_route=", + "http://example.com/wp-json/wp/v2/posts" ).forEach { url -> assertTrue("$url should open externally", opensExternally(configuredSiteView(), url)) } } - @Test - fun `shouldOverrideUrlLoading blocks site pages when the API root has no path`() { - // A root of `/` prefixes every path on the site, so it is no evidence that a - // URL is the API. Such a root is matched by its `rest_route` form alone. - val siteView = GutenbergView( - EditorConfiguration.builder("https://example.com", "https://example.com/") - .build(), - EditorDependencies.empty, - testScope, - RuntimeEnvironment.getApplication() - ) - - val result = opensExternally(siteView, "https://example.com/any-page") - - assertTrue("a pathless API root must not admit the whole site", result) - } - - @Test - fun `shouldOverrideUrlLoading blocks site pages under a rest_route API root's path`() { - // Without pretty permalinks the root is `/index.php?rest_route=/`, and - // `/index.php` also serves the site's ordinary pages. - val siteView = GutenbergView( - EditorConfiguration.builder("https://example.com", "https://example.com/index.php?rest_route=/") - .build(), - EditorDependencies.empty, - testScope, - RuntimeEnvironment.getApplication() - ) - - val result = opensExternally(siteView, "https://example.com/index.php?p=1") - - assertTrue("a page under a rest_route root's path should open externally", result) - } - - @Test - fun `shouldOverrideUrlLoading matches an API root without a trailing slash by whole path segments`() { - val siteView = GutenbergView( - EditorConfiguration.builder("https://example.com", "https://example.com/wp-json") - .build(), - EditorDependencies.empty, - testScope, - RuntimeEnvironment.getApplication() - ) - - assertFalse( - "the API should load in the WebView", - opensExternally(siteView, "https://example.com/wp-json/wp/v2/posts") - ) - assertTrue( - "a page whose slug merely starts with the root should open externally", - opensExternally(siteView, "https://example.com/wp-json-tutorial/") - ) - } - @Test fun `shouldOverrideUrlLoading blocks asset paths over a scheme the asset loader does not serve`() { // An https site's assets are served over https alone, so the same path over diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt index 685cd9242..a6c24a86b 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt @@ -332,82 +332,6 @@ class GutenbergViewTest { assertFalse(GutenbergView.isDevServerUrl(Uri.parse("tel:5551234"), "10.0.2.2:5173")) } - // ===== REST API navigation ===== - - @Test - fun `shouldOverrideUrlLoading allows REST API URLs on the site's API root`() { - // Callers pass a full API root with a path, e.g. WordPress-Android's - // `site.wpApiRestUrl ?: "${site.url}/wp-json/"`. - val siteView = GutenbergView( - EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/") - .build(), - EditorDependencies.empty, - testScope, - RuntimeEnvironment.getApplication() - ) - - val request = mock(WebResourceRequest::class.java) - `when`(request.url).thenReturn(Uri.parse("https://example.com/wp-json/wp/v2/posts")) - - val result = siteView.editorWebView.webViewClient.shouldOverrideUrlLoading(siteView.editorWebView, request) - assertFalse("REST API URLs on the site's API root should load in the WebView", result) - } - - @Test - fun `shouldOverrideUrlLoading allows REST API URLs for a rest_route API root`() { - val siteView = GutenbergView( - EditorConfiguration.builder( - "https://example.com", - "https://example.com/index.php?rest_route=/" - ).build(), - EditorDependencies.empty, - testScope, - RuntimeEnvironment.getApplication() - ) - - val request = mock(WebResourceRequest::class.java) - `when`(request.url).thenReturn( - Uri.parse("https://example.com/index.php?rest_route=/wp/v2/posts") - ) - - val result = siteView.editorWebView.webViewClient.shouldOverrideUrlLoading(siteView.editorWebView, request) - assertFalse("rest_route REST API URLs should load in the WebView", result) - } - - @Test - fun `shouldOverrideUrlLoading blocks REST API URLs on a different host`() { - val siteView = GutenbergView( - EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/") - .build(), - EditorDependencies.empty, - testScope, - RuntimeEnvironment.getApplication() - ) - - val request = mock(WebResourceRequest::class.java) - `when`(request.url).thenReturn(Uri.parse("https://other.example.net/wp-json/wp/v2/posts")) - - val result = siteView.editorWebView.webViewClient.shouldOverrideUrlLoading(siteView.editorWebView, request) - assertTrue("REST API URLs on another host should open externally", result) - } - - @Test - fun `shouldOverrideUrlLoading allows REST API URLs when the API root has a port`() { - val siteView = GutenbergView( - EditorConfiguration.builder("http://10.0.2.2:8888", "http://10.0.2.2:8888/wp-json/") - .build(), - EditorDependencies.empty, - testScope, - RuntimeEnvironment.getApplication() - ) - - val request = mock(WebResourceRequest::class.java) - `when`(request.url).thenReturn(Uri.parse("http://10.0.2.2:8888/wp-json/wp/v2/posts")) - - val result = siteView.editorWebView.webViewClient.shouldOverrideUrlLoading(siteView.editorWebView, request) - assertFalse("REST API URLs on a port-bearing API root should load in the WebView", result) - } - @Test fun `shouldOverrideUrlLoading allows asset URLs when the site URL has an explicit default port`() { val siteView = GutenbergView( From deec3aa2e3c296fc366f9e35d23406528bc40cc2 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 25 Sep 2026 18:16:56 -0400 Subject: [PATCH 12/19] test(android): explain how a site page still reaches the editor frame Since the REST allowlist was removed, the navigation policy admits no site pages, but loads it never sees still can. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../org/wordpress/gutenberg/GutenbergViewNavigationTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt index bbfd4a264..007782fec 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -99,9 +99,9 @@ class GutenbergViewNavigationTest { @Test fun `onPageStarted withholds the configuration from a non-editor page`() { - // `shouldOverrideUrlLoading` admits some site URLs into this frame, and on - // Android the editor shares an origin with the site, so a page served by the - // site's theme and plugins can load here. It must not receive the credential. + // Some loads never pass `shouldOverrideUrlLoading` (POST forms, history, a + // host's `loadUrl`), so a site page can still reach this frame. It must not + // receive the credential. val siteView = configuredSiteView() val webView = siteView.editorWebView From 27cb91b685d5fb029a5a7b86254294d57f5a6175 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 25 Sep 2026 18:17:28 -0400 Subject: [PATCH 13/19] test(android): assert a non-editor page leaves the upload server down Nothing pinned the editor-only check above the server start, so reordering them would have passed every test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../gutenberg/GutenbergViewUploadServerTest.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt index 2baf66055..85dd1930b 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt @@ -108,6 +108,22 @@ class GutenbergViewUploadServerTest { } } + @Test + fun `a non-editor page does not start the upload server`() { + val view = makeView() + try { + view.mediaUploadDelegate = mock(MediaUploadDelegate::class.java) + startLoading(view, "https://example.com/assets/support.html") + idle() + assertNull( + "only the editor document should bring up the upload server", + uploadServerOf(view) + ) + } finally { + detach(view) + } + } + @Test fun `setting the delegate after the page has started loading throws`() { val view = makeView() From a219102ef6b38e9d4d4f714103e8ff1e44e1a297 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 25 Sep 2026 18:17:54 -0400 Subject: [PATCH 14/19] refactor(android): set the asset scheme before installing the WebView client The client reads it, so assigning it alongside the asset authority avoids relying on no navigation running in between. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../src/main/java/org/wordpress/gutenberg/GutenbergView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index e45d08008..cf53f09a2 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -640,6 +640,7 @@ class GutenbergView : FrameLayout { // avoid accidentally downgrading asset traffic for production sites. val siteUri = Uri.parse(configuration.siteURL) val isLocalHttpSite = siteUri.scheme == "http" && siteUri.host in LOCAL_HOSTS + assetScheme = if (isLocalHttpSite) "http" else "https" assetLoader = WebViewAssetLoader.Builder() .setDomain(assetAuthority) .setHttpAllowed(isLocalHttpSite) @@ -651,7 +652,6 @@ class GutenbergView : FrameLayout { initializeWebView() - assetScheme = if (isLocalHttpSite) "http" else "https" val assetUrl = "$assetScheme://$assetAuthority$ASSET_PATH_INDEX" val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL.ifEmpty { assetUrl From 4c9b0cd25b28db23a97c322c392ac5321747a7f0 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 28 Sep 2026 08:28:05 -0400 Subject: [PATCH 15/19] docs(android): correct why isAssetUrl matches one scheme A local http site's asset loader serves https too, so the other scheme doesn't always reach the site over the network. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../src/main/java/org/wordpress/gutenberg/GutenbergView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index cf53f09a2..9d4fd1761 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -602,8 +602,8 @@ class GutenbergView : FrameLayout { } /** - * Whether [url] is served by [assetLoader]. Only the scheme it serves counts, as - * the other scheme on the same authority reaches the site over the network. + * Whether [url] is an asset [assetLoader] serves over the scheme the editor loads + * with. On an https site, http on the same authority reaches the site instead. */ private fun isAssetUrl(url: Uri): Boolean = url.scheme == assetScheme && From 9c5d871afe2a436dba7bc0bc158f5187d27995b2 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 30 Sep 2026 13:43:35 -0400 Subject: [PATCH 16/19] fix(android): compare origin hosts case-insensitively Chromium lowercases the host it reports, so a dev server URL written with capitals, like a Mac's mDNS name, never matched and the editor received no globals. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../java/org/wordpress/gutenberg/GutenbergView.kt | 9 +++++---- .../org/wordpress/gutenberg/GutenbergViewTest.kt | 13 +++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 46d1fddc6..147b1769a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -1442,9 +1442,10 @@ class GutenbergView : FrameLayout { * * This deliberately does not use [Uri.authority], which returns whatever the * URL was written with. Chromium canonicalizes a URL before it reaches - * [WebResourceRequest.url], dropping a default port and any userinfo, so - * `https://example.com:443` arrives as `example.com`. Comparing that against - * a raw authority of `example.com:443` would never match — and since + * [WebResourceRequest.url], lowercasing the host and dropping a default port + * and any userinfo, so `https://Example.com:443` arrives as `example.com`. + * Comparing that against a raw authority of `Example.com:443` would never + * match — and since * `WebViewAssetLoader.PathMatcher` compares authorities exactly, the bundled * editor document would not be served at all. * @@ -1460,7 +1461,7 @@ class GutenbergView : FrameLayout { // supports reaching, and the authority is at least well-formed. if (authority.startsWith("[")) return authority - val host = uri.host ?: return null + val host = uri.host?.lowercase() ?: return null val defaultPort = if (uri.scheme == "http") 80 else 443 return if (uri.port != -1 && uri.port != defaultPort) "$host:${uri.port}" else host } diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt index a6c24a86b..569a820df 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt @@ -257,6 +257,12 @@ class GutenbergViewTest { assertEquals("example.com", GutenbergView.originAuthority("http://example.com:80")) } + @Test + fun `originAuthority lowercases the host`() { + // Chromium lowercases the host, e.g. a Mac's `Davids-MacBook-Pro.local`. + assertEquals("mymac.local:5173", GutenbergView.originAuthority("http://MyMac.local:5173")) + } + @Test fun `originAuthority omits a port that is absent`() { assertEquals("example.com", GutenbergView.originAuthority("https://example.com")) @@ -312,6 +318,13 @@ class GutenbergViewTest { ) } + @Test + fun `isDevServerUrl matches a dev server URL written with a capitalized host`() { + assertTrue( + GutenbergView.isDevServerUrl(Uri.parse("http://mymac.local:5173/"), "http://MyMac.local:5173/") + ) + } + @Test fun `isDevServerUrl matches a dev server URL written with its default port`() { // Chromium drops a default port before the URL reaches the WebViewClient. From 6cff4ccc900ec6c34e6ff10a510c8bbd5aa1fe4c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 30 Sep 2026 13:44:18 -0400 Subject: [PATCH 17/19] refactor(android): recognize the editor document by the URL it loaded Re-deriving the editor URL duplicated loadEditor's choice and let any page on the dev server's host receive the globals. Comparing against the recorded URL keeps the two in step and requires the exact document. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../org/wordpress/gutenberg/GutenbergView.kt | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 147b1769a..88bf6fb74 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -113,7 +113,7 @@ class GutenbergView : FrameLayout { private lateinit var assetAuthority: String private lateinit var assetScheme: String - /** The editor document [loadEditor] loaded, so its load failures can be told apart. */ + /** The editor document [loadEditor] loaded, the only page given the editor globals. */ private var editorUri: Uri? = null private val configuration: EditorConfiguration private lateinit var dependencies: EditorDependencies @@ -740,7 +740,7 @@ class GutenbergView : FrameLayout { // they go to the editor document alone. `shouldOverrideUrlLoading` admits // other pages into this frame, and on Android the editor shares an origin // with the site, so the destination is checked rather than assumed. - if (!isEditorUrl(url)) return + if (url == null || !isEditorDocument(Uri.parse(url))) return if (!hasStartedLoading) { hasStartedLoading = true @@ -749,24 +749,6 @@ class GutenbergView : FrameLayout { setGlobalJavaScriptVariables() } - /** - * Whether [url] is the editor document this view loaded. - * - * A configured dev server replaces the bundled assets as the editor, mirroring - * the URL [loadEditor] chooses, so only one of the two can match. - */ - private fun isEditorUrl(url: String?): Boolean { - if (url.isNullOrEmpty()) return false - val uri = Uri.parse(url) - - if (BuildConfig.GUTENBERG_EDITOR_URL.isNotEmpty()) { - return isDevServerUrl(uri, BuildConfig.GUTENBERG_EDITOR_URL) - } - - // The host app's own bundled pages are asset URLs too, but not the editor. - return isAssetUrl(uri) && uri.path == ASSET_PATH_INDEX - } - private fun setGlobalJavaScriptVariables() { val gbKit = GBKitGlobal.fromConfiguration( configuration, From a2114961eabbbcec6b89226a04d6fbae84e4535e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 30 Sep 2026 13:44:50 -0400 Subject: [PATCH 18/19] test(android): cover injection after a reload and on a local http site Neither path had a positive case, so dropping the globals from either would have passed every test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../gutenberg/GutenbergViewNavigationTest.kt | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt index 007782fec..dd108f805 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -1,6 +1,7 @@ package org.wordpress.gutenberg import android.net.Uri +import android.os.Looper import android.webkit.WebResourceRequest import kotlinx.coroutines.test.TestScope import org.junit.Assert.assertNull @@ -97,6 +98,44 @@ class GutenbergViewNavigationTest { ) } + @Test + fun `onPageStarted injects the configuration again when the editor reloads`() { + val siteView = configuredSiteView() + val webView = siteView.editorWebView + webView.webViewClient.onPageStarted(webView, editorUrlFor("https://example.com"), null) + webView.evaluateJavascript("editor.undo()", null) + + siteView.reloadEditor() + shadowOf(Looper.getMainLooper()).idle() + webView.webViewClient.onPageStarted(webView, editorUrlFor("https://example.com"), null) + + assertTrue( + "the reloaded editor document should receive the globals again", + shadowOf(webView).lastEvaluatedJavascript.orEmpty().contains("window.GBKit") + ) + } + + @Test + fun `onPageStarted injects the configuration into a local http site's editor document`() { + // A local site serves the editor over http, which the asset scheme must follow. + val siteView = GutenbergView( + EditorConfiguration.builder("http://10.0.2.2:8888", "http://10.0.2.2:8888/wp-json/") + .setAuthHeader("Bearer secret-credential") + .build(), + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + val webView = siteView.editorWebView + + webView.webViewClient.onPageStarted(webView, editorUrlFor("http://10.0.2.2:8888"), null) + + assertTrue( + "a local http site's editor document should receive the globals", + shadowOf(webView).lastEvaluatedJavascript.orEmpty().contains("window.GBKit") + ) + } + @Test fun `onPageStarted withholds the configuration from a non-editor page`() { // Some loads never pass `shouldOverrideUrlLoading` (POST forms, history, a From 83346fbbe4a6828453de0376b0de7a5e55f47541 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 30 Sep 2026 13:45:22 -0400 Subject: [PATCH 19/19] test(android): build one view per navigation test Each URL built its own editor view, loading the editor eight times where two views suffice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs --- .../org/wordpress/gutenberg/GutenbergViewNavigationTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt index dd108f805..ceb759abc 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -53,18 +53,20 @@ class GutenbergViewNavigationTest { @Test fun `shouldOverrideUrlLoading opens REST API URLs externally`() { // The editor reaches the API by fetch, which never navigates the frame. + val siteView = configuredSiteView() listOf( "https://example.com/wp-json/wp/v2/posts", "https://example.com/?rest_route=/wp/v2/posts", "https://public-api.wordpress.com/wp/v2/sites/123/posts" ).forEach { url -> - assertTrue("$url should open externally", opensExternally(configuredSiteView(), url)) + assertTrue("$url should open externally", opensExternally(siteView, url)) } } @Test fun `shouldOverrideUrlLoading opens site pages that resemble the REST API externally`() { // WordPress serves each of these with the site's theme and plugins. + val siteView = configuredSiteView() listOf( "https://example.com/blog/wp-json/a-post", "https://example.com/a-page/?utm_campaign=rest_route=x", @@ -72,7 +74,7 @@ class GutenbergViewNavigationTest { "https://example.com/a-page/?rest_route=/wp/v2/posts&rest_route=", "http://example.com/wp-json/wp/v2/posts" ).forEach { url -> - assertTrue("$url should open externally", opensExternally(configuredSiteView(), url)) + assertTrue("$url should open externally", opensExternally(siteView, url)) } }