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..c894add2a 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() { @@ -808,13 +804,15 @@ 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() { - val jsCode = """ - delete window.GBKit; - localStorage.removeItem('GBKit'); - """.trimIndent() - - webView.evaluateJavascript(jsCode, null) + webView.evaluateJavascript("delete window.GBKit;", null) } fun setContent(newContent: String) { 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..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 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 - // 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, 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/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index dc7795024..a3d8be1d8 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -451,15 +451,27 @@ 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)); - "done"; + /// 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. 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); + localStorage.removeItem('GBKit'); """ - - return WKUserScript(source: jsCode, injectionTime: .atDocumentStart, forMainFrameOnly: true) } /// Starts the local HTTP server for routing file uploads through native processing. diff --git a/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift index 26524d11e..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 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 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"), 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 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 || {}; } /** 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;