Skip to content

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

Draft
jkmassel wants to merge 2 commits 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 2 commits into
trunkfrom
jkmassel/lockdown-mode-file-uploads

Conversation

@jkmassel

@jkmassel jkmassel commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the editor's REST API traffic failing with "Could not get a valid response from the server." when iOS Lockdown Mode is enabled. Ref CMM-2014.

  • Add RestRelay — a request handler on the native media upload server from feat: proxy media uploads through native delegate for processing #357 that relays the editor's REST API requests through URLSession
  • Start the local server automatically under Lockdown Mode, even when no media upload delegate is configured, and pass {port, token} to the editor via window.GBKit.networkProxy
  • Route the editor's REST traffic through the relay — a window.fetch wrapper plus a jQuery.ajaxPrefilter — so uploads, 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 — the local server's permissive CORS policy (Access-Control-Allow-Origin: *, verified accepted by WebKit under Lockdown Mode for these non-credentialed requests) replaces the CORS negotiation WordPress fails.

This builds on the media upload server that #357 introduced and #561 hardened, rather than running a second loopback server:

  • ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift: the relay handler. Forwards a request only when its url query parameter falls under the configured siteApiRoot (the upstream URL rides in the query string so the HTTP library's stock CORS policy covers the preflight), streams disk-buffered bodies, and injects the configured Authorization while discarding any client-supplied value. It also strips the upstream response's CORS headers — load-bearing, because the library adds its own with addingHeadersIfAbsent, and WordPress's empty Access-Control-Allow-Origin would otherwise survive. Redirects are re-checked against the same siteApiRoot allowlist (a per-task RedirectGuard), so a 3xx can't carry the injected Authorization to another host.
  • ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift: routes /proxy requests to the relay; /upload and everything else is unchanged.
  • ios/Sources/GutenbergKit/Sources/EditorViewController.swift: starts the server when a media upload delegate is configured or the web view is subject to Lockdown Mode (defaultWebpagePreferences.isLockdownModeEnabled at web view creation). GBKit.nativeUploadPort is still gated on the delegate; the new GBKit.networkProxy is gated on Lockdown Mode — the two JS code paths cannot activate each other by accident. 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.
  • src/utils/api-fetch.js: installs a window.fetch wrapper (createNetworkProxyFetch) that routes site requests through the relay at the layer below @wordpress/api-fetch's default handler — so it sees the fully-serialized request (data→body, Content-Type, per_page=-1 pagination, abort signal) and reuses api-fetch's own response parsing. An apiFetch.use() middleware can't do this: it always runs outside the built-in handler that finalizes the request. jQuery.ajax / wp.ajax (XHR, invisible to the fetch wrapper) get a companion jQuery.ajaxPrefilter (createNetworkProxyAjaxPrefilter). Both are gated on GBKit.networkProxy and prefer the relay after the first success; media uploads keep their priority routing through nativeMediaUploadMiddleware/upload from feat: proxy media uploads through native delegate for processing #357.
  • ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift: adds the optional networkProxy payload.

The relay's /proxy URL parsing was fixed. RestRelay read the upstream url from the request query through URLComponents.percentEncodedQuery, but ParsedHTTPRequest.query includes the leading ? — which made the first query item ?url, so the lookup for url never matched and every /proxy request returned a 400. It never surfaced on device: validation ran against the /upload pipeline and the older header-based design, never /proxy with the ?url= form. Fixed by stripping the leading ?; covered by RestRelayTests.

Nothing changes outside Lockdown Mode. Without a delegate the server didn't start before and still doesn't; with one, it behaves exactly as on trunk. The middleware is a no-op when GBKit.networkProxy is absent.

The relay is not a general proxy. It is reachable only via loopback with the per-session bearer token, refuses upstream URLs outside the site's API root, and strips client-supplied Authorization in favor of the natively-held credential.

Raw XMLHttpRequest is not relayed — yet. The relay covers fetch and jQuery.ajax / wp.ajax — the only request paths the editor core and its bundled/plugin scripts actually use. A direct, non-jQuery XMLHttpRequest would still bypass the relay and fail under Lockdown Mode; nothing exercises that path today, so it's left uncovered until something does.

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 an attachment on a local wp-env site through the relay after the direct fetch rejected. Notably, the wp-env Playground server sends no usable CORS headers to any origin, so this exercises the worst-case server. (Validated on the pre-rebase branch; the post-rebase revalidation below repeats this end-to-end.)
  • Simulator repro validated behaviorally: forcing isLockdownModeEnabled = true reproduces the device's lockdown fingerprint cell-for-cell (WebAssembly/FileReader removed, CORS enforcement flips no-cors responses from basic to opaque, direct REST rejects, relay carries the upload).
  • swift test — all 24 MediaUploadServer tests pass with the relay routing in place; JS suite passes including the feat: proxy media uploads through native delegate for processing #357 middleware tests.
  • swift test --filter RestRelay — 15 integration tests for the relay itself: /proxy URL parsing + SSRF refusal, forwarding, Authorization injection + client-auth stripping, request/response header + CORS stripping, the redirect guard, and 502 on an unreachable upstream. npm test covers the window.fetch wrapper and jQuery.ajaxPrefilter.
  • On-device against a real WordPress site (vanilla.wpmt.co) under Lockdown Mode: uploads and a non-media GET (/wp/v2/categories) succeed; direct fetches still reject (real WP sanitizes Origin: file:// to an empty ACAO).
  • Regression, Lockdown Mode off, no delegate: open the editor — no local server starts (no "HTTP server started" line in the GutenbergKit OSLog subsystem), and uploads behave as on trunk.
  • Regression, delegate configured, Lockdown Mode off: uploads route through /upload with native processing exactly as on trunk.

Before review

  • Unit tests for RestRelay (upstream-URL refusal, Authorization replacement, upstream CORS-header stripping) and the JS middleware (fallback, sticky preference, parse semantics)
  • Decide whether LockdownModeSheet copy should soften now that REST traffic works — 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

wpmobilebot commented Jul 9, 2026

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 8c77be2

…down Mode

# Conflicts:
#	ios/Sources/GutenbergKit/Sources/EditorViewController.swift
#	ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift
#	src/utils/api-fetch.js
@jkmassel
jkmassel force-pushed the jkmassel/lockdown-mode-file-uploads branch from f933707 to a999b0d Compare August 14, 2026 18:05
@jkmassel
jkmassel force-pushed the jkmassel/lockdown-mode-file-uploads branch from a999b0d to 8c77be2 Compare August 14, 2026 18:45
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