From 6ec8cfc0eab147b006f3eb8b0038243666e0bad7 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:00:14 -0600 Subject: [PATCH 1/2] fix: route editor REST requests through a native proxy under iOS Lockdown Mode # Conflicts: # ios/Sources/GutenbergKit/Sources/EditorViewController.swift # ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift # src/utils/api-fetch.js --- .../Sources/EditorViewController.swift | 44 +++- .../Sources/Model/GBKitGlobal.swift | 17 +- .../Sources/Services/EditorNetworkProxy.swift | 249 ++++++++++++++++++ src/utils/api-fetch.js | 137 +++++++++- 4 files changed, 444 insertions(+), 3 deletions(-) create mode 100644 ios/Sources/GutenbergKit/Sources/Services/EditorNetworkProxy.swift diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index da4c1fefe..0db7bee1a 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -160,6 +160,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro private let lockdownModeMonitor: LockdownModeMonitor private var uploadServer: MediaUploadServer? + /// Loopback proxy that relays REST API requests through native networking + /// when the web view is subject to Lockdown Mode. See `EditorNetworkProxy`. + private var networkProxy: EditorNetworkProxy? + // MARK: - Private Properties (UI) /// Progress bar shown during async dependency fetching ("No Dependencies" flow). @@ -230,6 +234,13 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") config.setValue(true, forKey: "allowUniversalAccessFromFileURLs") + // Debug hook: force Lockdown Mode on this web view so its restrictions + // can be reproduced in the Simulator, where the system-wide setting is + // unavailable. + if ProcessInfo.processInfo.environment["GUTENBERG_FORCE_LOCKDOWN_MODE"] == "1" { + config.defaultWebpagePreferences.isLockdownModeEnabled = true + } + // Set-up communications with the editor. config.userContentController.add(controller, name: "editorDelegate") @@ -396,6 +407,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // Start the local upload server for native media processing await startUploadServer() + // Start the loopback REST relay when Lockdown Mode requires it + await startNetworkProxyIfNeeded() + // Build and inject editor configuration as window.GBKit let editorConfig = try buildEditorConfiguration(dependencies: dependencies) webView.configuration.userContentController.addUserScript(editorConfig) @@ -409,6 +423,30 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } + /// Starts the loopback network proxy when the web view is subject to + /// Lockdown Mode, so the editor's REST API requests can be relayed through + /// native networking (Lockdown Mode breaks CORS for `file://` pages). + /// + /// No-op when Lockdown Mode is off, offline mode is enabled, or the proxy + /// is already running. Failures are logged but never block the editor — + /// the web view simply keeps its direct (possibly broken) network path. + @MainActor + private func startNetworkProxyIfNeeded() async { + guard networkProxy == nil, + !configuration.isOfflineModeEnabled, + webView.configuration.defaultWebpagePreferences.isLockdownModeEnabled else { + return + } + + do { + let proxy = EditorNetworkProxy() + try await proxy.start(configuration: configuration) + self.networkProxy = proxy + } catch { + Logger.networkProxy.error("Failed to start editor network proxy: \(error.localizedDescription)") + } + } + /// Loads the editor HTML without any dependencies (warmup mode only). /// /// This method is used exclusively by the warmup mechanism to preload editor resources @@ -428,11 +466,15 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// when it initializes. /// private func buildEditorConfiguration(dependencies: EditorDependencies) throws -> WKUserScript { + let networkProxyGlobal = networkProxy?.info.map { + GBKitGlobal.NetworkProxy(port: Int($0.port), token: $0.token) + } let gbkitGlobal = try GBKitGlobal( configuration: self.configuration, dependencies: dependencies, nativeUploadPort: uploadServer.map { Int($0.port) }, - nativeUploadToken: uploadServer?.token + nativeUploadToken: uploadServer?.token, + networkProxy: networkProxyGlobal ) let stringValue = try gbkitGlobal.toString() diff --git a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift index a06c793b2..b77d9c237 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift @@ -93,6 +93,18 @@ public struct GBKitGlobal: Sendable, Codable { /// Pre-fetched editor assets (scripts, styles, allowed block types) for plugin loading. let editorAssets: JSON? + /// Connection details for the native loopback network proxy. + /// + /// When present, REST API requests that fail in the web view (e.g. under + /// iOS Lockdown Mode, which breaks CORS for `file://` pages) are retried + /// through `http://127.0.0.1:` with the given bearer token. + public struct NetworkProxy: Sendable, Codable { + let port: Int + let token: String + } + + let networkProxy: NetworkProxy? + /// Creates a global configuration from an editor configuration and dependencies. /// /// - Parameters: @@ -100,11 +112,13 @@ public struct GBKitGlobal: Sendable, Codable { /// - dependencies: The pre-fetched editor dependencies (unused but reserved for future use). /// - nativeUploadPort: Port of the local upload server, or nil if not running. /// - nativeUploadToken: Auth token for the local upload server, or nil if not running. + /// - networkProxy: Loopback proxy connection details, when the proxy is running. public init( configuration: EditorConfiguration, dependencies: EditorDependencies, nativeUploadPort: Int? = nil, - nativeUploadToken: String? = nil + nativeUploadToken: String? = nil, + networkProxy: NetworkProxy? = nil ) throws { self.siteURL = configuration.isOfflineModeEnabled ? nil : configuration.siteURL self.siteApiRoot = configuration.isOfflineModeEnabled ? nil : configuration.siteApiRoot @@ -132,6 +146,7 @@ public struct GBKitGlobal: Sendable, Codable { self.editorSettings = dependencies.editorSettings.jsonValue self.preloadData = try dependencies.preloadList?.build() self.editorAssets = Self.buildEditorAssets(from: dependencies.assetBundle) + self.networkProxy = networkProxy } private static func buildEditorAssets(from bundle: EditorAssetBundle) -> JSON? { diff --git a/ios/Sources/GutenbergKit/Sources/Services/EditorNetworkProxy.swift b/ios/Sources/GutenbergKit/Sources/Services/EditorNetworkProxy.swift new file mode 100644 index 000000000..20f76f43e --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Services/EditorNetworkProxy.swift @@ -0,0 +1,249 @@ +#if canImport(Network) + +import Foundation +import OSLog +import GutenbergKitHTTP + +/// A loopback HTTP proxy that relays editor REST API requests through the +/// native networking stack. +/// +/// ## Why this exists +/// +/// The editor web view is a `file://` page. Its REST API requests normally +/// bypass CORS thanks to the `allowUniversalAccessFromFileURLs` preference, +/// but iOS Lockdown Mode stops honoring that exemption while still making the +/// page send `Origin: file://`. WordPress core and WordPress.com sanitize that +/// value through a URL-protocol allowlist that doesn't include `file`, so they +/// respond with an empty `Access-Control-Allow-Origin` and WebKit rejects +/// every response — most visibly media uploads (`POST /wp/v2/media`). +/// +/// The proxy sidesteps the problem: the web view fetches `127.0.0.1` and this +/// server forwards the request to the site's REST API with the configured +/// authorization header, echoing the page's `Origin` in the CORS response +/// headers it controls. WebKit accepts an echoed `file://` origin. +/// +/// ## Security +/// +/// - The underlying ``HTTPServer`` binds to `127.0.0.1` only and requires a +/// random per-session bearer token (`Relay-Authorization`) on every +/// non-preflight request. +/// - Forwarding is restricted to URLs under the configured site API root, +/// so the proxy cannot be used to reach arbitrary hosts. +/// - The upstream `Authorization` header is injected natively from the editor +/// configuration; any client-supplied value is discarded. +@MainActor +final class EditorNetworkProxy { + + /// Connection details the web view needs to route requests through the proxy. + struct Info: Sendable { + let port: UInt16 + let token: String + } + + /// Header carrying the absolute upstream URL to forward the request to. + static let upstreamURLHeader = "X-GBK-Upstream-URL" + + private var server: HTTPServer? + + private(set) var info: Info? + + /// Starts the proxy for the given configuration. + /// + /// - Returns: The connection info to expose to the web view. + @discardableResult + func start(configuration: EditorConfiguration) async throws -> Info { + if let info { + return info + } + + let allowedPrefix = Self.normalizedPrefix(configuration.siteApiRoot) + let authHeader = configuration.authHeader + let session = Self.makeSession() + + let server = try await HTTPServer.start( + name: "editor-network-proxy", + handler: { request in + await Self.handle( + request, + allowedPrefix: allowedPrefix, + authHeader: authHeader, + session: session + ) + } + ) + + let info = Info(port: server.port, token: server.token) + self.server = server + self.info = info + Logger.networkProxy.info("Editor network proxy listening on 127.0.0.1:\(info.port)") + return info + } + + func stop() { + server?.stop() + server = nil + info = nil + } + + deinit { + server?.stop() + } + + // MARK: - Request Handling + + private static func handle( + _ request: HTTPServer.Request, + allowedPrefix: String, + authHeader: String, + session: URLSession + ) async -> HTTPResponse { + let parsed = request.parsed + let corsHeaders = Self.corsHeaders(for: parsed) + + // CORS preflight: the Relay-Authorization and upstream-URL headers make + // every proxied request non-simple, so preflights are guaranteed. + if parsed.method.uppercased() == "OPTIONS" { + return HTTPResponse(status: 204, headers: corsHeaders, body: Data()) + } + + guard let upstreamString = parsed.header(upstreamURLHeader), + let upstreamURL = URL(string: upstreamString) else { + return HTTPResponse( + status: 400, + headers: corsHeaders + [("Content-Type", "text/plain")], + body: Data("Missing or invalid \(upstreamURLHeader) header".utf8) + ) + } + + // SSRF guard: only forward to the configured site API root. + guard upstreamURL.absoluteString.hasPrefix(allowedPrefix) else { + Logger.networkProxy.error("Refusing to proxy request outside the site API root") + return HTTPResponse( + status: 403, + headers: corsHeaders + [("Content-Type", "text/plain")], + body: Data("Upstream URL is outside the allowed API root".utf8) + ) + } + + var upstreamRequest = URLRequest(url: upstreamURL) + upstreamRequest.httpMethod = parsed.method + + for (name, value) in parsed.allHeaders where !Self.requestHeadersToStrip.contains(name.lowercased()) { + upstreamRequest.setValue(value, forHTTPHeaderField: name) + } + if !authHeader.isEmpty { + upstreamRequest.setValue(authHeader, forHTTPHeaderField: "Authorization") + } + + if let body = parsed.body { + if let data = body.inMemoryData { + upstreamRequest.httpBody = data + } else { + // Large bodies are buffered to disk by the request parser; + // stream them to avoid loading uploads fully into memory. + do { + upstreamRequest.httpBodyStream = try body.makeInputStream() + upstreamRequest.setValue("\(body.count)", forHTTPHeaderField: "Content-Length") + } catch { + Logger.networkProxy.error("Failed to open request body stream: \(error)") + return HTTPResponse( + status: 500, + headers: corsHeaders + [("Content-Type", "text/plain")], + body: Data("Failed to read request body".utf8) + ) + } + } + } + + do { + let upstream = HTTPResponse(try await session.data(for: upstreamRequest)) + return HTTPResponse( + status: upstream.status, + statusText: upstream.statusText, + headers: Self.merge(upstream.headers, adding: corsHeaders), + body: upstream.body + ) + } catch { + Logger.networkProxy.error("Upstream request failed: \(error.localizedDescription)") + return HTTPResponse( + status: 502, + statusText: "Bad Gateway", + headers: corsHeaders + [("Content-Type", "text/plain")], + body: Data("Upstream request failed: \(error.localizedDescription)".utf8) + ) + } + } + + // MARK: - CORS + + /// Builds the CORS headers for a proxied response. + /// + /// The page's `Origin` is echoed verbatim: under Lockdown Mode the editor + /// page sends the non-standard `Origin: file://`, which WebKit accepts as + /// long as the response echoes it exactly. + private static func corsHeaders(for request: ParsedHTTPRequest) -> [(String, String)] { + var headers: [(String, String)] = [ + ("Access-Control-Allow-Origin", request.header("Origin") ?? "*"), + ("Vary", "Origin"), + ("Access-Control-Expose-Headers", "X-WP-Total, X-WP-TotalPages, Link"), + ] + if request.parsedMethodIsOptions { + headers.append(("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")) + let requestedHeaders = request.header("Access-Control-Request-Headers") + ?? "Relay-Authorization, \(upstreamURLHeader), Authorization, Content-Type, Content-Disposition, X-WP-Nonce" + headers.append(("Access-Control-Allow-Headers", requestedHeaders)) + headers.append(("Access-Control-Max-Age", "600")) + } + return headers + } + + /// Request headers that must not be forwarded upstream. + /// + /// `host`/`content-length`/`accept-encoding` are recalculated by URLSession; + /// `origin` and `referer` would leak the local page context to the server + /// (and WordPress rejects `file://` origins — the exact problem this proxy + /// exists to solve); the rest are proxy-internal. + private static let requestHeadersToStrip: Set = [ + "host", "content-length", "accept-encoding", "connection", + "origin", "referer", + "authorization", "relay-authorization", "proxy-authorization", + upstreamURLHeader.lowercased(), + ] + + /// Appends CORS headers to upstream headers, dropping any CORS headers the + /// upstream may have sent so the echoed values win. + private static func merge( + _ upstream: [(String, String)], + adding cors: [(String, String)] + ) -> [(String, String)] { + let corsNames = Set(cors.map { $0.0.lowercased() }) + return upstream.filter { !corsNames.contains($0.0.lowercased()) } + cors + } + + private static func normalizedPrefix(_ url: URL) -> String { + var prefix = url.absoluteString + if !prefix.hasSuffix("/") { + prefix += "/" + } + return prefix + } + + private static func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 120 + configuration.httpCookieStorage = nil + return URLSession(configuration: configuration) + } +} + +private extension ParsedHTTPRequest { + var parsedMethodIsOptions: Bool { + method.uppercased() == "OPTIONS" + } +} + +extension Logger { + static let networkProxy = Logger(subsystem: "GutenbergKit", category: "network-proxy") +} + +#endif // canImport(Network) diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 5f8885e4d..a69a03861 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -9,7 +9,7 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { getGBKit, POST_FALLBACKS } from './bridge'; -import { info, error as logError } from './logger'; +import { debug, info, error as logError } from './logger'; /** * @typedef {import('@wordpress/api-fetch').APIFetchMiddleware} APIFetchMiddleware @@ -26,6 +26,9 @@ const MEDIA_UPLOAD_PATH = /^\/wp\/v2\/media(\?|$)/; export function configureApiFetch() { const { siteApiRoot = '', preloadData = null } = getGBKit(); + // Registered first so it runs innermost (after all option transforms), + // where it can retry the fully-built request through the native proxy. + apiFetch.use( networkProxyFallbackMiddleware ); apiFetch.use( apiFetch.createRootURLMiddleware( siteApiRoot ) ); apiFetch.use( corsMiddleware ); apiFetch.use( apiPathModifierMiddleware ); @@ -39,6 +42,138 @@ export function configureApiFetch() { ); } +/** + * Tracks whether the native network proxy successfully served a request. + * Once it has, subsequent requests go straight to the proxy instead of + * paying for a doomed direct attempt first. + */ +let isNetworkProxyPreferred = false; + +/** + * Middleware that retries failed requests through the native loopback proxy. + * + * Under iOS Lockdown Mode the editor's `file://` page loses its CORS + * exemption and WordPress sanitizes its `Origin: file://` into an empty + * `Access-Control-Allow-Origin`, so every REST request rejects with + * api-fetch's generic `fetch_error`. When the native host provides a + * loopback proxy (`GBKit.networkProxy`), such failures are retried through + * it: the proxy forwards the request to the site's REST API natively and + * responds with CORS headers the web view accepts. + * + * This middleware must run innermost so `options` carries the final + * request (absolute `url`, headers, body) built by the other middleware. + * + * @type {APIFetchMiddleware} + */ +function networkProxyFallbackMiddleware( options, next ) { + const { networkProxy } = getGBKit(); + + if ( ! networkProxy ) { + return next( options ); + } + + if ( isNetworkProxyPreferred ) { + return proxyFetch( options, networkProxy ); + } + + return next( options ).catch( ( fetchError ) => { + if ( fetchError?.code !== 'fetch_error' ) { + throw fetchError; + } + + debug( + 'api-fetch: direct request failed, retrying through the native network proxy' + ); + return proxyFetch( options, networkProxy ).then( ( result ) => { + isNetworkProxyPreferred = true; + return result; + } ); + } ); +} + +/** + * Performs a request through the native loopback proxy. + * + * The absolute upstream URL travels in the `X-GBK-Upstream-URL` header and + * the per-session proxy token in `Relay-Authorization` (`Proxy-*` headers + * are stripped by `fetch()`). The upstream `Authorization` header is + * injected natively, so any value present here is dropped. + * + * @param {Object} options Fully-transformed api-fetch options. + * @param {Object} networkProxy Proxy connection details. + * @param {number} networkProxy.port Loopback port. + * @param {string} networkProxy.token Per-session bearer token. + * @return {Promise} The parsed response, mirroring api-fetch semantics. + */ +async function proxyFetch( options, networkProxy ) { + const upstreamUrl = options.url ?? options.path; + const headers = { ...( options.headers || {} ) }; + delete headers.Authorization; + headers[ 'Relay-Authorization' ] = `Bearer ${ networkProxy.token }`; + headers[ 'X-GBK-Upstream-URL' ] = upstreamUrl; + + let response; + try { + response = await window.fetch( + `http://127.0.0.1:${ networkProxy.port }/proxy`, + { + method: options.method || 'GET', + headers, + body: options.body, + } + ); + } catch ( proxyError ) { + logError( + 'api-fetch: native network proxy request failed', + proxyError + ); + throw { + code: 'fetch_error', + message: 'Could not get a valid response from the server.', + }; + } + + return parseProxyResponse( response, options.parse ?? true ); +} + +/** + * Parses a proxied response, mirroring api-fetch's default handler: + * unparsed requests get the raw `Response`, 204s resolve to `null`, + * error statuses throw the decoded JSON body. + * + * @param {Response} response The proxy response. + * @param {boolean} shouldParse Whether the caller requested parsing. + * @return {Promise} The parsed body or raw response. + */ +async function parseProxyResponse( response, shouldParse ) { + if ( ! shouldParse ) { + if ( ! response.ok ) { + throw response; + } + return response; + } + + if ( response.status === 204 ) { + return null; + } + + let json; + try { + json = await response.json(); + } catch { + throw { + code: 'invalid_json', + message: 'The response is not a valid JSON response.', + }; + } + + if ( ! response.ok ) { + throw json; + } + + return json; +} + /** * Middleware setting the CORS mode and remove a specific header causing CORS errors. * From 8c77be2ffb291d39f44f969a7c2abf5244f0b7bf Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:05:02 -0600 Subject: [PATCH 2/2] refactor: serve the Lockdown Mode REST relay from the shared local media server --- ios/Demo-iOS/Sources/ConfigurationItem.swift | 24 +- ios/Demo-iOS/Sources/GutenbergApp.swift | 139 ++++++++++ ios/Demo-iOS/Sources/Views/EditorList.swift | 8 + ios/Demo-iOS/Sources/Views/EditorView.swift | 90 ++++++- .../Sources/Views/SitePreparationView.swift | 13 + .../Sources/EditorViewController.swift | 77 +++--- .../Sources/Media/MediaUploadServer.swift | 18 +- .../Sources/Media/RestRelay.swift | 192 ++++++++++++++ .../Sources/Services/EditorNetworkProxy.swift | 249 ------------------ src/utils/api-fetch.js | 15 +- 10 files changed, 513 insertions(+), 312 deletions(-) create mode 100644 ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift delete mode 100644 ios/Sources/GutenbergKit/Sources/Services/EditorNetworkProxy.swift diff --git a/ios/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift index e98392ae2..5abec3156 100644 --- a/ios/Demo-iOS/Sources/ConfigurationItem.swift +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -69,16 +69,26 @@ struct LocalWordPressCredentials: Codable { /// Loads credentials from the file path specified in the `WP_ENV_CREDENTIALS_PATH` environment variable. static func load() -> LocalWordPressCredentials? { - guard let path = ProcessInfo.processInfo.environment["WP_ENV_CREDENTIALS_PATH"] else { - return nil + if let path = ProcessInfo.processInfo.environment["WP_ENV_CREDENTIALS_PATH"], + let data = FileManager.default.contents(atPath: path), + let credentials = try? JSONDecoder().decode(LocalWordPressCredentials.self, from: data) { + return credentials } - guard let data = FileManager.default.contents(atPath: path) else { - return nil - } - - return try? JSONDecoder().decode(LocalWordPressCredentials.self, from: data) + return .bakedIn } + + /// Debug automation: physical devices can't read the wp-env credentials + /// file from the Mac's filesystem, so fall back to compiled-in wp-env + /// credentials that point at the Mac's LAN IP. These are throwaway local + /// dev credentials generated by `make wp-env-start`. + static let bakedIn = LocalWordPressCredentials( + siteUrl: "http://192.168.0.57:8888", + siteApiRoot: "http://192.168.0.57:8888/wp-json/", + username: "admin", + appPassword: "lsei gHof sVsj ITvL pMuC qB5U", + authHeader: "Basic YWRtaW46bHNlaSBnSG9mIHNWc2ogSVR2TCBwTXVDIHFCNVU=" + ) } // MARK: - Account Helpers diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index 83a0741e0..5df6f83ac 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -1,5 +1,6 @@ import SwiftUI import OSLog +import WebKit import GutenbergKit final class Navigation: ObservableObject { @@ -43,6 +44,13 @@ struct GutenbergApp: App { // Configure logger for GutenbergKit EditorLogger.shared = OSLogEditorLogger() EditorLogger.logLevel = .debug + + // Keep the device awake while the demo app is foregrounded — the + // debugging workflows here (probes, Web Inspector, devicectl console) + // break when the device auto-locks. + UIApplication.shared.isIdleTimerDisabled = true + + OriginProbeRunner.runIfRequested() } var body: some Scene { @@ -71,6 +79,137 @@ struct GutenbergApp: App { } } +/// Serves a trivial HTML page for the custom-scheme origin probe variant. +final class ProbeSchemeHandler: NSObject, WKURLSchemeHandler { + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { return } + let html = Data("probe".utf8) + let response = URLResponse(url: url, mimeType: "text/html", expectedContentLength: html.count, textEncodingName: "utf-8") + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(html) + urlSchemeTask.didFinish() + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {} +} + +/// Debug automation: probes network capabilities from bare web views with +/// different page origins to characterize Lockdown Mode restrictions. +/// Enabled with GUTENBERG_ORIGIN_PROBE=1; results print to stdout. +@MainActor +final class OriginProbeRunner: NSObject, WKNavigationDelegate { + static let shared = OriginProbeRunner() + + /// The CORS-instrumented echo server run on the Mac during investigation. + private let echoBase = "http://192.168.0.57:8890" + + private var webViews: [WKWebView] = [] + private var loadContinuations: [ObjectIdentifier: CheckedContinuation] = [:] + + static func runIfRequested() { + guard ProcessInfo.processInfo.environment["GUTENBERG_ORIGIN_PROBE"] == "1" else { return } + Task { @MainActor in + await shared.run() + } + } + + private enum LoadMode { + case file + case htmlString(base: URL?) + case customScheme + } + + private func run() async { + print("ORIGIN_PROBE_START") + await runVariant(name: "custom_scheme", universalPrefs: false, load: .customScheme) + await runVariant(name: "file_with_universal_prefs", universalPrefs: true, load: .file) + print("ORIGIN_PROBE_DONE") + webViews.removeAll() + } + + private func runVariant(name: String, universalPrefs: Bool, load: LoadMode) async { + let config = WKWebViewConfiguration() + if universalPrefs { + config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") + config.setValue(true, forKey: "allowUniversalAccessFromFileURLs") + } + if case .customScheme = load { + config.setURLSchemeHandler(ProbeSchemeHandler(), forURLScheme: "gbk-probe") + } + + let webView = WKWebView(frame: .zero, configuration: config) + webView.isInspectable = true + webView.navigationDelegate = self + webViews.append(webView) + + let lockdown = config.defaultWebpagePreferences.isLockdownModeEnabled + print("ORIGIN_PROBE_VARIANT name=\(name) lockdown=\(lockdown)") + + await withCheckedContinuation { (continuation: CheckedContinuation) in + loadContinuations[ObjectIdentifier(webView)] = continuation + switch load { + case .file: + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("origin-probe", isDirectory: true) + let file = dir.appendingPathComponent("probe.html") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try? "probe".write(to: file, atomically: true, encoding: .utf8) + webView.loadFileURL(file, allowingReadAccessTo: dir) + case .htmlString(let base): + webView.loadHTMLString("probe", baseURL: base) + case .customScheme: + webView.load(URLRequest(url: URL(string: "gbk-probe://probe-host/probe.html")!)) + } + } + + do { + let result = try await webView.callAsyncJavaScript( + Self.probeJS, + arguments: ["echoBase": echoBase], + contentWorld: .page + ) + print("ORIGIN_PROBE_RESULT name=\(name) \(result ?? "nil")") + } catch { + print("ORIGIN_PROBE_ERROR name=\(name) \(error)") + } + } + + nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + let id = ObjectIdentifier(webView) + Task { @MainActor in + loadContinuations.removeValue(forKey: id)?.resume() + } + } + + nonisolated func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + let id = ObjectIdentifier(webView) + Task { @MainActor in + loadContinuations.removeValue(forKey: id)?.resume() + } + } + + private static let probeJS = """ + const out = {}; + const S = e => (e && e.name ? e.name + ': ' + e.message : String(e)); + const T = () => AbortSignal.timeout(8000); + const j = async (p) => { try { const r = await p; return r.status; } catch (e) { return 'REJECT ' + S(e); } }; + out.origin = String(location.origin); + out.href = location.href.split('?')[0].slice(0, 90); + out.star_get = await j(fetch(echoBase + '/star/get', {signal: T()})); + out.star_post_text = await j(fetch(echoBase + '/star/post', {method: 'POST', body: 'x', signal: T()})); + const fd = new FormData(); + fd.append('probe', 'x'); + out.star_post_formdata = await j(fetch(echoBase + '/star/fd', {method: 'POST', body: fd, signal: T()})); + out.star_post_preflight = await j(fetch(echoBase + '/star/pf', {method: 'POST', headers: {'X-Probe': '1'}, body: 'x', signal: T()})); + out.star_put = await j(fetch(echoBase + '/star/put', {method: 'PUT', body: 'x', signal: T()})); + out.echo_post_text = await j(fetch(echoBase + '/echo/post', {method: 'POST', body: 'x', signal: T()})); + try { const r = await fetch(echoBase + '/star/nc', {method: 'POST', mode: 'no-cors', body: 'x', signal: T()}); out.nocors_post = 'ok type=' + r.type + ' status=' + r.status; } catch (e) { out.nocors_post = 'REJECT ' + S(e); } + out.https_get = await j(fetch('https://public-api.wordpress.com/rest/v1.1/sites/en.blog.wordpress.com', {signal: T()})); + out.https_post = await j(fetch('https://public-api.wordpress.com/rest/v1.1/sites/en.blog.wordpress.com/posts/new', {method: 'POST', body: 'x', signal: T()})); + try { const b = new Blob(['xy']); out.blob_arrayBuffer = 'ok len=' + (await b.arrayBuffer()).byteLength; } catch (e) { out.blob_arrayBuffer = 'FAIL ' + S(e); } + return JSON.stringify(out, null, 1); + """ +} + struct OSLogEditorLogger: GutenbergKit.EditorLogging { private let logger: Logger diff --git a/ios/Demo-iOS/Sources/Views/EditorList.swift b/ios/Demo-iOS/Sources/Views/EditorList.swift index d637858be..3b1ae0c16 100644 --- a/ios/Demo-iOS/Sources/Views/EditorList.swift +++ b/ios/Demo-iOS/Sources/Views/EditorList.swift @@ -9,6 +9,11 @@ struct EditorList: View { @State private var showDebugSettings = false @State private var showMediaProxyServer = false + // Debug automation: jump straight to the Local WordPress editor when + // launched with GUTENBERG_AUTO_START_LOCAL_WP=1 (see SitePreparationView). + @State private var autoOpenLocalWordPress = + ProcessInfo.processInfo.environment["GUTENBERG_AUTO_START_LOCAL_WP"] == "1" + @State var configurationToDelete: ConfigurationItem? @State private var errorMessage: String? @@ -91,6 +96,9 @@ struct EditorList: View { .navigationDestination(isPresented: $showMediaProxyServer) { MediaProxyServerView() } + .navigationDestination(isPresented: $autoOpenLocalWordPress) { + SitePreparationView(site: .localWordPress) + } .navigationTitle("GutenbergKit") .toolbar { ToolbarItem(placement: .primaryAction) { diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 0f9b56ca4..2edb3cfae 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -129,7 +129,7 @@ private struct _EditorView: UIViewControllerRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(viewModel: viewModel) + Coordinator(viewModel: viewModel, configuration: configuration) } func makeUIViewController(context: Context) -> EditorViewController { @@ -139,6 +139,17 @@ private struct _EditorView: UIViewControllerRepresentable { viewController.mediaUploadDelegate = context.coordinator } viewController.webView.isInspectable = true + context.coordinator.editorViewController = viewController + + // Debug automation: if the editor never reports ready (which can + // happen under Lockdown Mode), run the upload probe anyway after a + // grace period. + if ProcessInfo.processInfo.environment["GUTENBERG_UPLOAD_PROBE"] == "1" { + Task { @MainActor [weak coordinator = context.coordinator] in + try? await Task.sleep(nanoseconds: 30_000_000_000) + coordinator?.runUploadProbeIfRequested(trigger: "timeout") + } + } viewModel.perform = { [weak viewController] in switch $0 { @@ -191,15 +202,90 @@ private struct _EditorView: UIViewControllerRepresentable { @MainActor class Coordinator: NSObject, EditorViewControllerDelegate, MediaUploadDelegate { let viewModel: EditorViewModel + let configuration: EditorConfiguration + weak var editorViewController: EditorViewController? + private var didRunUploadProbe = false - init(viewModel: EditorViewModel) { + init(viewModel: EditorViewModel, configuration: EditorConfiguration) { self.viewModel = viewModel + self.configuration = configuration + } + + // MARK: - Lockdown Mode Upload Probe (debug automation) + + /// Runs a JS capability + upload probe inside the editor web view and + /// prints the results to stdout. Enabled with GUTENBERG_UPLOAD_PROBE=1. + func runUploadProbeIfRequested(trigger: String) { + guard ProcessInfo.processInfo.environment["GUTENBERG_UPLOAD_PROBE"] == "1", + !didRunUploadProbe, + let editorViewController else { return } + didRunUploadProbe = true + + let webView = editorViewController.webView + let lockdown = webView.configuration.defaultWebpagePreferences.isLockdownModeEnabled + print("LOCKDOWN_PROBE_START trigger=\(trigger) isLockdownModeEnabled=\(lockdown)") + + let probeJS = """ + const out = {}; + const S = (e) => (e && e.name ? (e.name + ': ' + e.message) : String(e)); + const T = (ms) => AbortSignal.timeout(ms || 10000); + const race = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej({name: 'ProbeTimeout', message: (ms || 30000) + 'ms elapsed'}), ms || 30000))]); + const makeFile = () => { + const bytes = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='), c => c.charCodeAt(0)); + return new File([bytes], 'lockdown-probe.png', {type: 'image/png'}); + }; + out.href = location.href.split('?')[0]; + out.origin = location.origin; + out.typeof_FileReader = typeof FileReader; + out.typeof_WebAssembly = typeof WebAssembly; + out.typeof_apiFetch = typeof (window.wp && window.wp.apiFetch); + out.gbk_nativeUploadPort = !!(window.GBKit && window.GBKit.nativeUploadPort); + out.gbk_networkProxy = !!(window.GBKit && window.GBKit.networkProxy); + const ECHO = 'http://192.168.0.57:8890'; + try { const r = await fetch(ECHO + '/star/get', {signal: T()}); out.echo_star_get = r.status; } catch (e) { out.echo_star_get = 'REJECT ' + S(e); } + try { const fdE = new FormData(); fdE.append('probe', 'x'); const r = await fetch(ECHO + '/star/fd', {method: 'POST', body: fdE, signal: T()}); out.echo_star_post_formdata = r.status; } catch (e) { out.echo_star_post_formdata = 'REJECT ' + S(e); } + try { const r = await fetch(apiRoot, {method: 'GET', signal: T()}); out.site_get_direct = r.status; } catch (e) { out.site_get_direct = 'REJECT ' + S(e); } + try { + const fd1 = new FormData(); + fd1.append('file', makeFile()); + const r = await fetch(apiRoot + 'wp/v2/media', {method: 'POST', headers: {Authorization: authHeader}, body: fd1, signal: T()}); + out.site_post_media_direct = r.status; + } catch (e) { out.site_post_media_direct = 'REJECT ' + S(e); } + try { + const fd = new FormData(); + fd.append('file', makeFile()); + const res = await race(window.wp.apiFetch({ path: '/wp/v2/media', method: 'POST', body: fd }), 45000); + out.apiFetch_post_media = 'ok id=' + res.id; + } catch (e) { out.apiFetch_post_media = 'FAIL ' + S(e) + ' code=' + (e && e.code); } + try { + const res = await race(window.wp.apiFetch({ path: '/wp/v2/categories?per_page=1' }), 30000); + out.apiFetch_get_categories = 'ok count=' + res.length; + } catch (e) { out.apiFetch_get_categories = 'FAIL ' + S(e) + ' code=' + (e && e.code); } + return JSON.stringify(out, null, 1); + """ + + Task { @MainActor in + do { + let result = try await webView.callAsyncJavaScript( + probeJS, + arguments: [ + "apiRoot": configuration.siteApiRoot.absoluteString, + "authHeader": configuration.authHeader + ], + contentWorld: .page + ) + print("LOCKDOWN_PROBE_RESULT \(result ?? "nil")") + } catch { + print("LOCKDOWN_PROBE_ERROR \(error)") + } + } } // MARK: - EditorViewControllerDelegate func editorDidLoad(_ viewContoller: EditorViewController) { viewModel.isEditorReady = true + runUploadProbeIfRequested(trigger: "editorDidLoad") } func editor(_ viewContoller: EditorViewController, didDisplayInitialContent content: String) { diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index c94c06a2d..3197c31db 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -10,6 +10,11 @@ struct SitePreparationView: View { @State private var viewModel: SitePreparationViewModel + // Debug automation: start the editor as soon as the configuration is + // ready when launched with GUTENBERG_AUTO_START_LOCAL_WP=1. + @State + private var didAutoStart = false + init(site: ConfigurationItem) { self.viewModel = SitePreparationViewModel(configurationItem: site) } @@ -42,6 +47,14 @@ struct SitePreparationView: View { .onAppear { self.viewModel.startLoading() } + .onChange(of: viewModel.editorConfiguration) { _, newValue in + guard newValue != nil, + !didAutoStart, + ProcessInfo.processInfo.environment["GUTENBERG_AUTO_START_LOCAL_WP"] == "1" + else { return } + didAutoStart = true + viewModel.buildAndLoadConfiguration(navigation: navigation) + } } func loadedView(configuration: EditorConfiguration) -> some View { diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 0db7bee1a..ca2beab15 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -160,9 +160,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro private let lockdownModeMonitor: LockdownModeMonitor private var uploadServer: MediaUploadServer? - /// Loopback proxy that relays REST API requests through native networking - /// when the web view is subject to Lockdown Mode. See `EditorNetworkProxy`. - private var networkProxy: EditorNetworkProxy? + /// Whether `uploadServer` also hosts the Lockdown Mode REST relay. + /// See `RestRelay` and `startUploadServer()`. + private var isRestRelayEnabled = false // MARK: - Private Properties (UI) @@ -404,12 +404,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // Set asset bundle for the URL scheme handler to serve cached plugin/theme assets self.bundleProvider.set(bundle: dependencies.assetBundle) - // Start the local upload server for native media processing + // Start the local server for native media processing and, under + // Lockdown Mode, the REST relay await startUploadServer() - // Start the loopback REST relay when Lockdown Mode requires it - await startNetworkProxyIfNeeded() - // Build and inject editor configuration as window.GBKit let editorConfig = try buildEditorConfiguration(dependencies: dependencies) webView.configuration.userContentController.addUserScript(editorConfig) @@ -424,29 +422,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } /// Starts the loopback network proxy when the web view is subject to - /// Lockdown Mode, so the editor's REST API requests can be relayed through - /// native networking (Lockdown Mode breaks CORS for `file://` pages). - /// - /// No-op when Lockdown Mode is off, offline mode is enabled, or the proxy - /// is already running. Failures are logged but never block the editor — - /// the web view simply keeps its direct (possibly broken) network path. - @MainActor - private func startNetworkProxyIfNeeded() async { - guard networkProxy == nil, - !configuration.isOfflineModeEnabled, - webView.configuration.defaultWebpagePreferences.isLockdownModeEnabled else { - return - } - - do { - let proxy = EditorNetworkProxy() - try await proxy.start(configuration: configuration) - self.networkProxy = proxy - } catch { - Logger.networkProxy.error("Failed to start editor network proxy: \(error.localizedDescription)") - } - } - /// Loads the editor HTML without any dependencies (warmup mode only). /// /// This method is used exclusively by the warmup mechanism to preload editor resources @@ -466,14 +441,19 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// when it initializes. /// private func buildEditorConfiguration(dependencies: EditorDependencies) throws -> WKUserScript { - let networkProxyGlobal = networkProxy?.info.map { + // The upload pipeline and the REST relay share one local server, but + // each is advertised to JavaScript only when its feature is active: + // `nativeUploadPort` requires a delegate to process uploads, and + // `networkProxy` is only useful under Lockdown Mode. + let hasUploadPipeline = mediaUploadDelegate != nil + let networkProxyGlobal = isRestRelayEnabled ? uploadServer.map { GBKitGlobal.NetworkProxy(port: Int($0.port), token: $0.token) - } + } : nil let gbkitGlobal = try GBKitGlobal( configuration: self.configuration, dependencies: dependencies, - nativeUploadPort: uploadServer.map { Int($0.port) }, - nativeUploadToken: uploadServer?.token, + nativeUploadPort: hasUploadPipeline ? uploadServer.map { Int($0.port) } : nil, + nativeUploadToken: hasUploadPipeline ? uploadServer?.token : nil, networkProxy: networkProxyGlobal ) let stringValue = try gbkitGlobal.toString() @@ -492,6 +472,13 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// The server binds to localhost on a random port. If it fails to start, the editor /// falls back to Gutenberg's default upload behavior (the JS override won't activate /// because `nativeUploadPort` will be nil in GBKit). + /// + /// The same server hosts the Lockdown Mode REST relay: when the web view + /// is subject to Lockdown Mode, its `file://` page loses the CORS + /// exemption and WordPress rejects its `Origin: file://`, so REST requests + /// that fail in the web view are retried through this server (see + /// `RestRelay`). Relay failures never block the editor — the web view + /// simply keeps its direct (possibly broken) network path. private func startUploadServer() async { // A delegate that was provided but is already nil here was deallocated before // the editor finished loading — the host didn't hold a strong reference to it. @@ -501,31 +488,33 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro "mediaUploadDelegate was released before the editor loaded — hold a strong reference to it." ) - guard mediaUploadDelegate != nil else { - return - } - // The native upload server relays through DefaultMediaUploader, which needs a // site root and an auth header (every host provides one — the editor injects // it because the WebView has no auth cookies). Without both there is nothing - // to upload through, so leave the server down and let uploads fall to the - // default WebView path rather than start a server that could only fail. - guard !configuration.authHeader.isEmpty else { + // to upload through, so leave the upload pipeline down and let uploads fall + // to the default WebView path rather than start a pipeline that could only fail. + let needsUploadPipeline = mediaUploadDelegate != nil && !configuration.authHeader.isEmpty + isRestRelayEnabled = webView.configuration.defaultWebpagePreferences.isLockdownModeEnabled + && !configuration.isOfflineModeEnabled + + guard needsUploadPipeline || isRestRelayEnabled else { return } - let defaultUploader = DefaultMediaUploader( + let defaultUploader = needsUploadPipeline ? DefaultMediaUploader( httpClient: httpClient.uploadClient(), siteApiRoot: configuration.siteApiRoot, siteApiNamespace: configuration.siteApiNamespace - ) + ) : nil do { self.uploadServer = try await MediaUploadServer.start( uploadDelegate: mediaUploadDelegate, - defaultUploader: defaultUploader + defaultUploader: defaultUploader, + restRelay: isRestRelayEnabled ? RestRelay(configuration: configuration) : nil ) } catch { + isRestRelayEnabled = false Logger.uploadServer.error("Failed to start upload server: \(error). Falling back to default upload behavior.") } } diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index cbab7d742..69428bea8 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -36,6 +36,7 @@ final class MediaUploadServer: Sendable { static func start( uploadDelegate: (any MediaUploadDelegate)? = nil, defaultUploader: DefaultMediaUploader? = nil, + restRelay: RestRelay? = nil, maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize ) async throws -> MediaUploadServer { // Sweep temp files orphaned by a prior crash, off the editor-startup @@ -45,7 +46,7 @@ final class MediaUploadServer: Sendable { cleanOrphanedUploads() } - let context = UploadContext(uploadDelegate: uploadDelegate, defaultUploader: defaultUploader) + let context = UploadContext(uploadDelegate: uploadDelegate, defaultUploader: defaultUploader, restRelay: restRelay) // A generous ceiling for receiving the upload body. The body read is // primarily bounded by the per-read idle timeout (which reaps a stalled @@ -87,6 +88,13 @@ final class MediaUploadServer: Sendable { private static func handleRequest(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse { let parsed = request.parsed + // REST relay route: `/proxy` requests are forwarded to the site's REST + // API (Lockdown Mode support). The upstream URL rides in the query + // string, so the library's permissive CORS policy covers the preflight. + if let restRelay = context.restRelay, parsed.path == "/proxy" { + return await restRelay.handle(request) + } + // Route: only POST /upload is handled. (OPTIONS preflight is answered by // the HTTP library under its permissive CORS policy.) Match on the path // alone — the target carries a query string (e.g. `?_embed`) that the @@ -402,8 +410,8 @@ enum UploadError: Error, LocalizedError { // MARK: - Upload Context -/// Container for the upload delegate and default uploader, captured by the -/// HTTPServer handler closure and re-read on each request. +/// Container for the upload delegate, default uploader, and REST relay, +/// captured by the HTTPServer handler closure and re-read on each request. /// /// The delegate is held **weakly**. `EditorViewController.mediaUploadDelegate` is /// declared `weak` — the host owns the delegate's lifetime. Capturing it strongly @@ -417,10 +425,12 @@ enum UploadError: Error, LocalizedError { private final class UploadContext: @unchecked Sendable { weak var uploadDelegate: (any MediaUploadDelegate)? let defaultUploader: DefaultMediaUploader? + let restRelay: RestRelay? - init(uploadDelegate: (any MediaUploadDelegate)?, defaultUploader: DefaultMediaUploader?) { + init(uploadDelegate: (any MediaUploadDelegate)?, defaultUploader: DefaultMediaUploader?, restRelay: RestRelay?) { self.uploadDelegate = uploadDelegate self.defaultUploader = defaultUploader + self.restRelay = restRelay } } diff --git a/ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift b/ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift new file mode 100644 index 000000000..f909c6d92 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift @@ -0,0 +1,192 @@ +#if canImport(Network) + +import Foundation +import OSLog +import GutenbergKitHTTP + +/// Relays editor REST API requests through the native networking stack. +/// +/// ## Why this exists +/// +/// The editor web view is a `file://` page. Its REST API requests normally +/// bypass CORS thanks to the `allowUniversalAccessFromFileURLs` preference, +/// but iOS Lockdown Mode stops honoring that exemption while still making the +/// page send `Origin: file://`. WordPress core and WordPress.com sanitize that +/// value through a URL-protocol allowlist that doesn't include `file`, so they +/// respond with an empty `Access-Control-Allow-Origin` and WebKit rejects +/// every response — most visibly media uploads (`POST /wp/v2/media`). +/// +/// The relay sidesteps the problem: the web view fetches the local +/// ``MediaUploadServer`` and this handler forwards the request to the site's +/// REST API with the configured authorization header, responding with CORS +/// headers we control. +/// +/// ## Security +/// +/// - Requests reach the relay only through the local server's loopback +/// listener and per-session bearer token. +/// - Forwarding is restricted to URLs under the configured site API root, +/// so the relay cannot be used to reach arbitrary hosts. +/// - The upstream `Authorization` header is injected natively from the editor +/// configuration; any client-supplied value is discarded. +struct RestRelay: Sendable { + + /// Query parameter carrying the absolute upstream URL to forward to. + /// + /// The URL rides in the query string rather than a custom header so the + /// HTTP library's permissive CORS policy (which enumerates allowed + /// headers) covers the preflight without additions. + static let upstreamURLQueryItem = "url" + + /// The URL prefix (the site's API root) that forwarded requests must match. + private let allowedPrefix: String + + /// The authorization header injected into upstream requests. + private let authHeader: String + + private let session: URLSession + + init(configuration: EditorConfiguration) { + var prefix = configuration.siteApiRoot.absoluteString + if !prefix.hasSuffix("/") { + prefix += "/" + } + self.allowedPrefix = prefix + self.authHeader = configuration.authHeader + + let sessionConfiguration = URLSessionConfiguration.ephemeral + sessionConfiguration.timeoutIntervalForRequest = 120 + sessionConfiguration.httpCookieStorage = nil + self.session = URLSession(configuration: sessionConfiguration) + } + + /// Forwards a relayed request to the site's REST API and returns the + /// upstream response with permissive CORS headers. + func handle(_ request: HTTPServer.Request) async -> HTTPResponse { + let parsed = request.parsed + + guard let upstreamURL = Self.upstreamURL(from: parsed.query) else { + return Self.errorResponse(status: 400, body: "Missing or invalid `\(Self.upstreamURLQueryItem)` query parameter") + } + + // SSRF guard: only forward to the configured site API root. + guard upstreamURL.absoluteString.hasPrefix(allowedPrefix) else { + Logger.restRelay.error("Refusing to relay request outside the site API root") + return Self.errorResponse(status: 403, body: "Upstream URL is outside the allowed API root") + } + + var upstreamRequest = URLRequest(url: upstreamURL) + upstreamRequest.httpMethod = parsed.method + + for (name, value) in parsed.allHeaders where !Self.requestHeadersToStrip.contains(name.lowercased()) { + upstreamRequest.setValue(value, forHTTPHeaderField: name) + } + if !authHeader.isEmpty { + upstreamRequest.setValue(authHeader, forHTTPHeaderField: "Authorization") + } + + if let body = parsed.body { + if let data = body.inMemoryData { + upstreamRequest.httpBody = data + } else { + // Large bodies are buffered to disk by the request parser; + // stream them to avoid loading uploads fully into memory. + do { + upstreamRequest.httpBodyStream = try body.makeInputStream() + upstreamRequest.setValue("\(body.count)", forHTTPHeaderField: "Content-Length") + } catch { + Logger.restRelay.error("Failed to open request body stream: \(error)") + return Self.errorResponse(status: 500, body: "Failed to read request body") + } + } + } + + do { + let upstream = HTTPResponse(try await session.data(for: upstreamRequest)) + return HTTPResponse( + status: upstream.status, + statusText: upstream.statusText, + headers: Self.merge(upstream.headers, adding: Self.corsHeaders), + body: upstream.body + ) + } catch { + Logger.restRelay.error("Upstream request failed: \(error.localizedDescription)") + return Self.errorResponse(status: 502, body: "Upstream request failed: \(error.localizedDescription)") + } + } + + // MARK: - CORS + + /// Response headers added to every relayed response. The library's + /// permissive CORS policy stamps `Access-Control-Allow-Origin` and friends; + /// the exposed headers keep paginated REST responses readable to + /// `api-fetch` callers. + private static let corsHeaders: [(String, String)] = [ + ("Access-Control-Expose-Headers", "X-WP-Total, X-WP-TotalPages, Link"), + ] + + /// Request headers that must not be forwarded upstream. + /// + /// `host`/`content-length`/`accept-encoding` are recalculated by URLSession; + /// `origin` and `referer` would leak the local page context to the server + /// (and WordPress rejects `file://` origins — the exact problem the relay + /// exists to solve); the rest are relay-internal. + private static let requestHeadersToStrip: Set = [ + "host", "content-length", "accept-encoding", "connection", + "origin", "referer", + "authorization", "relay-authorization", "proxy-authorization", + ] + + /// Upstream response headers dropped from relayed responses. + /// + /// The CORS strip is load-bearing: the library adds its permissive CORS + /// headers with `addingHeadersIfAbsent`, so an upstream + /// `Access-Control-Allow-Origin` (WordPress sends an empty one for origins + /// it rejects) would otherwise survive and be honored by WebKit over the + /// policy's `*`. + /// + /// `Content-Encoding` must go because URLSession already decompressed the + /// body: advertising the upstream encoding would make WebKit decode the + /// plain bytes a second time, corrupting every gzipped JSON response. + private static let responseHeadersToStrip: Set = [ + "access-control-allow-origin", "access-control-allow-credentials", + "access-control-allow-headers", "access-control-allow-methods", + "access-control-expose-headers", "access-control-max-age", "vary", + "content-encoding", + ] + + /// Appends local response headers to upstream headers, dropping the + /// upstream's own CORS and transport-encoding headers (see + /// `responseHeadersToStrip`). + private static func merge( + _ upstream: [(String, String)], + adding cors: [(String, String)] + ) -> [(String, String)] { + upstream.filter { !Self.responseHeadersToStrip.contains($0.0.lowercased()) } + cors + } + + /// Extracts the upstream URL from the relay request's query string. + private static func upstreamURL(from query: String) -> URL? { + var components = URLComponents() + components.percentEncodedQuery = query + guard let value = components.queryItems?.first(where: { $0.name == upstreamURLQueryItem })?.value, + let url = URL(string: value) else { + return nil + } + return url + } + + private static func errorResponse(status: Int, body: String) -> HTTPResponse { + HTTPResponse( + status: status, + headers: corsHeaders + [("Content-Type", "text/plain")], + body: Data(body.utf8) + ) + } +} + +extension Logger { + static let restRelay = Logger(subsystem: "GutenbergKit", category: "rest-relay") +} + +#endif // canImport(Network) diff --git a/ios/Sources/GutenbergKit/Sources/Services/EditorNetworkProxy.swift b/ios/Sources/GutenbergKit/Sources/Services/EditorNetworkProxy.swift deleted file mode 100644 index 20f76f43e..000000000 --- a/ios/Sources/GutenbergKit/Sources/Services/EditorNetworkProxy.swift +++ /dev/null @@ -1,249 +0,0 @@ -#if canImport(Network) - -import Foundation -import OSLog -import GutenbergKitHTTP - -/// A loopback HTTP proxy that relays editor REST API requests through the -/// native networking stack. -/// -/// ## Why this exists -/// -/// The editor web view is a `file://` page. Its REST API requests normally -/// bypass CORS thanks to the `allowUniversalAccessFromFileURLs` preference, -/// but iOS Lockdown Mode stops honoring that exemption while still making the -/// page send `Origin: file://`. WordPress core and WordPress.com sanitize that -/// value through a URL-protocol allowlist that doesn't include `file`, so they -/// respond with an empty `Access-Control-Allow-Origin` and WebKit rejects -/// every response — most visibly media uploads (`POST /wp/v2/media`). -/// -/// The proxy sidesteps the problem: the web view fetches `127.0.0.1` and this -/// server forwards the request to the site's REST API with the configured -/// authorization header, echoing the page's `Origin` in the CORS response -/// headers it controls. WebKit accepts an echoed `file://` origin. -/// -/// ## Security -/// -/// - The underlying ``HTTPServer`` binds to `127.0.0.1` only and requires a -/// random per-session bearer token (`Relay-Authorization`) on every -/// non-preflight request. -/// - Forwarding is restricted to URLs under the configured site API root, -/// so the proxy cannot be used to reach arbitrary hosts. -/// - The upstream `Authorization` header is injected natively from the editor -/// configuration; any client-supplied value is discarded. -@MainActor -final class EditorNetworkProxy { - - /// Connection details the web view needs to route requests through the proxy. - struct Info: Sendable { - let port: UInt16 - let token: String - } - - /// Header carrying the absolute upstream URL to forward the request to. - static let upstreamURLHeader = "X-GBK-Upstream-URL" - - private var server: HTTPServer? - - private(set) var info: Info? - - /// Starts the proxy for the given configuration. - /// - /// - Returns: The connection info to expose to the web view. - @discardableResult - func start(configuration: EditorConfiguration) async throws -> Info { - if let info { - return info - } - - let allowedPrefix = Self.normalizedPrefix(configuration.siteApiRoot) - let authHeader = configuration.authHeader - let session = Self.makeSession() - - let server = try await HTTPServer.start( - name: "editor-network-proxy", - handler: { request in - await Self.handle( - request, - allowedPrefix: allowedPrefix, - authHeader: authHeader, - session: session - ) - } - ) - - let info = Info(port: server.port, token: server.token) - self.server = server - self.info = info - Logger.networkProxy.info("Editor network proxy listening on 127.0.0.1:\(info.port)") - return info - } - - func stop() { - server?.stop() - server = nil - info = nil - } - - deinit { - server?.stop() - } - - // MARK: - Request Handling - - private static func handle( - _ request: HTTPServer.Request, - allowedPrefix: String, - authHeader: String, - session: URLSession - ) async -> HTTPResponse { - let parsed = request.parsed - let corsHeaders = Self.corsHeaders(for: parsed) - - // CORS preflight: the Relay-Authorization and upstream-URL headers make - // every proxied request non-simple, so preflights are guaranteed. - if parsed.method.uppercased() == "OPTIONS" { - return HTTPResponse(status: 204, headers: corsHeaders, body: Data()) - } - - guard let upstreamString = parsed.header(upstreamURLHeader), - let upstreamURL = URL(string: upstreamString) else { - return HTTPResponse( - status: 400, - headers: corsHeaders + [("Content-Type", "text/plain")], - body: Data("Missing or invalid \(upstreamURLHeader) header".utf8) - ) - } - - // SSRF guard: only forward to the configured site API root. - guard upstreamURL.absoluteString.hasPrefix(allowedPrefix) else { - Logger.networkProxy.error("Refusing to proxy request outside the site API root") - return HTTPResponse( - status: 403, - headers: corsHeaders + [("Content-Type", "text/plain")], - body: Data("Upstream URL is outside the allowed API root".utf8) - ) - } - - var upstreamRequest = URLRequest(url: upstreamURL) - upstreamRequest.httpMethod = parsed.method - - for (name, value) in parsed.allHeaders where !Self.requestHeadersToStrip.contains(name.lowercased()) { - upstreamRequest.setValue(value, forHTTPHeaderField: name) - } - if !authHeader.isEmpty { - upstreamRequest.setValue(authHeader, forHTTPHeaderField: "Authorization") - } - - if let body = parsed.body { - if let data = body.inMemoryData { - upstreamRequest.httpBody = data - } else { - // Large bodies are buffered to disk by the request parser; - // stream them to avoid loading uploads fully into memory. - do { - upstreamRequest.httpBodyStream = try body.makeInputStream() - upstreamRequest.setValue("\(body.count)", forHTTPHeaderField: "Content-Length") - } catch { - Logger.networkProxy.error("Failed to open request body stream: \(error)") - return HTTPResponse( - status: 500, - headers: corsHeaders + [("Content-Type", "text/plain")], - body: Data("Failed to read request body".utf8) - ) - } - } - } - - do { - let upstream = HTTPResponse(try await session.data(for: upstreamRequest)) - return HTTPResponse( - status: upstream.status, - statusText: upstream.statusText, - headers: Self.merge(upstream.headers, adding: corsHeaders), - body: upstream.body - ) - } catch { - Logger.networkProxy.error("Upstream request failed: \(error.localizedDescription)") - return HTTPResponse( - status: 502, - statusText: "Bad Gateway", - headers: corsHeaders + [("Content-Type", "text/plain")], - body: Data("Upstream request failed: \(error.localizedDescription)".utf8) - ) - } - } - - // MARK: - CORS - - /// Builds the CORS headers for a proxied response. - /// - /// The page's `Origin` is echoed verbatim: under Lockdown Mode the editor - /// page sends the non-standard `Origin: file://`, which WebKit accepts as - /// long as the response echoes it exactly. - private static func corsHeaders(for request: ParsedHTTPRequest) -> [(String, String)] { - var headers: [(String, String)] = [ - ("Access-Control-Allow-Origin", request.header("Origin") ?? "*"), - ("Vary", "Origin"), - ("Access-Control-Expose-Headers", "X-WP-Total, X-WP-TotalPages, Link"), - ] - if request.parsedMethodIsOptions { - headers.append(("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")) - let requestedHeaders = request.header("Access-Control-Request-Headers") - ?? "Relay-Authorization, \(upstreamURLHeader), Authorization, Content-Type, Content-Disposition, X-WP-Nonce" - headers.append(("Access-Control-Allow-Headers", requestedHeaders)) - headers.append(("Access-Control-Max-Age", "600")) - } - return headers - } - - /// Request headers that must not be forwarded upstream. - /// - /// `host`/`content-length`/`accept-encoding` are recalculated by URLSession; - /// `origin` and `referer` would leak the local page context to the server - /// (and WordPress rejects `file://` origins — the exact problem this proxy - /// exists to solve); the rest are proxy-internal. - private static let requestHeadersToStrip: Set = [ - "host", "content-length", "accept-encoding", "connection", - "origin", "referer", - "authorization", "relay-authorization", "proxy-authorization", - upstreamURLHeader.lowercased(), - ] - - /// Appends CORS headers to upstream headers, dropping any CORS headers the - /// upstream may have sent so the echoed values win. - private static func merge( - _ upstream: [(String, String)], - adding cors: [(String, String)] - ) -> [(String, String)] { - let corsNames = Set(cors.map { $0.0.lowercased() }) - return upstream.filter { !corsNames.contains($0.0.lowercased()) } + cors - } - - private static func normalizedPrefix(_ url: URL) -> String { - var prefix = url.absoluteString - if !prefix.hasSuffix("/") { - prefix += "/" - } - return prefix - } - - private static func makeSession() -> URLSession { - let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = 120 - configuration.httpCookieStorage = nil - return URLSession(configuration: configuration) - } -} - -private extension ParsedHTTPRequest { - var parsedMethodIsOptions: Bool { - method.uppercased() == "OPTIONS" - } -} - -extension Logger { - static let networkProxy = Logger(subsystem: "GutenbergKit", category: "network-proxy") -} - -#endif // canImport(Network) diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index a69a03861..8fdf82801 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -94,10 +94,12 @@ function networkProxyFallbackMiddleware( options, next ) { /** * Performs a request through the native loopback proxy. * - * The absolute upstream URL travels in the `X-GBK-Upstream-URL` header and - * the per-session proxy token in `Relay-Authorization` (`Proxy-*` headers - * are stripped by `fetch()`). The upstream `Authorization` header is - * injected natively, so any value present here is dropped. + * The absolute upstream URL travels in the `url` query parameter (a query + * parameter rather than a custom header, so the local server's stock CORS + * policy covers the preflight) and the per-session proxy token in + * `Relay-Authorization` (`Proxy-*` headers are stripped by `fetch()`). The + * upstream `Authorization` header is injected natively, so any value present + * here is dropped. * * @param {Object} options Fully-transformed api-fetch options. * @param {Object} networkProxy Proxy connection details. @@ -110,12 +112,13 @@ async function proxyFetch( options, networkProxy ) { const headers = { ...( options.headers || {} ) }; delete headers.Authorization; headers[ 'Relay-Authorization' ] = `Bearer ${ networkProxy.token }`; - headers[ 'X-GBK-Upstream-URL' ] = upstreamUrl; let response; try { response = await window.fetch( - `http://127.0.0.1:${ networkProxy.port }/proxy`, + `http://127.0.0.1:${ + networkProxy.port + }/proxy?url=${ encodeURIComponent( upstreamUrl ) }`, { method: options.method || 'GET', headers,