From 062f5dd7daf288e158b0534187e037ca597f1424 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 1 Sep 2026 16:41:47 -0400 Subject: [PATCH 1/9] refactor: read the editor configuration from the injected global only `getGBKit` fell back to a copy of the configuration in `localStorage`. Boot waits for `window.GBKit` before anything reads the configuration, and outside `?dev_mode` aborts when it never arrives, so the fallback could only ever serve a previous session's values to a dev-mode page with no host. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JsMCoa3jNEuvnhxicDVRBV --- src/utils/bridge.js | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/utils/bridge.js b/src/utils/bridge.js index cc3a2d970..ae2f3ac16 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -264,22 +264,13 @@ export const POST_FALLBACKS = { }; /** - * Retrieves the native-host-provided GBKit object from localStorage or returns - * an empty object if not found. + * Retrieves the native-host-provided GBKit object or returns an empty object + * if the host has not injected one. * * @return {GBKitConfig} The GBKit object. */ export function getGBKit() { - if ( window.GBKit ) { - return window.GBKit; - } - - try { - return JSON.parse( localStorage.getItem( 'GBKit' ) ) || {}; - } catch ( err ) { - error( 'Failed to parse GBKit from localStorage', err ); - return {}; - } + return window.GBKit || {}; } /** From 48f8d23a4a3b504da32e95b210b56b71daacfca8 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 1 Sep 2026 16:47:23 -0400 Subject: [PATCH 2/9] fix(ios): stop persisting the editor configuration `GBKit` carries the site credential and the local server's port and tokens, all valid only for the load that injected them, and iOS mirrored it into `localStorage`, which the default website data store keeps on disk across launches. The document-start user script replays the global on every navigation, including the reload after a WebContent process termination, so the copy had no reader. Remove the key as the configuration is injected so devices upgraded from an earlier version are scrubbed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JsMCoa3jNEuvnhxicDVRBV --- .../Sources/EditorViewController.swift | 23 +++++++++++----- .../EditorConfigurationScriptTests.swift | 26 +++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 ios/Tests/GutenbergKitTests/EditorConfigurationScriptTests.swift diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index dc7795024..883387952 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -451,15 +451,26 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro nativeUploadPort: uploadServer.map { Int($0.port) }, nativeUploadToken: uploadServer?.token ) - let stringValue = try gbkitGlobal.toString() + return WKUserScript( + source: Self.configurationScript(gbkitGlobal: try gbkitGlobal.toString()), + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + } - let jsCode = """ - window.GBKit = \(stringValue); - localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); + /// The document-start script that installs `window.GBKit`. + /// + /// The configuration is session-scoped — it carries the site credential and + /// the local server's port and tokens — so no copy of it outlives the load + /// that injected it. Earlier versions mirrored it into `localStorage`, which + /// the default website data store keeps on disk across launches; the script + /// removes that key so a device upgraded from one of them is scrubbed. + static func configurationScript(gbkitGlobal: String) -> String { + """ + window.GBKit = \(gbkitGlobal); + localStorage.removeItem('GBKit'); "done"; """ - - return WKUserScript(source: jsCode, injectionTime: .atDocumentStart, forMainFrameOnly: true) } /// Starts the local HTTP server for routing file uploads through native processing. diff --git a/ios/Tests/GutenbergKitTests/EditorConfigurationScriptTests.swift b/ios/Tests/GutenbergKitTests/EditorConfigurationScriptTests.swift new file mode 100644 index 000000000..1115dd931 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/EditorConfigurationScriptTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing + +@testable import GutenbergKit + +#if canImport(UIKit) + +@Suite("Editor configuration script") +struct EditorConfigurationScriptTests { + + @MainActor + @Test("injects the configuration without persisting it") + func doesNotPersistTheConfiguration() { + // The injected configuration carries the site credential and the local + // server's port and tokens, all of them valid only for this session. + let script = EditorViewController.configurationScript( + gbkitGlobal: #"{"authHeader":"Bearer secret"}"# + ) + + #expect(script.contains(#"window.GBKit = {"authHeader":"Bearer secret"};"#)) + #expect(script.contains("localStorage.removeItem('GBKit')")) + #expect(!script.contains("localStorage.setItem")) + } +} + +#endif From 8d469668492d994f8d16eda1d948f4fe60b07df4 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 1 Sep 2026 16:48:59 -0400 Subject: [PATCH 3/9] fix(android): stop persisting the editor configuration `GBKit` carries the site credential and the local server's port and token, all valid only for the load that injected them. The view re-injects the global on every page start and wipes web storage before each load, so the `localStorage` copy had no reader and nothing left to clear on detach. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JsMCoa3jNEuvnhxicDVRBV --- .../java/org/wordpress/gutenberg/GutenbergView.kt | 13 ++----------- 1 file changed, 2 insertions(+), 11 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 1607fb4db..c8c9b32a6 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -750,12 +750,8 @@ class GutenbergView : FrameLayout { nativeUploadToken = uploadServer?.token ) val gbKitJson = gbKit.toJsonString() - val gbKitConfig = """ - window.GBKit = $gbKitJson; - localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); - """.trimIndent() - webView.evaluateJavascript(gbKitConfig, null) + webView.evaluateJavascript("window.GBKit = $gbKitJson;", null) } private fun startUploadServer() { @@ -809,12 +805,7 @@ class GutenbergView : FrameLayout { } fun clearConfig() { - val jsCode = """ - delete window.GBKit; - localStorage.removeItem('GBKit'); - """.trimIndent() - - webView.evaluateJavascript(jsCode, null) + webView.evaluateJavascript("delete window.GBKit;", null) } fun setContent(newContent: String) { From b09371e0b2564463f57c9202f97fa60ad46e81b1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 1 Sep 2026 16:49:24 -0400 Subject: [PATCH 4/9] docs: describe where the local server's token lives The CORS rationale on both platforms named `localStorage` alongside `window.GBKit` as where the editor holds the per-session bearer token. The token now lives in the injected global only. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JsMCoa3jNEuvnhxicDVRBV --- .../src/main/java/org/wordpress/gutenberg/HttpServer.kt | 8 ++++---- ios/Sources/GutenbergKitHTTP/CORSPolicy.swift | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt index fefd16a3e..971f78050 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt @@ -114,10 +114,10 @@ enum class CorsPolicy { // `*` (any origin) rather than echoing a specific origin is // deliberate, and safe here — not an oversight to tighten. The // server is loopback-only, and every non-OPTIONS request is gated - // by a per-session random bearer token stored only in the editor - // origin's localStorage/window.GBKit, which is origin-scoped and - // unreadable by any other origin — so no cross-origin can obtain - // it. `*` only governs whether a *token-holding* origin may read + // by a per-session random bearer token held only in the editor + // origin's window.GBKit, which is origin-scoped and unreadable + // by any other origin — so no cross-origin can obtain it. `*` + // only governs whether a *token-holding* origin may read // the response, and the sole token-holder is the editor itself, the // legitimate client. Echoing the origin isn't viable anyway: the // editor loads from file:// (Origin null), which can't be cleanly diff --git a/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift index 26524d11e..f7f01e4f9 100644 --- a/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift +++ b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift @@ -22,10 +22,10 @@ public enum CORSPolicy: Sendable { // `*` (any origin) rather than echoing a specific origin is // deliberate, and safe here — not an oversight to tighten. The // server is loopback-only, and every non-OPTIONS request is gated - // by a per-session random bearer token stored only in the editor - // origin's `localStorage`/`window.GBKit`, which is origin-scoped - // and unreadable by any other origin — so no cross-origin can - // obtain it. `*` only governs whether a *token-holding* origin may + // by a per-session random bearer token held only in the editor + // origin's `window.GBKit`, which is origin-scoped and unreadable + // by any other origin — so no cross-origin can obtain it. `*` + // only governs whether a *token-holding* origin may // read the response, and the sole token-holder is the editor // itself, the legitimate client. Echoing the origin isn't viable // anyway: the editor loads from `file://` (Origin `null`), which From 33dc3112ad65b7539fa0f7b514a6dd862dbd571f Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 24 Sep 2026 09:19:13 -0400 Subject: [PATCH 5/9] docs(android): correct the CORS rationale's origin premise The comment justified `*` partly by claiming the editor loads from file:// (Origin null) and so can't be allowlisted. That holds on iOS, but the Android editor has loaded from the site's own origin since #181, where echoing the origin would be perfectly possible. Name the document, not the origin, as what scopes the token: same-origin site pages exist on Android, and it is the per-document JS global that keeps them from reading it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016zAv5Tqtqr1bcYWVDJHGAh --- .../java/org/wordpress/gutenberg/HttpServer.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt index 971f78050..244e5a5a3 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt @@ -115,13 +115,13 @@ enum class CorsPolicy { // deliberate, and safe here — not an oversight to tighten. The // server is loopback-only, and every non-OPTIONS request is gated // by a per-session random bearer token held only in the editor - // origin's window.GBKit, which is origin-scoped and unreadable - // by any other origin — so no cross-origin can obtain it. `*` - // only governs whether a *token-holding* origin may read - // the response, and the sole token-holder is the editor itself, the - // legitimate client. Echoing the origin isn't viable anyway: the - // editor loads from file:// (Origin null), which can't be cleanly - // allowlisted. + // document's window.GBKit, which no other document can read — + // including same-origin ones, since the editor loads from the + // site's own origin here. `*` only governs whether a + // *token-holding* document may read the response, and the sole + // token-holder is the editor itself, the legitimate client. + // Echoing the editor's origin would be possible, but it is the + // token, not the origin, that gates access. "Access-Control-Allow-Origin" to "*", "Access-Control-Allow-Methods" to "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers" to "Authorization, Relay-Authorization, Content-Type", From add18a9eb6dd75934a1d75caed9e28450364154a Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 24 Sep 2026 09:19:30 -0400 Subject: [PATCH 6/9] refactor(ios): drop the dead completion value from the configuration script The trailing `"done";` dates from #14, when the configuration was injected with `evaluateJavaScript`, which needed a serializable trailing expression. #15 moved the script to a document-start `WKUserScript`, whose completion value WebKit discards, and the line has been inert since. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016zAv5Tqtqr1bcYWVDJHGAh --- ios/Sources/GutenbergKit/Sources/EditorViewController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 883387952..866f61f03 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -469,7 +469,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro """ window.GBKit = \(gbkitGlobal); localStorage.removeItem('GBKit'); - "done"; """ } From c82a8788d10cf19548e587ae81e017ba3bc08b85 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 24 Sep 2026 09:20:45 -0400 Subject: [PATCH 7/9] test: cover getGBKit's injected-global-only contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getGBKit` now ignores any persisted copy, but nothing held that contract in place: the suite only ever exercised it through `window.GBKit`, so restoring the storage fallback would have left every test green. Verified as a tripwire — reinstating the fallback fails the second case. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016zAv5Tqtqr1bcYWVDJHGAh --- src/utils/bridge.test.js | 48 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js index 62178fb1a..4ec5ab0c0 100644 --- a/src/utils/bridge.test.js +++ b/src/utils/bridge.test.js @@ -6,7 +6,12 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; /** * Internal dependencies */ -import { requestLatestContent, getPost, showBlockInserter } from './bridge'; +import { + requestLatestContent, + getGBKit, + getPost, + showBlockInserter, +} from './bridge'; vi.mock( './logger.js', () => ( { error: vi.fn(), @@ -186,6 +191,47 @@ describe( 'requestLatestContent', () => { } ); } ); +describe( 'getGBKit', () => { + let originalGBKit; + + beforeEach( () => { + originalGBKit = window.GBKit; + delete window.GBKit; + localStorage.clear(); + } ); + + afterEach( () => { + if ( originalGBKit !== undefined ) { + window.GBKit = originalGBKit; + } else { + delete window.GBKit; + } + localStorage.clear(); + } ); + + it( 'returns the injected global', () => { + window.GBKit = { siteApiRoot: 'https://example.com/wp-json/' }; + + expect( getGBKit() ).toEqual( { + siteApiRoot: 'https://example.com/wp-json/', + } ); + } ); + + it( 'ignores a configuration persisted by an earlier session', () => { + // The configuration carries the site credential and the local server's + // port and token, none of which outlive the load that injected them. + localStorage.setItem( + 'GBKit', + JSON.stringify( { + siteApiRoot: 'https://stale.example.com/wp-json/', + authHeader: 'Bearer stale', + } ) + ); + + expect( getGBKit() ).toEqual( {} ); + } ); +} ); + describe( 'getPost', () => { let originalWindow; From 16c125dddbe9a592c944ddb71a6cf4560b5ae86a Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 24 Sep 2026 09:21:20 -0400 Subject: [PATCH 8/9] docs: say when the configuration scrub runs The iOS comment promised that an upgraded device "is scrubbed" without saying this only happens on the next editor load, and left no signal for when the migration can be dropped. Document `clearConfig` as teardown-only: with the storage fallback gone, `window.GBKit` is the editor's only source of configuration, so clearing it under a live editor now leaves that editor unusable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016zAv5Tqtqr1bcYWVDJHGAh --- .../main/java/org/wordpress/gutenberg/GutenbergView.kt | 7 +++++++ .../GutenbergKit/Sources/EditorViewController.swift | 8 +++++--- 2 files changed, 12 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 c8c9b32a6..c894add2a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -804,6 +804,13 @@ class GutenbergView : FrameLayout { } } + /** + * Removes the injected configuration from the page. + * + * Call this only when tearing the view down. The editor reads its + * configuration from `window.GBKit` alone, so clearing it under a live + * editor leaves that editor without a site API root or credential. + */ fun clearConfig() { webView.evaluateJavascript("delete window.GBKit;", null) } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 866f61f03..a3d8be1d8 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -462,9 +462,11 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// /// The configuration is session-scoped — it carries the site credential and /// the local server's port and tokens — so no copy of it outlives the load - /// that injected it. Earlier versions mirrored it into `localStorage`, which - /// the default website data store keeps on disk across launches; the script - /// removes that key so a device upgraded from one of them is scrubbed. + /// that injected it. Versions before #613 mirrored it into `localStorage`, + /// which persists across launches; the script removes that key, scrubbing an + /// upgraded device the next time the editor loads. Nothing reads it any + /// more, so the line can go once builds from before #613 are no longer in + /// use. static func configurationScript(gbkitGlobal: String) -> String { """ window.GBKit = \(gbkitGlobal); From 8b27d201ed7ae921790dd6c0dd72b5497592235c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 28 Sep 2026 15:01:43 -0400 Subject: [PATCH 9/9] docs: rest the CORS rationale on the token alone Both comments claimed no other document can read the editor's global, which overstated the guarantee and disagreed across platforms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FEok5vK3cCWGfcQp8zEGWW --- .../java/org/wordpress/gutenberg/HttpServer.kt | 16 +++++----------- ios/Sources/GutenbergKitHTTP/CORSPolicy.swift | 17 ++++++----------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt index 244e5a5a3..19b52a313 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt @@ -111,17 +111,11 @@ enum class CorsPolicy { get() = when (this) { None -> emptyMap() Permissive -> mapOf( - // `*` (any origin) rather than echoing a specific origin is - // deliberate, and safe here — not an oversight to tighten. The - // server is loopback-only, and every non-OPTIONS request is gated - // by a per-session random bearer token held only in the editor - // document's window.GBKit, which no other document can read — - // including same-origin ones, since the editor loads from the - // site's own origin here. `*` only governs whether a - // *token-holding* document may read the response, and the sole - // token-holder is the editor itself, the legitimate client. - // Echoing the editor's origin would be possible, but it is the - // token, not the origin, that gates access. + // `*` is deliberate, not an oversight to tighten: the server is + // loopback-only, and every non-OPTIONS request needs a + // per-session bearer token that is never persisted, only + // injected into the editor page. The token, not the origin, + // gates access, so echoing the editor's origin would add nothing. "Access-Control-Allow-Origin" to "*", "Access-Control-Allow-Methods" to "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers" to "Authorization, Relay-Authorization, Content-Type", diff --git a/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift index f7f01e4f9..09f1f6060 100644 --- a/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift +++ b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift @@ -19,17 +19,12 @@ public enum CORSPolicy: Sendable { [] case .permissive: [ - // `*` (any origin) rather than echoing a specific origin is - // deliberate, and safe here — not an oversight to tighten. The - // server is loopback-only, and every non-OPTIONS request is gated - // by a per-session random bearer token held only in the editor - // origin's `window.GBKit`, which is origin-scoped and unreadable - // by any other origin — so no cross-origin can obtain it. `*` - // only governs whether a *token-holding* origin may - // read the response, and the sole token-holder is the editor - // itself, the legitimate client. Echoing the origin isn't viable - // anyway: the editor loads from `file://` (Origin `null`), which - // can't be cleanly allowlisted. + // `*` is deliberate, not an oversight to tighten: the server is + // loopback-only, and every non-OPTIONS request needs a + // per-session bearer token that is never persisted, only + // injected into the editor page. The token, not the origin, + // gates access; echoing the origin isn't viable anyway, as the + // editor loads from `file://` (Origin `null`). ("Access-Control-Allow-Origin", "*"), ("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"), ("Access-Control-Allow-Headers", "Authorization, Relay-Authorization, Content-Type"),