Skip to content

fix(android): scope the editor globals and keep site pages out of the editor frame - #730

Draft
dcalhoun wants to merge 14 commits into
trunkfrom
fix/android-scope-config-injection
Draft

dcalhoun wants to merge 14 commits into
trunkfrom
fix/android-scope-config-injection

Conversation

@dcalhoun

@dcalhoun dcalhoun commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

What?

Mitigate exposing editor globals to non-editor pages.

Why?

The editor globals contain configuration that should only be accessible to the editor HTML page and its scripts.

How?

  • Remove logic allowing REST API endpoints to load directly in the WebView, fetch of these URLs works without this
  • Narrow the asset path detection to matching schemes
  • Disable editor globals and upload server for non-editor URLs

Testing Instructions

Manual testing on a device against wp-env, in both configurations, using chrome://inspect to reach the editor WebView's console:

  1. Production path — with GUTENBERG_EDITOR_URL unset in android/local.properties, open a post. window.GBKit is defined.
  2. Edit, save, upload an image, and open the inserter's Patterns tab. All work; the API is reached by fetch, and pattern previews by blob: frames, neither of which the policy gates.
  3. In the console, each of these opens in the OS browser instead of replacing the editor:
    • window.top.location.href = location.origin + '/wp-json/wp/v2/posts'
    • window.top.location.href = location.origin + '/?rest_route=/wp/v2/posts&rest_route=' (on trunk, the themed front page)
    • On an https site (not wp-env), window.top.location.href = 'http://' + location.host + '/wp-json/wp/v2/posts'
  4. Add a temporary android/app/src/main/assets/probe.html, rebuild, and navigate to location.origin + '/assets/probe.html'. It loads, and in its console window.GBKit is undefined (on trunk, the object).
  5. Dev server — set GUTENBERG_EDITOR_URL=http://10.0.2.2:5173/, run make dev-server, open a post. window.GBKit is defined.
  6. With the dev server still configured, navigate to http://10.0.2.2:8888/ — the wp-env site on another port of the same host. It opens in the OS browser rather than being taken for the dev server.

Accessibility Testing Instructions

N/A, no user-facing changes.

Screenshots or screencast

N/A, no user-facing changes.


AI-generated details

Problem

onPageStarted received the URL of the page that had begun loading and discarded it, so window.GBKit — the site credential and the local upload server's port and token — was injected into whatever loaded in the main frame. Separately, the navigation policy admitted the REST API into the main frame, recognizing it by substring: any path containing /wp-json/ or any query containing rest_route=.

Impact

Since #181 the Android editor loads from the site's own origin, so the pages those substrings admit are ordinary pages that WordPress serves with the site's theme, plugins and third-party scripts — rendered inside the editor's frame, and handed the credential in a readable global. /blog/wp-json/a-post and /a-page/?utm_campaign=rest_route=x both qualify. iOS is unaffected: it blocks all main-frame navigation outside the editor.

Both behaviors reproduce on trunk; the Robolectric cases added here fail against it.

Mechanism

  • onEditorPageStarted takes the loaded URL and advertises the globals, and starts the upload server, only for the editor document. Readiness still resets for any page, since navigating away leaves the editor unusable either way. The dev server is matched with fix(android): match the dev server by host and port #729's isDevServerUrl, by authority, so a local site on another port of the same host is not mistaken for the editor.
  • The main frame no longer admits the REST API, site or WordPress.com. The editor reaches it by fetch, which never passes through shouldOverrideUrlLoading, so the allowlist only ever admitted navigations — and matching WordPress's URL parsing proved open-ended: a duplicate or empty rest_route, or http on an https site, still let a page replace the editor.
  • The editor's assets match only the scheme the asset loader serves; the same path over the other scheme reaches the site over the network. The editor document itself is matched by its exact index path, since the asset loader also serves the host app's other bundled pages.
  • The new cases live in GutenbergViewNavigationTest rather than GutenbergViewTest, which Detekt flags as LargeClass once they are added.

Testing

Unit and lint, both green, and each commit passes on its own:

make test-android-library-unit
make lint-android

Manual testing on a device against wp-env, in both configurations, using chrome://inspect to reach the editor WebView's console:

  1. Production path — with GUTENBERG_EDITOR_URL unset in android/local.properties, open a post. window.GBKit is defined.
  2. Edit, save, upload an image, and open the inserter's Patterns tab. All work; the API is reached by fetch, and pattern previews by blob: frames, neither of which the policy gates.
  3. In the console, each of these opens in the OS browser instead of replacing the editor:
    • window.top.location.href = location.origin + '/wp-json/wp/v2/posts'
    • window.top.location.href = location.origin + '/?rest_route=/wp/v2/posts&rest_route=' (on trunk, the themed front page)
    • On an https site, window.top.location.href = 'http://' + location.host + '/wp-json/wp/v2/posts'
  4. Add a temporary android/app/src/main/assets/probe.html, rebuild, and navigate to location.origin + '/assets/probe.html'. It loads, and in its console window.GBKit is undefined (on trunk, the object).
  5. Dev server — set GUTENBERG_EDITOR_URL=http://10.0.2.2:5173/, run make dev-server, open a post. window.GBKit is defined.
  6. With the dev server still configured, navigate to http://10.0.2.2:8888/ — the wp-env site on another port of the same host. It opens in the OS browser rather than being taken for the dev server.
  7. make test-android-library-e2e on an emulator.

Not verified on a device: asset paths over the other scheme, covered by a unit case.

The localStorage copy of the globals is removed separately in #613.

@github-actions github-actions Bot added the [Type] Bug An existing feature does not function as intended label Sep 25, 2026
@wpmobilebot

wpmobilebot commented Sep 25, 2026 •

Copy link
Copy Markdown

XCFramework Build

This PR's XCFramework is available for testing. Add the following to your Package.swift:

.package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/730")

Built from a219102

@dcalhoun dcalhoun changed the title fix(android): scope the editor globals and tighten the REST allowlist fix(android): scope the editor globals and keep site pages out of the editor frame Sep 25, 2026
dcalhoun and others added 11 commits September 25, 2026 16:37
`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zAv5Tqtqr1bcYWVDJHGAh
…ring

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zAv5Tqtqr1bcYWVDJHGAh
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs
Checking only the last evaluated script would pass if another script ran after an injection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs
@dcalhoun
dcalhoun force-pushed the fix/android-scope-config-injection branch from 1a1a592 to 39894ad Compare September 25, 2026 20:37
dcalhoun and others added 3 commits September 25, 2026 18:16
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs
… 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSbnGRWvgz7WNn6cGFRKxs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants