Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 19 additions & 7 deletions ios/Sources/GutenbergKit/Sources/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 6 additions & 11 deletions ios/Sources/GutenbergKitHTTP/CORSPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
26 changes: 26 additions & 0 deletions ios/Tests/GutenbergKitTests/EditorConfigurationScriptTests.swift
Original file line number Diff line number Diff line change
@@ -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
15 changes: 3 additions & 12 deletions src/utils/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};
}

/**
Expand Down
48 changes: 47 additions & 1 deletion src/utils/bridge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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;

Expand Down
Loading