Skip to content

fix: route editor REST requests through a native proxy under iOS Lockdown Mode#544

Draft
jkmassel wants to merge 1 commit into
trunkfrom
jkmassel/lockdown-mode-file-uploads
Draft

fix: route editor REST requests through a native proxy under iOS Lockdown Mode#544
jkmassel wants to merge 1 commit into
trunkfrom
jkmassel/lockdown-mode-file-uploads

Conversation

@jkmassel

@jkmassel jkmassel commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes media uploads failing with "Could not get a valid response from the server." when iOS Lockdown Mode is enabled. Ref CMM-2014.

  • Add EditorNetworkProxy — a loopback HTTP proxy built on the GutenbergKitHTTP server from feat: add cross-platform HTTP/1.1 parser and local proxy server for iOS and Android #367 that relays the editor's REST API requests through URLSession
  • Start it automatically when the web view is subject to Lockdown Mode, and pass its {port, token} to the editor via window.GBKit
  • Retry failed requests through the proxy in a new api-fetch middleware, so uploads (and link search, embeds, and any other in-editor REST traffic) work under Lockdown Mode

Root Cause

The editor is a file:// page (loadFileURL), and every REST request it makes is a cross-origin fetch() that normally bypasses CORS via the allowUniversalAccessFromFileURLs preference. Lockdown Mode stops honoring that exemption while the preference still changes how WebKit serializes the page's origin: requests go out with Origin: file://.

WordPress core sanitizes the echoed origin in rest_send_cors_headers() through esc_url_raw(), whose protocol allowlist does not include file — so both WP.com and self-hosted sites respond with an empty Access-Control-Allow-Origin. WebKit rejects the response, and api-fetch surfaces its generic fetch_error — the exact message in the user reports.

Verified on an iPhone 15 Pro (iOS 26.5) with system Lockdown Mode enabled, using a logging echo server to observe the wire:

Page origin on the wire WordPress ACAO response Result
Origin: file:// (current editor) empty (sanitized) ❌ fetch rejects
Origin: null echoed verbatim (core special-cases 'null')
Origin: file://, echoed verbatim by the server matches

Two corollaries worth knowing: requests do reach the server — only the response is discarded — so a "failed" upload can still create an attachment; and endpoints answering an unconditional Access-Control-Allow-Origin: * keep working, which is why the editor otherwise appears functional.

What We Explored

  1. Opting the web view out of Lockdown Mode (WKWebpagePreferences.isLockdownModeEnabled = false) crashes with NSInternalInconsistencyException: iOS requires the restricted com.apple.developer.web-browser entitlement, which only browsers can hold.
  2. Dropping the file-URL preferences makes the page send Origin: null, which WordPress accepts — verified on device — but the editor fails to boot: Vite's dynamic import() of file:// chunks requires allowFileAccessFromFileURLs.
  3. Serving the editor from a custom URL scheme produces Origin: gbk-probe://probe-host, which the same sanitization reduces to an empty header. Same failure as file://.
  4. Reworking the request body in JS (hand-built multipart, raw binary with Content-Disposition) changes nothing — the failure is response-side CORS, not the body.

Fix

The web view fetches http://127.0.0.1:<port> and native code performs the real request — CORS is negotiated with a server we control, which echoes the page's Origin verbatim (WebKit accepts an echoed file://).

  • ios/Sources/GutenbergKit/Sources/Services/EditorNetworkProxy.swift: wraps HTTPServer (loopback-only, per-session bearer token via Relay-Authorization). Forwards a request only when its X-GBK-Upstream-URL falls under the configured siteApiRoot, streams disk-buffered bodies, and answers preflights.
  • ios/Sources/GutenbergKit/Sources/EditorViewController.swift: starts the proxy before the editor loads when defaultWebpagePreferences.isLockdownModeEnabled is set, which reflects the system state at web view creation. Also gains a GUTENBERG_FORCE_LOCKDOWN_MODE=1 debug hook, since Lockdown Mode's web view restrictions can be forced per-view — this is how the fix is testable in the Simulator.
  • ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift: adds the optional networkProxy payload.
  • src/utils/api-fetch.js: adds networkProxyFallbackMiddleware, registered innermost so it sees the fully-built request. It retries fetch_error rejections through the proxy and prefers the proxy for subsequent requests after the first success.
  • Package.swift: GutenbergKit now depends on GutenbergKitHTTP.

Nothing changes outside Lockdown Mode. The proxy only starts when the lockdown flag is true at web view creation, and the middleware is a no-op when GBKit.networkProxy is absent.

The proxy is not a general relay. It binds to 127.0.0.1, rejects requests without the per-session token, refuses upstream URLs outside the site's API root, and strips any client-supplied Authorization in favor of the natively-held credential.

Test plan

  • On an iPhone 15 Pro with system Lockdown Mode enabled, the editor's upload path (wp.apiFetch POST /wp/v2/media with multipart FormData) created attachment id=5 on a local wp-env site: the direct fetch rejected, the middleware retried through the proxy. Notably, the wp-env Playground server sends no usable CORS headers to any origin, so this exercises the worst-case server.
  • The editor boots and reports ready with the proxy active (same device and mode).
  • swift build (host platform) and eslint pass.
  • Simulator repro: launch the demo app with GUTENBERG_FORCE_LOCKDOWN_MODE=1 against wp-env (make wp-env-start), insert an image from the block inserter. Without this PR the snackbar reads "Could not get a valid response from the server."; with it, the image uploads and the block's src swaps to the site URL.
  • Regression, Lockdown Mode off: open the editor normally — the network-proxy OSLog category logs no "listening" line, and uploads behave as on trunk.

Before review

  • Unit tests: proxy handler (upstream-URL refusal, Authorization replacement, preflight and CORS echo) and the JS middleware (fallback, sticky preference, parse semantics)
  • Decide whether LockdownModeSheet copy should soften now that uploads work — WebAssembly/FileReader-dependent features remain degraded (tracked with CMM-2014)

Related issues

@github-actions github-actions Bot added the [Type] Bug An existing feature does not function as intended label Jul 9, 2026
@wpmobilebot

Copy link
Copy Markdown

XCFramework Build

This PR's XCFramework is available for testing. Add the following to your Package.swift:

.package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/544")

Built from f933707

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants