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 070954c10..dcf2fb27d 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 da4c1fefe..ca2beab15 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? + /// Whether `uploadServer` also hosts the Lockdown Mode REST relay. + /// See `RestRelay` and `startUploadServer()`. + private var isRestRelayEnabled = false + // 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") @@ -393,7 +404,8 @@ 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() // Build and inject editor configuration as window.GBKit @@ -409,6 +421,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } + /// Starts the loopback network proxy when the web view is subject to /// 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 +441,20 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// when it initializes. /// private func buildEditorConfiguration(dependencies: EditorDependencies) throws -> WKUserScript { + // 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() @@ -450,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. @@ -459,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/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/src/utils/api-fetch.js b/src/utils/api-fetch.js index 5f8885e4d..8fdf82801 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,141 @@ 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 `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. + * @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 }`; + + let response; + try { + response = await window.fetch( + `http://127.0.0.1:${ + networkProxy.port + }/proxy?url=${ encodeURIComponent( upstreamUrl ) }`, + { + 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. *