diff --git a/ios/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift index e98392ae2..282245d4d 100644 --- a/ios/Demo-iOS/Sources/ConfigurationItem.swift +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -68,16 +68,17 @@ struct LocalWordPressCredentials: Codable { let authHeader: String /// Loads credentials from the file path specified in the `WP_ENV_CREDENTIALS_PATH` environment variable. + /// + /// Returns `nil` when the variable is unset or the file cannot be read, so + /// a misconfigured environment surfaces as the "not configured" message + /// rather than as a confusing failure against some other site. static func load() -> LocalWordPressCredentials? { - guard let path = ProcessInfo.processInfo.environment["WP_ENV_CREDENTIALS_PATH"] else { + guard 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) else { return nil } - - guard let data = FileManager.default.contents(atPath: path) else { - return nil - } - - return try? JSONDecoder().decode(LocalWordPressCredentials.self, from: data) + return credentials } } diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index 83a0741e0..3b8336d19 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -43,6 +43,14 @@ struct GutenbergApp: App { // Configure logger for GutenbergKit EditorLogger.shared = OSLogEditorLogger() EditorLogger.logLevel = .debug + + // Opt-in: keep the device awake while the demo app is foregrounded. + // The debugging workflows here (Web Inspector, devicectl console) + // break when the device auto-locks, but a demo app that never lets + // the screen sleep is its own surprise. + if ProcessInfo.processInfo.environment["GUTENBERG_DISABLE_IDLE_TIMER"] == "1" { + UIApplication.shared.isIdleTimerDisabled = true + } } var body: some Scene { 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/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/EditorLogging.swift b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift index 77b0a461c..46c49d02c 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift @@ -31,6 +31,9 @@ extension Logger { /// Logs upload server activity static let uploadServer = Logger(subsystem: "GutenbergKit", category: "upload-server") + + /// Logs REST relay activity + static let restRelay = Logger(subsystem: "GutenbergKit", category: "rest-relay") } public struct SignpostMonitor: Sendable { diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index da4c1fefe..c03a64dba 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -160,6 +160,15 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro private let lockdownModeMonitor: LockdownModeMonitor private var uploadServer: MediaUploadServer? + /// Whether `uploadServer` was started with a media upload pipeline behind + /// it, and so whether its port and token may be advertised to JavaScript. + /// See `startUploadServer()`. + private var isUploadPipelineEnabled = false + + /// 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 +239,16 @@ 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. Debug-only so a release build of a host app cannot have + // the editor's transport rerouted by its process environment. + #if DEBUG + if ProcessInfo.processInfo.environment["GUTENBERG_FORCE_LOCKDOWN_MODE"] == "1" { + config.defaultWebpagePreferences.isLockdownModeEnabled = true + } + #endif + // Set-up communications with the editor. config.userContentController.add(controller, name: "editorDelegate") @@ -393,7 +412,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 @@ -428,11 +448,24 @@ 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 `startUploadServer()` + // actually enabled it: routing uploads to a server started without an + // uploader behind it would fail every one of them, and `networkProxy` + // is only useful under Lockdown Mode. + let networkProxyGlobal = isRestRelayEnabled ? uploadServer.map { + GBKitGlobal.NetworkProxy( + port: Int($0.port), + token: $0.token, + baseURL: RestRelay.baseURL(port: $0.port) + ) + } : nil let gbkitGlobal = try GBKitGlobal( configuration: self.configuration, dependencies: dependencies, - nativeUploadPort: uploadServer.map { Int($0.port) }, - nativeUploadToken: uploadServer?.token + nativeUploadPort: isUploadPipelineEnabled ? uploadServer.map { Int($0.port) } : nil, + nativeUploadToken: isUploadPipelineEnabled ? uploadServer?.token : nil, + networkProxy: networkProxyGlobal ) let stringValue = try gbkitGlobal.toString() @@ -450,6 +483,15 @@ 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 the editor + /// sends every site REST request through this server (see `RestRelay`). + /// The relay is the transport, not a fallback — it is advertised only when + /// a direct request cannot work, so there is no direct attempt to retry + /// from. If this server fails to start, `networkProxy` stays nil and the + /// web view 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 +501,34 @@ 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. + isUploadPipelineEnabled = mediaUploadDelegate != nil && !configuration.authHeader.isEmpty + isRestRelayEnabled = webView.configuration.defaultWebpagePreferences.isLockdownModeEnabled + && !configuration.isOfflineModeEnabled + + guard isUploadPipelineEnabled || isRestRelayEnabled else { return } - let defaultUploader = DefaultMediaUploader( + let defaultUploader = isUploadPipelineEnabled ? DefaultMediaUploader( httpClient: httpClient.uploadClient(), siteApiRoot: configuration.siteApiRoot, siteApiNamespace: configuration.siteApiNamespace - ) + ) : nil do { self.uploadServer = try await MediaUploadServer.start( - uploadDelegate: mediaUploadDelegate, - defaultUploader: defaultUploader + uploadDelegate: isUploadPipelineEnabled ? mediaUploadDelegate : nil, + defaultUploader: defaultUploader, + restRelay: isRestRelayEnabled ? RestRelay(configuration: configuration) : nil ) } catch { + isUploadPipelineEnabled = false + 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..f48d16752 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -26,16 +26,35 @@ final class MediaUploadServer: Sendable { /// Exposed so tests can await completion. (Mirrors Android's `cleanupJob`.) let cleanupTask: Task + /// The concurrent connection ceiling for the local server. + /// + /// The library's default of 5 suits a server that only ever receives one + /// upload at a time. This one also carries every REST request the editor + /// makes under Lockdown Mode (see ``RestRelay``), and editor boot fans out + /// well past five: each connection serves exactly one request + /// (`Connection: close`), and a connection past the limit is closed + /// immediately, surfacing in JavaScript as an unretried `fetch_error`. + /// WebKit caps its own concurrency per host well below this, so the ceiling + /// exists to bound a runaway, not to schedule normal traffic. + static let maxConnections = 32 + /// Creates and starts a new upload server. /// /// - Parameters: /// - uploadDelegate: Optional delegate for customizing file processing and upload. /// - defaultUploader: Fallback uploader used when no delegate provides `uploadFile`. + /// - restRelay: Optional ``RestRelay``. When present, this server also + /// answers the relay's route, becoming the transport for every REST + /// request the editor makes under iOS Lockdown Mode — which is what + /// `GBKit.networkProxy` advertises to the web view. When `nil`, the + /// server serves only the upload route and the editor calls the site + /// directly. /// - maxRequestBodySize: The maximum allowed request body size in bytes. /// Requests exceeding this limit receive a 413 response. Defaults to 4 GB. 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 +64,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 @@ -58,7 +77,11 @@ final class MediaUploadServer: Sendable { let server = try await HTTPServer.start( name: "media-upload", requiresAuthentication: true, + // The editor web view is this server's only legitimate client, and + // every request it makes carries these headers. + requiresBrowserOrigin: true, maxRequestBodySize: maxRequestBodySize, + maxConnections: maxConnections, bodyReadTimeout: bodyReadTimeout, cors: .permissive, delegate: ServerDelegate(), @@ -87,6 +110,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 path after the route resolving + // against the site API root. + if let restRelay = context.restRelay, RestRelay.handles(parsed) { + 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 @@ -281,23 +311,20 @@ final class MediaUploadServer: Sendable { } private static func errorResponse(status: Int, message: String) -> HTTPResponse { - // Emit a WordPress-REST-style error object so the JS middleware normalizes - // it (and surfaces `message`) the same way it does a relayed WordPress - // error — the local server's own errors need no special-casing. - let payload = ["code": "upload_error", "message": message] - let body = (try? JSONSerialization.data(withJSONObject: payload)) - ?? Data(#"{"code":"upload_error","message":"Upload failed"}"#.utf8) - return HTTPResponse( - status: status, - headers: [("Content-Type", "application/json")], - body: body - ) + .wordPressError(status: status, code: "upload_error", message: message) } - /// Answers the server's recoverable parse errors (e.g. an over-limit body) - /// with the same JSON `{code, message}` shape the editor expects, so the - /// middleware surfaces a real message ("The file is too large…") instead of a - /// generic parse-failure. A leaf object — the HTTP server retains it. + /// Answers the errors the HTTP server raises itself with the same JSON + /// `{code, message}` shape the editor expects, so the middleware surfaces a + /// real message instead of a generic parse failure. + /// + /// Every response on this server reaches `@wordpress/api-fetch`, which parses + /// all of them as JSON: a `text/plain` refusal arrives as `invalid_json` + /// ("The response is not a valid JSON response."), losing the reason. Under + /// the relay that covers every REST request the editor makes, so these are + /// the failures a user actually sees. + /// + /// A leaf object — the HTTP server retains it. private final class ServerDelegate: HTTPServerDelegate { func response(forRecoverableParseError error: HTTPRequestParseError) -> HTTPResponse { let message: String = switch error { @@ -306,6 +333,24 @@ final class MediaUploadServer: Sendable { } return MediaUploadServer.errorResponse(status: error.httpStatus, message: message) } + + func errorBody(for error: HTTPServerError) -> HTTPErrorBody? { + let (code, message): (String, String) = switch error { + case .authenticationFailed: + ("server_unauthorized", "The editor's credential for the local server was missing or stale.") + case .forbiddenOrigin: + ("server_forbidden_origin", "The local server accepts requests from the editor only.") + case .lengthRequired: + ("server_length_required", "The request did not declare its content length.") + case .unexpectedBody: + ("server_unexpected_body", "A preflight request carried a body.") + case .readTimeout: + ("server_timeout", "The local server timed out before the request finished arriving.") + default: + ("server_error", error.localizedDescription) + } + return .wordPressError(code: code, message: message) + } } // MARK: - Helpers @@ -402,8 +447,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 +462,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..0c73f10e7 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift @@ -0,0 +1,488 @@ +#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 sanitizes that value through a +/// URL-protocol allowlist that doesn't include `file`, so it responds 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. +/// - The caller supplies a **path**, not a URL: everything after `/proxy/` is +/// resolved natively against the configured site API root, so the relay +/// cannot be pointed at another host by construction rather than by string +/// matching. The resolved URL is re-checked against the root, and redirects +/// away from it are refused. +/// - Which route *within* the site is reached is the caller's to choose. The +/// query is forwarded as-is, and WordPress registers `rest_route` as a public +/// query variable that `WP::parse_request()` prefers over the route the path +/// names, so a caller-supplied one wins. There is no boundary here to +/// defend: every route reachable that way is one the editor may request +/// through the relay directly. Filtering the parameter would have to +/// reproduce PHP's `$_GET` name mangling — `.`, space and `+` all become `_` +/// — and a filter that misses a spelling reads as a guarantee it does not +/// provide. +/// - The upstream `Authorization` header is injected natively from the editor +/// configuration; any client-supplied value is discarded. +struct RestRelay: Sendable { + + /// The local server route the relay answers. Everything after it is the + /// upstream path, relative to the site API root — `/proxy/wp/v2/posts?…` + /// relays to `wp/v2/posts?…`. + /// + /// A path rather than an absolute URL in a query parameter: there is no + /// caller-supplied URL to contain in the first place, and each request + /// identifies itself in a network log instead of every row reading + /// `/proxy`. + static let route = "/proxy" + + /// The URL the web view appends an upstream path to, slash-terminated. + /// + /// Built here rather than in JavaScript so ``route`` is spelled once. The + /// address is literal `127.0.0.1` rather than `localhost`: the server binds + /// the IPv4 loopback only, and a name that may resolve to `::1` first would + /// have to fall back. + static func baseURL(port: UInt16) -> String { + "http://127.0.0.1:\(port)\(route)/" + } + + /// The site's API root, slash-terminated. Upstream paths are appended to + /// it, and every resulting URL — including redirect targets — must still + /// start with it. + /// + /// Held as a string rather than a `URL` because the root is not always + /// directory-shaped: a site on plain permalinks has + /// `https://example.com/?rest_route=/`, where relative URL resolution would + /// discard the query. + private let apiRoot: String + + /// The authorization header injected into upstream requests. + private let authHeader: String + + /// The session upstream requests are sent on. + private let session: URLSession + + /// The session every relay shares. + /// + /// A `URLSession` holds its resources until it is invalidated, and a relay + /// is built on every editor load under Lockdown Mode, so one session each + /// would accumulate for the life of the process. Nothing about the session + /// is per-relay, and a relayed request is cancelled through its own task + /// rather than by tearing the session down. + private static let sharedSession: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 120 + configuration.httpCookieStorage = nil + return URLSession(configuration: configuration) + }() + + /// - Parameter session: The session to send upstream requests on. Defaults + /// to the shared one; tests substitute a stubbed session to exercise + /// ``handle(_:)`` without a site. + init(configuration: EditorConfiguration, session: URLSession? = nil) { + self.apiRoot = Self.normalizedRoot(configuration.siteApiRoot.absoluteString) + self.authHeader = configuration.authHeader + self.session = session ?? Self.sharedSession + } + + /// The configured root, slash-terminated, with the route value of a + /// plain-permalink root decoded. + /// + /// WordPress advertises that root through `add_query_arg`, which + /// percent-encodes the value: `index.php?rest_route=%2F`. The separators + /// are decoded before the slash is added so it lands inside the route + /// value. Appended after `%2F`, it would make a root no path can extend: + /// WordPress reads `rest_route=%2F/wp/v2/posts` as the route + /// `//wp/v2/posts` and answers `rest_no_route`. `createRelayFetch` + /// normalizes the same way, so both sides agree on what the root is. + private static func normalizedRoot(_ configured: String) -> String { + var root = configured + if let query = root.firstIndex(of: "?") { + let decoded = root[query...].replacingOccurrences(of: "%2f", with: "/", options: .caseInsensitive) + root = String(root[.. Bool { + request.path == route || request.path.hasPrefix("\(route)/") + } + + /// 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 = upstreamURL(for: parsed) else { + Logger.restRelay.error("Refusing to relay a request outside the site API root") + return Self.errorResponse( + status: 403, + code: "relay_forbidden_path", + message: "The requested path is outside the site API root." + ) + } + + var upstreamRequest = parsed.urlRequest(url: upstreamURL, stripping: Self.requestHeadersToStrip) + if !authHeader.isEmpty { + upstreamRequest.setValue(authHeader, forHTTPHeaderField: "Authorization") + } + + var streamedBody: RequestBody? + 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. + // + // `count` is the length the parser recorded for the slice, not + // a file-system lookup, so it cannot fail here. + do { + upstreamRequest.httpBodyStream = try body.makeInputStream() + upstreamRequest.setValue("\(body.count)", forHTTPHeaderField: "Content-Length") + streamedBody = body + } catch { + Logger.restRelay.error("Failed to open request body stream: \(error)") + return Self.errorResponse(status: 500, code: "relay_body_unreadable", message: "Failed to read the request body.") + } + } + } + + do { + // The redirect guard is a per-task delegate: `URLSession` follows + // 3xx responses on its own, which would carry the site credential + // to whatever host the `Location` header names and relay that + // response back. See ``RedirectGuard``. + let redirectGuard = RedirectGuard(allowedPrefix: apiRoot, streamedBody: streamedBody) + let upstream = HTTPResponse(try await session.data(for: upstreamRequest, delegate: redirectGuard)) + + // A refused redirect leaves `URLSession` holding the 3xx itself. + // Relaying that would undo the refusal: the response carries the + // `Location` the guard just declined, and `fetch` follows redirects + // by default, so the web view would chase it to the very host the + // guard exists to keep the request away from — arriving as an + // opaque CORS failure rather than as this. + if let refused = redirectGuard.refusedTarget { + Logger.restRelay.error("Refused a relay redirect outside the site API root") + return Self.errorResponse( + status: 502, + code: "relay_redirect_refused", + message: "The site redirected this request to \(refused), which is outside its configured REST API root. The editor did not follow it." + ) + } + + return HTTPResponse( + status: upstream.status, + statusText: upstream.statusText, + headers: Self.merged(upstream.headers), + body: upstream.body + ) + } catch { + Logger.restRelay.error("Upstream request failed: \(error.localizedDescription)") + return Self.errorResponse(status: 502, code: "relay_upstream_failed", message: error.localizedDescription) + } + } + + // MARK: - Upstream URL + + /// Builds the upstream URL for a relayed request, or `nil` if the result + /// would address anything outside the site API root. + /// + /// Everything after the ``route`` prefix is treated as a path relative to + /// the API root and appended to it. Appending rather than resolving is what + /// `createRootURLMiddleware` does on the JavaScript side, and it is the only + /// approach that works for both root shapes WordPress produces: pretty + /// permalinks give `https://example.com/wp-json/`, plain permalinks give + /// `https://example.com/?rest_route=/`, where the path has to merge into an + /// existing query string. + /// + /// Dot segments — literal or percent-encoded — are refused rather than + /// normalized. A REST path never contains one, `URLSession` resolves them + /// before sending, and a normalized `..` is the one thing that could walk + /// out of the API root and reach the rest of the site with the credential + /// attached. + func upstreamURL(for request: ParsedHTTPRequest) -> URL? { + guard Self.handles(request) else { return nil } + let path = request.path + + // Strip the route and any leading slashes, so the remainder appends to + // the API root rather than resolving against the site root. + let relativePath = path.dropFirst(Self.route.count).drop(while: { $0 == "/" }) + guard !Self.containsDotSegment(relativePath) else { return nil } + + var suffix = String(relativePath) + request.query + // A root that already carries a query (plain permalinks) continues it + // rather than starting a second one — mirroring `createRootURLMiddleware`. + if apiRoot.contains("?"), let separator = suffix.firstIndex(of: "?") { + suffix.replaceSubrange(separator...separator, with: "&") + } + + guard let url = URL(string: apiRoot + suffix), + url.absoluteString.hasPrefix(apiRoot) else { + return nil + } + return url + } + + /// Whether `path` contains a `.` or `..` segment, including the + /// percent-encoded spellings a server may decode before resolving it. + /// + /// The separators are decoded alongside the dots. A server that decodes + /// `%2f` before normalizing — nginx normalizes the request URI ahead of + /// location matching — reads `%2e%2e%2fwp-admin` as `../wp-admin`, which + /// splitting on literal slashes alone would pass through. `%5c` is decoded + /// too because Windows-hosted servers treat a backslash as a separator. + private static func containsDotSegment(_ path: some StringProtocol) -> Bool { + let decoded = path.lowercased() + .replacingOccurrences(of: "%2e", with: ".") + .replacingOccurrences(of: "%2f", with: "/") + .replacingOccurrences(of: "%5c", with: "/") + .replacingOccurrences(of: "\\", with: "/") + guard decoded.contains(".") else { return false } + return decoded.split(separator: "/", omittingEmptySubsequences: false).contains { + $0 == "." || $0 == ".." + } + } + + /// Refuses redirects that leave the site API root. + /// + /// `URLSession` follows 3xx responses automatically, so without this the + /// containment check would only ever apply to the first hop: a site that + /// redirected `/wp-json/wp/v2/posts` elsewhere would have the request — + /// carrying the site credential — followed to that host, and its response + /// relayed back to the editor. Refusing hands the 3xx itself back instead. + /// + /// The comparison is a prefix match on the whole URL, read through the + /// host spellings `relayUpstreamPath` tolerates — `www.` versus bare, the + /// loopback names — and an `http`→`https` upgrade. So another path on the + /// same site (`/wp-login.php`), another port, another host, and a scheme + /// downgrade are refused: the site credential follows the request only to + /// the API it was configured for. The cost is that a legitimate + /// permalink-structure redirect is refused too, which the response says + /// specifically enough to diagnose. + /// + /// The tolerances are the layer above's so that the two agree. A `Link` + /// target on the `www.` alias is relayed by `createRelayFetch`, and a site + /// whose canonical redirect names that alias — or whose `siteurl` is `http` + /// behind a TLS-terminating proxy — would otherwise have every relayed + /// request refused here. Containment holds: the same site under another + /// of its own names, and a strictly stronger scheme. + /// + /// A redirect the guard follows is also its job to make work. A `307` or + /// `308` has `URLSession` resend the body, and a body the parser spilled + /// to disk went out as a one-shot stream, so the resend needs a fresh one + /// from ``urlSession(_:task:needNewBodyStream:)``. Without it the task + /// fails with `requestBodyStreamExhausted` — and only for bodies over the + /// in-memory threshold, since `URLSession` replays a `Data` body itself: + /// every JSON save would succeed while every photo upload failed. + /// + /// `@unchecked Sendable`: the prefixes and the body are `let`s set at + /// init; the refusal is recorded under a lock. + final class RedirectGuard: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + /// The API root, in the form ``normalized(_:)`` gives a target. + private let allowedPrefix: String + + /// `allowedPrefix` under `https`, when the configured root is `http`. + private let upgradedPrefix: String? + + /// The body sent as a one-shot stream, reopened when a followed + /// redirect resends it. `nil` when `URLSession` holds the bytes itself. + private let streamedBody: RequestBody? + + private let lock = NSLock() + private var _refusedTarget: String? + + /// The redirect target that was refused, or `nil` if none was. + var refusedTarget: String? { + lock.withLock { _refusedTarget } + } + + init(allowedPrefix: String, streamedBody: RequestBody? = nil) { + let root = Self.normalized(allowedPrefix) ?? allowedPrefix + self.allowedPrefix = root + let insecureScheme = "http://" + self.upgradedPrefix = root.hasPrefix(insecureScheme) + ? "https://" + root.dropFirst(insecureScheme.count) + : nil + self.streamedBody = streamedBody + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + needNewBodyStream completionHandler: @escaping (InputStream?) -> Void + ) { + // `makeInputStream()` opens a fresh read of the parser's file on + // each call, so the resend carries the whole body again. + completionHandler(try? streamedBody?.makeInputStream()) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + guard let url = request.url, contains(url.absoluteString) else { + lock.withLock { _refusedTarget = request.url?.absoluteString ?? "an unreadable URL" } + completionHandler(nil) + return + } + completionHandler(request) + } + + /// Whether `target` is inside the API root, allowing only the host + /// spelling and a scheme upgrade to differ. + private func contains(_ target: String) -> Bool { + guard let target = Self.normalized(target) else { return false } + if target.hasPrefix(allowedPrefix) { + return true + } + guard let upgradedPrefix else { return false } + return target.hasPrefix(upgradedPrefix) + } + + /// `url` with its host in the form its aliases share and a default + /// port dropped, so that a prefix comparison reads through the + /// spellings the layer above tolerates. `nil` for a URL without a host. + private static func normalized(_ url: String) -> String? { + guard var components = URLComponents(string: url), let host = components.host else { + return nil + } + components.host = canonicalHost(host) + if let port = components.port, port == defaultPort(for: components.scheme) { + components.port = nil + } + return components.string + } + + private static func defaultPort(for scheme: String?) -> Int? { + switch scheme?.lowercased() { + case "http": return 80 + case "https": return 443 + default: return nil + } + } + + /// A host reduced to the form its aliases share: every loopback + /// spelling collapses to one, and a `www.` prefix is dropped. + /// + /// Mirrors `canonicalHost` in `fetch-relay.js`, and the two must stay + /// the same: a spelling the web view relays and this refuses fails + /// every request on a site whose canonical redirect uses it. + private static func canonicalHost(_ host: String) -> String { + let lowercased = host.lowercased() + if ["localhost", "127.0.0.1", "::1", "[::1]"].contains(lowercased) { + return "localhost" + } + return lowercased.hasPrefix("www.") ? String(lowercased.dropFirst(4)) : lowercased + } + } + + // MARK: - CORS + + /// Response headers the editor may read off a relayed response. The + /// library's permissive CORS policy stamps `Access-Control-Allow-Origin` + /// and friends; this governs what JavaScript can see. + /// + /// A name missing from an expose list does not fail loudly: `headers.get()` + /// returns `null`, so the feature behind it reads as absent rather than + /// broken. `canUser` reads `Allow` to decide whether the user may create a + /// page, update settings, or edit global styles, so without it every such + /// capability reads as false with no error surfaced. Hence the leading + /// `*`, which covers whatever a plugin or a core update reads next. It is + /// valid because relayed requests are sent `credentials: 'omit'`, and it + /// withholds nothing that was not already the editor's: the response comes + /// from the site it is authenticated to, over loopback. + /// + /// The four names stay listed behind the wildcard because `*` is ignored + /// for a *credentialed* request — treated as a literal header name, not a + /// wildcard. `createRelayFetch` sends `credentials: 'omit'`, so the + /// wildcard applies today; if that ever changes, these keep working rather + /// than every capability silently reading false again. They are the names + /// whose absence is known to break a feature: `Allow` for capabilities, + /// `Link` for `fetchAllMiddleware`'s pagination, `X-WP-Total`/ + /// `X-WP-TotalPages` for list counts. + private static let corsHeaders: [(String, String)] = [ + ("Access-Control-Expose-Headers", "*, Allow, Link, X-WP-Total, X-WP-TotalPages"), + ] + + /// Request headers the relay strips beyond what + /// ``ParsedHTTPRequest/urlRequest(url:stripping:)`` already drops. + /// + /// That method removes the RFC 9110 §7.6.1 hop-by-hop set — including the + /// headers this request's own `Connection` names — along with `host` and the + /// proxy credentials. These are the rest: `content-length` and + /// `accept-encoding` are recalculated by `URLSession`; `origin`, `referer` + /// and `sec-fetch-*` describe the web view's fetch context and would leak + /// the local page to the site (and WordPress rejects a `file://` origin — + /// the exact problem the relay exists to solve); `authorization` is the + /// caller's, replaced below by the natively held site credential. + private static let requestHeadersToStrip: Set = [ + "content-length", "accept-encoding", + "origin", "referer", + "sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest", "sec-fetch-user", + "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. + /// + /// `Set-Cookie` is the site's, scoped to the site. Passing it on would + /// store the site's session cookies against `127.0.0.1:` instead — + /// a different origin, and one whose port belongs to another process after + /// this server stops. A proxy consumes the upstream's cookies; the relay + /// carries the site credential natively and never needs them. + 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", + "set-cookie", "set-cookie2", + ] + + /// The upstream response's headers as the editor should see them: the + /// relay's own CORS headers appended, and the upstream's CORS and + /// transport-encoding headers dropped (see `responseHeadersToStrip`). + private static func merged(_ upstream: [(String, String)]) -> [(String, String)] { + upstream.filter { !responseHeadersToStrip.contains($0.0.lowercased()) } + corsHeaders + } + + /// Emits a WordPress-REST-style error object rather than plain text, so the + /// editor decodes a relay failure the same way it decodes WordPress's own — + /// a `text/plain` body reaches JavaScript as an unparseable `invalid_json` + /// with the real reason lost. + static func errorResponse(status: Int, code: String, message: String) -> HTTPResponse { + .wordPressError(status: status, code: code, message: message, headers: corsHeaders) + } +} + +#endif // canImport(Network) diff --git a/ios/Sources/GutenbergKit/Sources/Media/WordPressErrorBody.swift b/ios/Sources/GutenbergKit/Sources/Media/WordPressErrorBody.swift new file mode 100644 index 000000000..a0246879e --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/WordPressErrorBody.swift @@ -0,0 +1,49 @@ +#if canImport(Network) + +import Foundation +import GutenbergKitHTTP + +extension HTTPErrorBody { + + /// A WordPress-REST-style `{code, message}` error. + /// + /// The editor normalizes every failure through the same middleware, so the + /// local server's own errors — the relay's, the upload route's, and the + /// refusals the HTTP library raises before either runs — reach JavaScript + /// in the shape it already understands, surfacing `message` rather than a + /// generic parse failure. + /// + /// The shape belongs to GutenbergKit rather than to `GutenbergKitHTTP`, + /// which serves whatever body its consumer hands it and knows nothing about + /// WordPress. + static func wordPressError(code: String, message: String) -> HTTPErrorBody { + let payload = ["code": code, "message": message] + // A `[String: String]` is always serializable, so the fallback is + // unreachable in practice; it is a fixed literal rather than an + // interpolation so the fallback itself cannot produce invalid JSON. + let data = (try? JSONSerialization.data(withJSONObject: payload)) + ?? Data(#"{"code":"unknown_error","message":"The request failed."}"#.utf8) + return HTTPErrorBody(contentType: "application/json", data: data) + } +} + +extension HTTPResponse { + + /// A response carrying a WordPress-REST-style error, with any headers the + /// route adds ahead of the body's `Content-Type`. + static func wordPressError( + status: Int, + code: String, + message: String, + headers: [(String, String)] = [] + ) -> HTTPResponse { + let body = HTTPErrorBody.wordPressError(code: code, message: message) + return HTTPResponse( + status: status, + headers: headers + [("Content-Type", body.contentType)], + body: body.data + ) + } +} + +#endif // canImport(Network) diff --git a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift index a06c793b2..c446bfcca 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift @@ -93,6 +93,37 @@ 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, the editor sends every site REST API request through + /// `http://127.0.0.1:` with the given bearer token rather than + /// directly. The host advertises it only when a direct request cannot work + /// — under iOS Lockdown Mode, which breaks CORS for `file://` pages — so + /// there is no direct attempt to fall back from. + public struct NetworkProxy: Sendable, Codable { + let port: Int + let token: String + + /// The relay's route, slash-terminated, for JavaScript to append an + /// upstream path to. + /// + /// Built natively so the route is spelled in one language: deriving it + /// in JavaScript instead means `/proxy` appears on both sides of the + /// bridge with nothing keeping them in step. + let baseURL: String + + /// The synthesized memberwise initializer is internal, which would + /// leave the `networkProxy` parameter of this type's public initializer + /// with no value a host could pass it. + public init(port: Int, token: String, baseURL: String) { + self.port = port + self.token = token + self.baseURL = baseURL + } + } + + let networkProxy: NetworkProxy? + /// Creates a global configuration from an editor configuration and dependencies. /// /// - Parameters: @@ -100,11 +131,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 +165,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/GutenbergKitHTTP/CORSPolicy.swift b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift index 26524d11e..b6dabe7e8 100644 --- a/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift +++ b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift @@ -28,15 +28,53 @@ public enum CORSPolicy: Sendable { // obtain it. `*` only governs whether a *token-holding* origin may // read the response, and the sole token-holder is the editor // itself, the legitimate client. Echoing the origin isn't viable - // anyway: the editor loads from `file://` (Origin `null`), which - // can't be cleanly allowlisted. + // anyway: the editor loads from `file://` and WebKit sends + // `Origin: file://`, an opaque origin that can't be cleanly + // allowlisted. ("Access-Control-Allow-Origin", "*"), - ("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"), - ("Access-Control-Allow-Headers", "Authorization, Relay-Authorization, Content-Type"), + ("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS"), + ("Access-Control-Allow-Headers", Self.allowedRequestHeadersValue), ("Access-Control-Max-Age", "86400"), ] } } + + /// The request headers a browser may send under ``permissive``. + /// + /// Only headers a browser actually announces in a preflight belong here. + /// `Accept` does not: api-fetch's value (`application/json, */*;q=0.1`) + /// carries none of the CORS-unsafe bytes and is well under the 128-byte + /// cap, so it is safelisted and never reaches the preflight. + /// + /// Enumerated rather than echoed back from `Access-Control-Request-Headers` + /// because this governs what a caller may *send*, and the relay forwards + /// most request headers upstream with the site credential attached. A + /// header announced but not listed here is refused by the browser, which + /// reports only an opaque CORS error — see ``unallowedHeaders(announced:)`` + /// for the diagnostic that makes that legible. + static let allowedRequestHeaders = [ + "Authorization", "Content-Type", "Relay-Authorization", "X-HTTP-Method-Override", + ] + + /// ``allowedRequestHeaders`` as the header value, joined once rather than on + /// every response — `responseHeaders` is evaluated for each one. + static let allowedRequestHeadersValue = allowedRequestHeaders.joined(separator: ", ") + + /// The headers a preflight announced that this policy will not allow. + /// + /// A preflight announces exactly the headers that are not CORS-safelisted, + /// so anything reported here is a header the caller intends to send and the + /// browser is about to refuse — failing the request before it reaches the + /// handler, with nothing on the wire to explain why. + func unallowedHeaders(announced: String?) -> [String] { + guard case .permissive = self, let announced else { return [] } + + let allowed = Set(Self.allowedRequestHeaders.map { $0.lowercased() }) + return announced + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + .filter { !$0.isEmpty && !allowed.contains($0) } + } } extension HTTPResponse { diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift index ac05fbb01..4071e0311 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift @@ -37,15 +37,20 @@ import OSLog /// /// ## CORS /// -/// When `requiresAuthentication` is enabled, `OPTIONS` requests are exempt -/// from authentication because CORS preflight requests never include -/// credentials (Fetch spec §3.3.5). However, the server does not generate -/// CORS response headers — this is the handler's responsibility. +/// When `requiresAuthentication` is enabled, CORS preflight requests are exempt +/// from authentication because a preflight never includes credentials (Fetch +/// spec §3.3.5). The exemption is scoped to genuine preflights — an `OPTIONS` +/// without `Access-Control-Request-Method` is a request the client made on its +/// own behalf and is authenticated, and dispatched to the handler, like any +/// other. Under ``CORSPolicy/permissive`` the server answers preflights itself; +/// otherwise it does not generate CORS response headers and that is the +/// handler's responsibility. /// /// When proxying to a remote server, the upstream response will typically /// include the correct CORS headers already — pass it through unaltered. -/// When serving local content, the handler must return appropriate headers -/// for `OPTIONS` requests, typically: +/// When serving local content under ``CORSPolicy/none`` with +/// `requiresAuthentication` disabled, the handler must return appropriate +/// headers for `OPTIONS` requests, typically: /// /// Access-Control-Allow-Origin: /// Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS @@ -56,6 +61,12 @@ import OSLog /// the actual request. A handler that returns 404 for unrecognized methods /// will silently break CORS for browser clients. /// +/// With `requiresAuthentication` enabled, a browser client needs +/// ``CORSPolicy/permissive``. Because the exemption above is scoped to the +/// policy that answers preflights, ``CORSPolicy/none`` authenticates a +/// preflight like any other request — and a browser sends one without +/// credentials, so the server answers 407 and the handler is never reached. +/// /// ## Connection Model /// /// Each connection handles exactly one request (`Connection: close`). HTTP @@ -141,6 +152,11 @@ public final class HTTPServer: Sendable { /// to choose a descriptive, collision-free identifier (e.g. `"media-proxy"`, /// `"editor-assets"`). /// - port: The port to listen on. Pass `nil` or omit to let the system assign an available port. + /// - requiresBrowserOrigin: When enabled, requests must carry `Origin` or + /// `Sec-Fetch-Site` — headers a web view's `fetch()` always sets and a raw + /// socket does not — and receive a 403 otherwise. Defense in depth behind the + /// bearer token, for servers whose only legitimate client is a web view; both + /// headers are trivially forged by a process that cares to. Defaults to off. /// - maxRequestBodySize: The maximum allowed request body size in bytes. /// Requests exceeding this limit receive a 413 response. Defaults to 4 GB. /// - maxConnections: The maximum number of concurrent connections. New connections @@ -169,6 +185,7 @@ public final class HTTPServer: Sendable { port: UInt16? = nil, listenOnAllInterfaces: Bool = false, requiresAuthentication: Bool = true, + requiresBrowserOrigin: Bool = false, maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize, maxConnections: Int = HTTPServer.defaultMaxConnections, readTimeout: Duration = HTTPServer.defaultReadTimeout, @@ -210,6 +227,7 @@ public final class HTTPServer: Sendable { let queue = DispatchQueue(label: "com.gutenbergkit.http-server.\(safeName)") let requiresAuth = requiresAuthentication + let requiresOrigin = requiresBrowserOrigin // Falls back to `readTimeout` so consumers that don't distinguish the two // keep the prior whole-request behavior. let resolvedBodyReadTimeout = bodyReadTimeout ?? readTimeout @@ -222,6 +240,7 @@ public final class HTTPServer: Sendable { handleConnection( connection, queue: queue, token: token, requiresAuthentication: requiresAuth, + requiresBrowserOrigin: requiresOrigin, maxRequestBodySize: maxRequestBodySize, readTimeout: readTimeout, bodyReadTimeout: resolvedBodyReadTimeout, idleTimeout: idleTimeout, cors: cors, tempDirectory: tempDirectory, @@ -324,6 +343,33 @@ public final class HTTPServer: Sendable { return HTTPResponse(status: error.httpStatus, statusText: statusText, body: Data(statusText.utf8)) } + /// An error the server raises itself, carrying the delegate's body when it + /// supplies one. + /// + /// The status and `headers` are the server's: a delegate customizes what the + /// client reads, never the protocol semantics. Without a delegate body the + /// response is the reason phrase as `text/plain`, which is what a client + /// that does not parse bodies expects. + /// + /// The reason phrase comes from ``HTTPResponse/defaultStatusText(for:)`` so + /// the status line and the fallback body cannot disagree with the rest of + /// the library about what a status is called. + private static func errorResponse( + status: Int, + headers: [(String, String)] = [], + for error: HTTPServerError, + delegate: HTTPServerDelegate? + ) -> HTTPResponse { + let statusText = String(HTTPResponse.defaultStatusText(for: status)) + let body = delegate?.errorBody(for: error) + return HTTPResponse( + status: status, + statusText: statusText, + headers: headers + [("Content-Type", body?.contentType ?? "text/plain")], + body: body?.data ?? Data(statusText.utf8) + ) + } + // MARK: - Connection Handling private static func handleConnection( @@ -331,6 +377,7 @@ public final class HTTPServer: Sendable { queue: DispatchQueue, token: String, requiresAuthentication: Bool, + requiresBrowserOrigin: Bool, maxRequestBodySize: Int64, readTimeout: Duration, bodyReadTimeout: Duration, @@ -370,20 +417,30 @@ public final class HTTPServer: Sendable { // Check auth on headers alone, before draining or consuming any // body bytes — an unauthenticated client must not be able to make // the server read (and discard) an arbitrarily large body, and the - // handler must never see an unauthenticated request. OPTIONS is - // exempt because CORS preflight requests never include credentials - // (Fetch spec §3.3.5). - if requiresAuthentication && partial.method.uppercased() != "OPTIONS" { + // handler must never see an unauthenticated request. A CORS + // preflight is exempt because preflights never include credentials + // (Fetch spec §3.3.5), and only under the policy that answers one + // below without reaching the handler; an `OPTIONS` the client sent + // deliberately is not a preflight and is authenticated like any + // other request. + let isExemptPreflight = cors == .permissive && isPreflight(partial) + if requiresAuthentication && !isExemptPreflight { guard authenticate(partial, token: token) else { throw HTTPServerError.authenticationFailed } } - // Reject auth-exempt OPTIONS that carry a body. Real CORS preflight + if requiresBrowserOrigin { + guard hasBrowserOrigin(partial) else { + throw HTTPServerError.forbiddenOrigin + } + } + + // Reject preflights that carry a body. Real CORS preflight // requests are bodyless; a body on the auth-exempt path would // otherwise be read/drained without authentication — and the // accepted-body read below is bounded only by the idle timeout. - if partial.method.uppercased() == "OPTIONS", (parser.expectedBodyLength ?? 0) > 0 { + if isExemptPreflight, (parser.expectedBodyLength ?? 0) > 0 { throw HTTPServerError.unexpectedBody } @@ -439,9 +496,26 @@ public final class HTTPServer: Sendable { if let parseError = parser.parseError { response = delegate?.response(forRecoverableParseError: parseError) ?? Self.defaultErrorResponse(for: parseError) - } else if cors == .permissive, request.method.uppercased() == "OPTIONS" { + } else if cors == .permissive, isPreflight(request) { // Under a permissive CORS policy the library answers the OPTIONS // preflight itself; the send layer stamps the CORS headers. + // + // A header the caller announced but the policy does not allow + // fails the real request inside the browser, which reports only + // an opaque CORS error and never reaches the handler. This is + // the one place that can say which header it was. + let unallowed = cors.unallowedHeaders( + announced: request.header("Access-Control-Request-Headers") + ) + if !unallowed.isEmpty { + Logger.httpServer.warning( + """ + Refusing preflight header(s) \(unallowed.joined(separator: ", "), privacy: .public) \ + for \(request.target, privacy: .public); the browser will fail the request before \ + it reaches the handler. Add them to CORSPolicy.allowedRequestHeaders if they belong. + """ + ) + } response = HTTPResponse(status: 204) } else { // Run the handler, but race it against the peer closing the @@ -479,18 +553,42 @@ public final class HTTPServer: Sendable { let ms = Double(sec) * 1000.0 + Double(atto) / 1_000_000_000_000_000.0 Logger.httpServer.debug("\(request.method) \(request.target) → \(response.status) (\(String(format: "%.1f", ms))ms)") } catch HTTPServerError.authenticationFailed { - await send(HTTPResponse(status: 407, headers: [("Content-Type", "text/plain"), ("Proxy-Authenticate", "Bearer")]), on: connection, cors: cors) + let response = Self.errorResponse( + status: 407, + headers: [("Proxy-Authenticate", "Bearer")], + for: .authenticationFailed, delegate: delegate + ) + await send(response, on: connection, cors: cors) + } catch HTTPServerError.forbiddenOrigin { + Logger.httpServer.warning("Rejected a request that did not originate from a web view") + let response = Self.errorResponse( + status: 403, + for: .forbiddenOrigin, delegate: delegate + ) + await send(response, on: connection, cors: cors) } catch HTTPServerError.lengthRequired { - await send(HTTPResponse(status: 411, statusText: "Length Required", body: Data("Length Required".utf8)), on: connection, cors: cors) + let response = Self.errorResponse( + status: 411, + for: .lengthRequired, delegate: delegate + ) + await send(response, on: connection, cors: cors) } catch HTTPServerError.unexpectedBody { Logger.httpServer.warning("Rejected auth-exempt request carrying a body") - await send(HTTPResponse(status: 400, statusText: "Bad Request", body: Data("Unexpected request body".utf8)), on: connection, cors: cors) + let response = Self.errorResponse( + status: 400, + for: .unexpectedBody, delegate: delegate + ) + await send(response, on: connection, cors: cors) } catch is CancellationError { Logger.httpServer.debug("Connection cancelled during shutdown") connection.cancel() } catch HTTPServerError.readTimeout { Logger.httpServer.warning("Read timeout, closing connection") - await send(HTTPResponse(status: 408, statusText: "Request Timeout", body: Data("Request Timeout".utf8)), on: connection, cors: cors) + let response = Self.errorResponse( + status: 408, + for: .readTimeout, delegate: delegate + ) + await send(response, on: connection, cors: cors) } catch let error as HTTPRequestParseError { // Fatal parse error (malformed framing, smuggling-relevant, etc.): // always answered by the library, never routed to the delegate. @@ -772,6 +870,47 @@ public final class HTTPServer: Sendable { } } + // MARK: - CORS + + /// Whether a request is a CORS preflight rather than an `OPTIONS` the + /// client sent on its own behalf. + /// + /// Both arrive as `OPTIONS` at the same target, so the two are only + /// separable by `Access-Control-Request-Method`: a preflight always carries + /// it (Fetch spec §4.8), and a deliberate `OPTIONS` — WordPress's + /// `canUser`, which reads the `Allow` response header — never does. + /// Answering the latter with the library's 204 would swallow it before the + /// handler ran, reporting no capabilities at all and surfacing no error, + /// because the request "succeeded". + /// + /// A preflight also only *announces* custom headers via + /// `Access-Control-Request-Headers`; it never sends them. So a client + /// cannot use this to skip authentication: dropping the bearer token to + /// look like a preflight means adding `Access-Control-Request-Method`, + /// which routes the request to the 204 answer instead of the handler. + /// That holds only under ``CORSPolicy/permissive``, which is why the + /// authentication exemption is scoped to it — under any other policy a + /// preflight reaches the handler, so it is authenticated like anything else. + private static func isPreflight(_ request: ParsedHTTPRequest) -> Bool { + request.method.uppercased() == "OPTIONS" + && request.header("Access-Control-Request-Method") != nil + } + + /// Whether a request looks like it came from a web view's `fetch()`. + /// + /// WebKit sets `Origin` on every cross-origin fetch and `Sec-Fetch-Site` on + /// every fetch, so the editor's requests always carry at least one. Either + /// is accepted rather than a specific value: the editor's origin is + /// `file://` in a release build but the dev server's `http://localhost:…` + /// when `GUTENBERG_EDITOR_URL` is set, and neither is worth pinning. + /// + /// This is a speed bump, not the control — the per-session bearer token is. + /// Another process on the device sends neither header by default, but can + /// forge both the moment it cares to. + private static func hasBrowserOrigin(_ request: ParsedHTTPRequest) -> Bool { + request.header("Origin") != nil || request.header("Sec-Fetch-Site") != nil + } + // MARK: - Authentication /// Validates the proxy bearer token from the request. diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServerDelegate.swift b/ios/Sources/GutenbergKitHTTP/HTTPServerDelegate.swift index 281533746..d44563dd1 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServerDelegate.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServerDelegate.swift @@ -8,7 +8,7 @@ import Foundation /// behavior it wants to change. A server started without a delegate — or whose /// delegate leaves a method defaulted — uses the library's built-in behavior. /// New customization points are added here as new defaulted methods, so -/// ``HTTPServer/start(name:port:listenOnAllInterfaces:requiresAuthentication:maxRequestBodySize:maxConnections:readTimeout:bodyReadTimeout:idleTimeout:startTimeout:cors:delegate:handler:)`` +/// ``HTTPServer/start(name:port:listenOnAllInterfaces:requiresAuthentication:requiresBrowserOrigin:maxRequestBodySize:maxConnections:readTimeout:bodyReadTimeout:idleTimeout:startTimeout:cors:delegate:handler:)`` /// never grows another parameter for them. /// /// The server **retains** its delegate for its lifetime. Because the delegate is @@ -30,12 +30,48 @@ public protocol HTTPServerDelegate: AnyObject, Sendable { /// Fatal parse errors (malformed framing, header smuggling, etc.) are always /// answered by the library and never routed here. func response(forRecoverableParseError error: HTTPRequestParseError) -> HTTPResponse + + /// The body to send for an error the server itself raises — failed + /// authentication, a refused origin, a read timeout, a missing + /// `Content-Length` — where the request never reaches the handler. + /// + /// Only the payload: the server keeps the status and the headers the + /// protocol requires, so answering a 407 cannot drop its + /// `Proxy-Authenticate` challenge. Returning `nil` keeps the default, the + /// reason phrase as `text/plain`. + /// + /// Worth overriding when the client parses every response the same way. + /// `@wordpress/api-fetch` reads each one as JSON, so a `text/plain` refusal + /// reaches it as a parse failure — reported to the user as an invalid + /// response rather than as the timeout or the missing credential it was. + func errorBody(for error: HTTPServerError) -> HTTPErrorBody? } public extension HTTPServerDelegate { func response(forRecoverableParseError error: HTTPRequestParseError) -> HTTPResponse { HTTPServer.defaultErrorResponse(for: error) } + + func errorBody(for error: HTTPServerError) -> HTTPErrorBody? { + nil + } +} + +/// A response payload for an error the server generates itself. +/// +/// The status and protocol headers stay with the server; this carries only what +/// the client reads. See ``HTTPServerDelegate/errorBody(for:)``. +public struct HTTPErrorBody: Sendable { + /// The `Content-Type` to send. + public let contentType: String + + /// The serialized body. + public let data: Data + + public init(contentType: String, data: Data) { + self.contentType = contentType + self.data = data + } } #endif // canImport(Network) diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServerError.swift b/ios/Sources/GutenbergKitHTTP/HTTPServerError.swift index 1b24009a5..55a1309d5 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServerError.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServerError.swift @@ -17,9 +17,13 @@ public enum HTTPServerError: Error, LocalizedError, Sendable { case readTimeout /// The request failed authentication (checked after headers, before body). case authenticationFailed + /// The request carried neither `Origin` nor `Sec-Fetch-Site`, so it did not + /// come from a web view's `fetch()`. Only raised when the server is started + /// with `requiresBrowserOrigin`. + case forbiddenOrigin /// The request method requires a Content-Length header but none was provided. case lengthRequired - /// An auth-exempt request (OPTIONS) carried a body. CORS preflights are + /// An auth-exempt request (a CORS preflight) carried a body. Preflights are /// bodyless, so a body on the auth-exempt path is rejected rather than read. case unexpectedBody /// A network-level error occurred on the connection. @@ -32,6 +36,7 @@ public enum HTTPServerError: Error, LocalizedError, Sendable { case .connectionClosed: "Connection closed before request was complete" case .readTimeout: "Read timeout expired before request was complete" case .authenticationFailed: "Request failed authentication" + case .forbiddenOrigin: "Request did not originate from a web view" case .lengthRequired: "Content-Length header is required for this method" case .unexpectedBody: "Request method must not carry a body" case .networkError(let error): "Network error: \(error.localizedDescription)" diff --git a/ios/Sources/GutenbergKitHTTP/ParsedHTTPRequest.swift b/ios/Sources/GutenbergKitHTTP/ParsedHTTPRequest.swift index 95222d1c0..7ef25082f 100644 --- a/ios/Sources/GutenbergKitHTTP/ParsedHTTPRequest.swift +++ b/ios/Sources/GutenbergKitHTTP/ParsedHTTPRequest.swift @@ -195,36 +195,56 @@ extension ParsedHTTPRequest { return nil } + var request = urlRequest(url: url) + if let body { + request.httpBodyStream = try? body.makeInputStream() + } + return request + } + + /// A `URLRequest` for `url` carrying this request's method and forwardable + /// headers. + /// + /// The hop-by-hop headers of RFC 9110 §7.6.1 are dropped — including the + /// ones this request's own `Connection` header names — as are the proxy + /// credentials a forwarding hop consumes rather than passes on. + /// `Authorization` is kept, so a client's own upstream credentials survive; + /// a caller that supplies its own must strip it through `additionalStripped`. + /// + /// The body is deliberately not attached: how to send it differs by caller — + /// buffered or streamed, `Content-Length` declared or left to `URLSession`, + /// a read failure swallowed or surfaced. ``urlRequest(relativeTo:)`` attaches + /// it as a stream. + /// + /// - Parameters: + /// - url: The URL to send to, already resolved. + /// - additionalStripped: Further header names the caller owns and will set + /// itself, or that describe a hop the upstream should not see. + /// **Lowercase**; names are compared case-insensitively against them. + /// - Returns: A configured `URLRequest`, without a body. + public func urlRequest(url: URL, stripping additionalStripped: Set = []) -> URLRequest { var request = URLRequest(url: url) request.httpMethod = method - // RFC 9110 §7.6.1: hop-by-hop headers must not be forwarded by proxies. - // "proxy-authorization" and "relay-authorization" carry the proxy's - // own bearer token and must not be forwarded to the upstream server. - // "authorization" is intentionally kept so that the client's own - // credentials (e.g. HTTP Basic for the upstream server) pass through. - var hopByHop: Set = [ + var stripped: Set = [ "host", "connection", "transfer-encoding", "keep-alive", "proxy-connection", "te", "upgrade", "trailer", "proxy-authorization", "relay-authorization", ] + stripped.formUnion(additionalStripped) // Headers listed in Connection are also hop-by-hop (RFC 9110 §7.6.1). if let connectionValue = header("Connection") { for name in connectionValue.split(separator: ",") { - hopByHop.insert(name.trimmingCharacters(in: .whitespaces).lowercased()) + stripped.insert(name.trimmingCharacters(in: .whitespaces).lowercased()) } } for (key, value) in headers { - guard !hopByHop.contains(key.lowercased()) else { continue } + guard !stripped.contains(key.lowercased()) else { continue } request.setValue(value, forHTTPHeaderField: key) } - if let body { - request.httpBodyStream = try? body.makeInputStream() - } - return request } #endif diff --git a/ios/Tests/GutenbergKitHTTPTests/CORSPolicyTests.swift b/ios/Tests/GutenbergKitHTTPTests/CORSPolicyTests.swift new file mode 100644 index 000000000..6c7ae6a92 --- /dev/null +++ b/ios/Tests/GutenbergKitHTTPTests/CORSPolicyTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing +@testable import GutenbergKitHTTP + +@Suite("CORSPolicy") +struct CORSPolicyTests { + + @Test("advertises exactly the request headers it allows") + func advertisesAllowedRequestHeaders() { + // One source of truth: the advertised value is built from the list the + // preflight diagnostic checks against, so the two cannot disagree. + let headers = CORSPolicy.permissive.responseHeaders + let advertised = headers.first { $0.0 == "Access-Control-Allow-Headers" }?.1 + + #expect(advertised == "Authorization, Content-Type, Relay-Authorization, X-HTTP-Method-Override") + } + + @Test("reports an announced header the policy will not allow") + func reportsUnallowedHeader() { + // The browser refuses the request on this, reporting only an opaque + // CORS error — a plugin's api-fetch middleware adding its own header is + // the case that produces it. + let announced = "relay-authorization, x-wp-api-fetch-from-editor" + + #expect( + CORSPolicy.permissive.unallowedHeaders(announced: announced) + == ["x-wp-api-fetch-from-editor"] + ) + } + + @Test("reports nothing when every announced header is allowed") + func allowsAnnouncedHeaders() { + // Case and spacing vary by browser; neither should read as a refusal. + #expect(CORSPolicy.permissive.unallowedHeaders(announced: "relay-authorization").isEmpty) + #expect(CORSPolicy.permissive.unallowedHeaders(announced: "Content-Type, RELAY-AUTHORIZATION").isEmpty) + #expect(CORSPolicy.permissive.unallowedHeaders(announced: nil).isEmpty) + #expect(CORSPolicy.permissive.unallowedHeaders(announced: "").isEmpty) + } + + @Test("reports nothing under a policy that does not answer preflights") + func reportsNothingWithoutAPolicy() { + // `.none` sends no allow list and forwards the preflight to the + // handler, so the library is in no position to call a header refused. + #expect(CORSPolicy.none.unallowedHeaders(announced: "x-anything").isEmpty) + } +} diff --git a/ios/Tests/GutenbergKitHTTPTests/HTTPServerAuthenticationTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPServerAuthenticationTests.swift index 427526eaa..42ba22438 100644 --- a/ios/Tests/GutenbergKitHTTPTests/HTTPServerAuthenticationTests.swift +++ b/ios/Tests/GutenbergKitHTTPTests/HTTPServerAuthenticationTests.swift @@ -241,10 +241,31 @@ struct HTTPServerAuthenticationTests { #expect(http.value(forHTTPHeaderField: "X-Received-Auth") == "Basic dXNlcjpwYXNz") } - // MARK: - CORS Preflight (OPTIONS) Auth Exemption + // MARK: - CORS Preflight Auth Exemption - @Test("OPTIONS without token returns 200 (CORS preflight exempt from auth)") - func optionsWithoutTokenReturns200() async throws { + @Test("preflight without token is answered under permissive CORS") + func preflightWithoutTokenIsAnsweredUnderPermissiveCORS() async throws { + // A preflight cannot carry credentials, so it is exempt from + // authentication — and the library answers it itself, so the exemption + // never reaches the handler. + let server = try await HTTPServer.start( + name: "auth-test", + requiresAuthentication: true, + cors: .permissive + ) { _ in + HTTPResponse(status: 200, body: Data("OK\n".utf8)) + } + defer { server.stop() } + + let raw = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\nAccess-Control-Request-Method: GET\r\n\r\n" + let response = try await sendRaw(raw, toPort: server.port) + #expect(response.hasPrefix("HTTP/1.1 204")) + } + + @Test("preflight without token is authenticated without a CORS policy") + func preflightWithoutTokenRequiresAuthWithoutCORS() async throws { + // Without a policy to answer it, a preflight would reach the handler, + // so the exemption would be an unauthenticated way in. let server = try await HTTPServer.start( name: "auth-test", requiresAuthentication: true @@ -253,12 +274,77 @@ struct HTTPServerAuthenticationTests { } defer { server.stop() } + let raw = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\nAccess-Control-Request-Method: GET\r\n\r\n" + let response = try await sendRaw(raw, toPort: server.port) + #expect(response.hasPrefix("HTTP/1.1 407")) + } + + @Test("OPTIONS without Access-Control-Request-Method is not a preflight and returns 407") + func nonPreflightOptionsWithoutTokenReturns407() async throws { + // `canUser` issues a deliberate `OPTIONS` to read the `Allow` header. It + // is a request the client made on its own behalf, so it carries the + // token and must be authenticated like any other — the exemption covers + // preflights, which cannot carry credentials, and nothing else. + // + // Under `.permissive` specifically: that is the only policy where the + // exemption exists at all, so it is the only one where dropping the + // `Access-Control-Request-Method` test would let this request through + // unauthenticated. Under `.none` the assertion would hold no matter + // what `isPreflight` returned. + let server = try await HTTPServer.start( + name: "auth-test", + requiresAuthentication: true, + cors: .permissive + ) { _ in + HTTPResponse(status: 200, body: Data("OK\n".utf8)) + } + defer { server.stop() } + let raw = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n" let response = try await sendRaw(raw, toPort: server.port) + #expect(response.hasPrefix("HTTP/1.1 407")) + } + + @Test("authenticated OPTIONS without Access-Control-Request-Method reaches the handler") + func nonPreflightOptionsWithTokenReachesHandler() async throws { + let server = try await HTTPServer.start( + name: "auth-test", + requiresAuthentication: true + ) { request in + HTTPResponse(status: 200, headers: [("Allow", request.parsed.method)], body: Data()) + } + defer { server.stop() } + + let raw = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\nRelay-Authorization: Bearer \(server.token)\r\n\r\n" + let response = try await sendRaw(raw, toPort: server.port) + #expect(response.hasPrefix("HTTP/1.1 200")) + #expect(response.contains("Allow: OPTIONS")) + } + + @Test("permissive CORS answers a preflight itself but forwards a deliberate OPTIONS") + func permissiveCORSDistinguishesPreflightFromOptions() async throws { + let server = try await HTTPServer.start( + name: "auth-test", + requiresAuthentication: true, + cors: .permissive + ) { _ in + HTTPResponse(status: 200, headers: [("Allow", "GET, POST")], body: Data()) + } + defer { server.stop() } + + let preflight = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\nAccess-Control-Request-Method: POST\r\n\r\n" + #expect(try await sendRaw(preflight, toPort: server.port).hasPrefix("HTTP/1.1 204")) + + // Without the preflight header the request belongs to the handler; the + // library answering it with its own 204 would swallow the `Allow` + // header `canUser` exists to read. + let deliberate = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\nRelay-Authorization: Bearer \(server.token)\r\n\r\n" + let response = try await sendRaw(deliberate, toPort: server.port) #expect(response.hasPrefix("HTTP/1.1 200")) + #expect(response.contains("Allow: GET, POST")) } - @Test("GET without token still returns 407 (only OPTIONS is exempt)") + @Test("GET without token still returns 407 (only a preflight is exempt)") func getWithoutTokenStillReturns407() async throws { let server = try await HTTPServer.start( name: "auth-test", @@ -275,6 +361,51 @@ struct HTTPServerAuthenticationTests { #expect(http.statusCode == 407) } + // MARK: - Delegate Error Bodies + + @Test("a delegate's body answers a refusal the handler never sees") + func delegateBodyAnswersRefusal() async throws { + // A client that parses every response the same way — api-fetch reads + // them all as JSON — reports a text/plain refusal as a parse failure, + // losing the reason it was refused. + let server = try await HTTPServer.start( + name: "error-body-test", + requiresAuthentication: true, + delegate: JSONErrorDelegate() + ) { _ in + HTTPResponse(status: 200, body: Data("OK\n".utf8)) + } + defer { server.stop() } + + let raw = "GET /test HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n" + let response = try await sendRaw(raw, toPort: server.port) + + #expect(response.hasPrefix("HTTP/1.1 407")) + #expect(response.contains("Content-Type: application/json")) + #expect(response.contains(#"{"code":"refused"}"#)) + // The status and the protocol's own headers stay the server's: a + // delegate supplies the payload, never the semantics. + #expect(response.contains("Proxy-Authenticate: Bearer")) + } + + @Test("a refusal falls back to the reason phrase without a delegate") + func refusalWithoutDelegateIsPlainText() async throws { + let server = try await HTTPServer.start( + name: "error-body-test", + requiresAuthentication: true + ) { _ in + HTTPResponse(status: 200, body: Data("OK\n".utf8)) + } + defer { server.stop() } + + let raw = "GET /test HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n" + let response = try await sendRaw(raw, toPort: server.port) + + #expect(response.hasPrefix("HTTP/1.1 407")) + #expect(response.contains("Content-Type: text/plain")) + #expect(response.contains("Proxy Authentication Required")) + } + // MARK: - Content-Length Requirement @Test("POST without Content-Length returns 411") @@ -398,4 +529,12 @@ struct HTTPServerAuthenticationTests { } } +/// Answers every server-generated error with a JSON body, the way a consumer +/// whose client parses all responses as JSON would. +private final class JSONErrorDelegate: HTTPServerDelegate { + func errorBody(for error: HTTPServerError) -> HTTPErrorBody? { + HTTPErrorBody(contentType: "application/json", data: Data(#"{"code":"refused"}"#.utf8)) + } +} + #endif // canImport(Network) diff --git a/ios/Tests/GutenbergKitHTTPTests/HTTPServerTimeoutTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPServerTimeoutTests.swift index 4fc444480..3281e69ec 100644 --- a/ios/Tests/GutenbergKitHTTPTests/HTTPServerTimeoutTests.swift +++ b/ios/Tests/GutenbergKitHTTPTests/HTTPServerTimeoutTests.swift @@ -8,7 +8,7 @@ import Testing /// Covers the split read-timeout model: the pre-body phase (headers + drain) is /// bounded by `readTimeout`, while an accepted body is bounded by the generous /// `bodyReadTimeout` plus the per-read `idleTimeout`. Also covers rejecting an -/// auth-exempt `OPTIONS` request that carries a body. +/// auth-exempt CORS preflight that carries a body. @Suite("HTTPServer Timeouts") struct HTTPServerTimeoutTests { @@ -72,54 +72,57 @@ struct HTTPServerTimeoutTests { #expect(elapsed < .seconds(3)) // reaped by the 500ms idle timeout, not the 10s ceiling } - @Test("auth-exempt OPTIONS carrying a body is rejected with 400") - func optionsWithBodyReturns400() async throws { + @Test("auth-exempt preflight carrying a body is rejected with 400") + func preflightWithBodyReturns400() async throws { let server = try await HTTPServer.start( name: "options-with-body", - requiresAuthentication: true + requiresAuthentication: true, + cors: .permissive ) { _ in HTTPResponse(status: 200, body: Data("OK\n".utf8)) } defer { server.stop() } - // A real CORS preflight is bodyless; an OPTIONS with a body must not be + // A real CORS preflight is bodyless; one with a body must not be // read/drained on the auth-exempt path. - let raw = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 5\r\n\r\nhello" + let raw = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\nAccess-Control-Request-Method: POST\r\nContent-Length: 5\r\n\r\nhello" let response = try await sendRaw(raw, toPort: server.port) #expect(response.hasPrefix("HTTP/1.1 400")) } - @Test("auth-exempt OPTIONS with an oversized body is rejected with 400, not drained") - func optionsWithOversizedBodyReturns400() async throws { + @Test("auth-exempt preflight with an oversized body is rejected with 400, not drained") + func preflightWithOversizedBodyReturns400() async throws { let server = try await HTTPServer.start( name: "options-oversized-body", requiresAuthentication: true, - maxRequestBodySize: 16 + maxRequestBodySize: 16, + cors: .permissive ) { _ in HTTPResponse(status: 200, body: Data("OK\n".utf8)) } defer { server.stop() } // Content-Length exceeds the max body size, so the parser would otherwise - // enter the drain path — the OPTIONS-with-body guard must reject it first. - let raw = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 1000\r\n\r\n" + // enter the drain path — the preflight-with-body guard must reject it first. + let raw = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\nAccess-Control-Request-Method: POST\r\nContent-Length: 1000\r\n\r\n" let response = try await sendRaw(raw, toPort: server.port) #expect(response.hasPrefix("HTTP/1.1 400")) } - @Test("bodyless OPTIONS preflight still succeeds") + @Test("bodyless OPTIONS preflight is still answered") func bodylessOptionsSucceeds() async throws { let server = try await HTTPServer.start( name: "options-bodyless", - requiresAuthentication: true + requiresAuthentication: true, + cors: .permissive ) { _ in HTTPResponse(status: 200, body: Data("OK\n".utf8)) } defer { server.stop() } - let raw = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n" + let raw = "OPTIONS /test HTTP/1.1\r\nHost: 127.0.0.1\r\nAccess-Control-Request-Method: GET\r\n\r\n" let response = try await sendRaw(raw, toPort: server.port) - #expect(response.hasPrefix("HTTP/1.1 200")) + #expect(response.hasPrefix("HTTP/1.1 204")) } // MARK: - Start timeout diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 97a300733..b2bdde5df 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -54,6 +54,29 @@ struct MediaUploadServerTests { #expect(httpResponse.statusCode == 407) } + @Test("refuses in the JSON shape the editor parses") + func refusalIsParseableByTheEditor() async throws { + // `@wordpress/api-fetch` reads every response as JSON, so a `text/plain` + // refusal reaches the editor as `invalid_json` — "The response is not a + // valid JSON response." — with the real reason lost. Under the relay this + // server answers every REST request the editor makes. + let server = try await MediaUploadServer.start() + defer { server.stop() } + + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + + #expect(httpResponse.statusCode == 407) + #expect(httpResponse.value(forHTTPHeaderField: "Content-Type") == "application/json") + let error = try #require((try? JSONSerialization.jsonObject(with: data)) as? [String: Any]) + #expect(error["code"] as? String == "server_unauthorized") + #expect(error["message"] is String) + } + @Test("rejects requests with wrong token") func rejectsWrongToken() async throws { let server = try await MediaUploadServer.start() @@ -77,6 +100,8 @@ struct MediaUploadServerTests { let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! var request = URLRequest(url: url) request.httpMethod = "OPTIONS" + request.setValue("POST", forHTTPHeaderField: "Access-Control-Request-Method") + request.setBrowserOrigin() let (_, response) = try await URLSession.shared.data(for: request) let httpResponse = try #require(response as? HTTPURLResponse) @@ -85,6 +110,23 @@ struct MediaUploadServerTests { #expect(httpResponse.value(forHTTPHeaderField: "Access-Control-Allow-Methods")?.contains("POST") == true) } + @Test("rejects a request that did not come from the web view") + func rejectsNonBrowserRequest() async throws { + let server = try await MediaUploadServer.start() + defer { server.stop() } + + // A correct token but none of the headers WebKit sets on a `fetch()`: the + // shape another process on the device would produce over a raw socket. + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 403) + } + @Test("returns 404 for unknown paths") func unknownPath() async throws { let server = try await MediaUploadServer.start() @@ -94,6 +136,7 @@ struct MediaUploadServerTests { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setBrowserOrigin() let (_, response) = try await URLSession.shared.data(for: request) let httpResponse = try #require(response as? HTTPURLResponse) @@ -117,6 +160,7 @@ struct MediaUploadServerTests { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setBrowserOrigin() request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpBody = body @@ -145,6 +189,7 @@ struct MediaUploadServerTests { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setBrowserOrigin() request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpBody = body @@ -180,6 +225,7 @@ struct MediaUploadServerTests { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setBrowserOrigin() request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpBody = body @@ -212,6 +258,7 @@ struct MediaUploadServerTests { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setBrowserOrigin() request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpBody = body @@ -240,6 +287,7 @@ struct MediaUploadServerTests { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setBrowserOrigin() request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpBody = body @@ -266,6 +314,7 @@ struct MediaUploadServerTests { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setBrowserOrigin() request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpBody = body @@ -291,6 +340,7 @@ struct MediaUploadServerTests { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setBrowserOrigin() request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpBody = body @@ -827,6 +877,16 @@ private struct MockHTTPClient: EditorHTTPClientProtocol { } } +private extension URLRequest { + /// Adds the header WebKit sets on every cross-origin `fetch()` the editor + /// makes. The server rejects requests carrying neither `Origin` nor + /// `Sec-Fetch-Site` (see `requiresBrowserOrigin`), so a test standing in for + /// the web view has to look like one. + mutating func setBrowserOrigin() { + setValue("file://", forHTTPHeaderField: "Origin") + } +} + private extension Data { mutating func append(_ string: String) { append(string.data(using: .utf8)!) diff --git a/ios/Tests/GutenbergKitTests/Media/RestRelayHandleTests.swift b/ios/Tests/GutenbergKitTests/Media/RestRelayHandleTests.swift new file mode 100644 index 000000000..81127cb86 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Media/RestRelayHandleTests.swift @@ -0,0 +1,293 @@ +#if canImport(Network) + +import Foundation +import Testing +@testable import GutenbergKit +@testable import GutenbergKitHTTP + +/// Covers what ``RestRelay/handle(_:)`` sends upstream and what it hands back, +/// against a stubbed session rather than a site. +/// +/// The header rewriting on both sides of the hop is the part of the relay that +/// fails silently: an upstream `Access-Control-Allow-Origin` that survives is +/// honored by WebKit over the policy's own, and a surviving `Content-Encoding` +/// makes WebKit decode an already-decoded body. ``RestRelayIntegrationTests`` +/// covers the same ground against wp-env, but it is gated on credentials and +/// skipped in CI, so a regression there ships green. +@Suite("RestRelay request handling") +struct RestRelayHandleTests { + + // MARK: - Request rewriting + + @Test("injects the site credential and discards the caller's") + func injectsSiteCredential() async throws { + let exchange = try await relay( + headers: ["Authorization": "Bearer caller-token"] + ) + + #expect(exchange.upstream.value(forHTTPHeaderField: "Authorization") == "Bearer test-token") + } + + @Test("strips the headers that describe the web view's own hop") + func stripsHopHeaders() async throws { + // `origin`/`referer`/`sec-fetch-*` describe the `file://` page and are + // what WordPress rejects in the first place; the relay's own bearer + // token is not the site's business; the rest are hop-by-hop. + let exchange = try await relay(headers: [ + "Origin": "file://", + "Referer": "file:///editor.html", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Mode": "cors", + "Relay-Authorization": "Bearer relay-token", + "Connection": "keep-alive", + "Host": "127.0.0.1:8080", + "Accept-Encoding": "gzip, deflate", + ]) + + for header in [ + "Origin", "Referer", "Sec-Fetch-Site", "Sec-Fetch-Mode", + "Relay-Authorization", "Connection", + ] { + #expect( + exchange.upstream.value(forHTTPHeaderField: header) == nil, + "\(header) should not reach the site" + ) + } + } + + @Test("strips the headers the request's own Connection names") + func stripsConnectionNamedHeaders() async throws { + // RFC 9110 §7.6.1: `Connection` names further headers that describe this + // hop only, so a forwarding hop consumes those too rather than passing + // them to the site. + let exchange = try await relay(headers: [ + "Connection": "X-Hop-Only, close", + "X-Hop-Only": "1", + ]) + + #expect(exchange.upstream.value(forHTTPHeaderField: "X-Hop-Only") == nil) + } + + @Test("forwards the headers the site needs") + func forwardsContentHeaders() async throws { + let exchange = try await relay( + method: "POST", + headers: ["Content-Type": "application/json", "X-HTTP-Method-Override": "PUT"] + ) + + #expect(exchange.upstream.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(exchange.upstream.value(forHTTPHeaderField: "X-HTTP-Method-Override") == "PUT") + #expect(exchange.upstream.httpMethod == "POST") + } + + // MARK: - Response rewriting + + @Test("strips the upstream CORS headers that would override the policy's") + func stripsUpstreamCORSHeaders() async throws { + // WordPress answers an origin it rejects with an *empty* + // `Access-Control-Allow-Origin`. The library adds its own headers with + // `addingHeadersIfAbsent`, so one that survives here wins — and WebKit + // rejects the response the relay exists to deliver. + let exchange = try await relay(responseHeaders: [ + "Access-Control-Allow-Origin": "", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Expose-Headers": "X-Upstream", + "Vary": "Origin", + ]) + + for header in [ + "Access-Control-Allow-Origin", "Access-Control-Allow-Credentials", "Vary", + ] { + #expect( + exchange.response.header(header) == nil, + "\(header) should not reach the web view" + ) + } + // The relay's own expose list replaces the upstream's, rather than + // both arriving and the browser reading whichever came first. + let exposed = exchange.response.headers.filter { $0.0.lowercased() == "access-control-expose-headers" } + #expect(exposed.count == 1) + #expect(exposed.first?.1.contains("Allow") == true) + } + + @Test("strips the site's cookies rather than rescoping them to loopback") + func stripsSetCookie() async throws { + // Passed on, these would be stored against `127.0.0.1:` — a + // different origin, on a port another process owns once this server + // stops. + let exchange = try await relay(responseHeaders: [ + "Set-Cookie": "wordpress_logged_in_abc=user%7C123; Path=/; HttpOnly", + ]) + + #expect(exchange.response.header("Set-Cookie") == nil) + } + + @Test("strips Content-Encoding, which URLSession already acted on") + func stripsContentEncoding() async throws { + // `URLSession` decompresses transparently, so advertising the upstream + // encoding makes WebKit decode plain bytes a second time. + let exchange = try await relay(responseHeaders: ["Content-Encoding": "gzip"]) + + #expect(exchange.response.header("Content-Encoding") == nil) + } + + @Test("relays the status, body, and the headers the editor reads") + func relaysStatusBodyAndHeaders() async throws { + let exchange = try await relay( + status: 201, + responseHeaders: ["Allow": "GET, POST", "X-WP-Total": "42"], + responseBody: Data(#"{"id":1}"#.utf8) + ) + + #expect(exchange.response.status == 201) + #expect(exchange.response.body == Data(#"{"id":1}"#.utf8)) + #expect(exchange.response.header("Allow") == "GET, POST") + #expect(exchange.response.header("X-WP-Total") == "42") + } + + @Test("answers an upstream failure as a relay error the editor can decode") + func reportsUpstreamFailure() async throws { + let exchange = try await relay(failure: URLError(.notConnectedToInternet)) + + #expect(exchange.response.status == 502) + let body = try #require(String(data: exchange.response.body, encoding: .utf8)) + #expect(body.contains("relay_upstream_failed")) + } + + @Test("replays a streamed body across a redirect it follows") + func replaysStreamedBodyAcrossRedirect() async throws { + // A body over the in-memory threshold is spilled to disk and sent as + // a one-shot stream. A `308` has `URLSession` resend it, which needs + // a fresh stream or fails with `requestBodyStreamExhausted`. The site + // is a real server rather than a stub: `URLSession` asks for a + // replacement only for a stream it drained itself. + let contents = Data(repeating: 0x41, count: 100_000) + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try contents.write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + + let resent = ReceivedBody() + let site = try await HTTPServer.start(name: "relay-redirect-site", requiresAuthentication: false) { request in + guard request.parsed.query.isEmpty else { + resent.record((try? await request.parsed.body?.data) ?? Data()) + return HTTPResponse(status: 201) + } + // Relative, as RFC 9110 allows, since the port is not known + // until the server has started. + return HTTPResponse(status: 308, headers: [("Location", "/wp-json/wp/v2/media?redirected=1")]) + } + defer { site.stop() } + + let relay = RestRelay( + configuration: EditorConfigurationBuilder( + postType: .post, + siteURL: URL(string: "http://127.0.0.1:\(site.port)")!, + siteApiRoot: URL(string: "http://127.0.0.1:\(site.port)/wp-json/")!, + authHeader: "Bearer test-token" + ).build() + ) + let parsed = ParsedHTTPRequest.complete( + method: "POST", + target: "/proxy/wp/v2/media", + httpVersion: "HTTP/1.1", + headers: ["Content-Type": "image/jpeg"], + body: RequestBody(fileURL: file) + ) + + let response = await relay.handle(HTTPServer.Request(parsed: parsed, parseDuration: .zero)) + + #expect(response.status == 201) + #expect(resent.body == contents) + } + + @Test("refuses a path outside the API root before sending anything") + func refusesForbiddenPath() async throws { + let exchange = try await relay(target: "/proxy/../wp-admin/") + + #expect(exchange.response.status == 403) + #expect(exchange.sentRequest == nil, "nothing should have been sent upstream") + } + + // MARK: - Helpers + + /// One relayed exchange: what reached the stub, and what the relay returned. + private struct Exchange { + let sentRequest: URLRequest? + let response: HTTPResponse + + /// The request that reached the stub. Fails the test if none did. + var upstream: URLRequest { + guard let sentRequest else { + Issue.record("No request reached the stubbed session") + return URLRequest(url: URL(string: "about:blank")!) + } + return sentRequest + } + } + + /// Relays one request through a stubbed session and reports both sides. + private func relay( + target: String = "/proxy/wp/v2/posts?_locale=user", + method: String = "GET", + headers: [String: String] = [:], + status: Int = 200, + responseHeaders: [String: String] = [:], + responseBody: Data = Data(), + failure: (any Error)? = nil + ) async throws -> Exchange { + let stub = StubURLProtocol.Stub( + status: status, + headers: responseHeaders, + body: responseBody, + failure: failure + ) + let stubbed = StubURLProtocol.makeSession(stub: stub) + defer { stubbed.finish() } + + let relay = RestRelay( + configuration: EditorConfigurationBuilder( + postType: .post, + siteURL: URL(string: "https://example.com")!, + siteApiRoot: URL(string: "https://example.com/wp-json/")!, + authHeader: "Bearer test-token" + ).build(), + session: stubbed.session + ) + + let parsed = ParsedHTTPRequest.complete( + method: method, + target: target, + httpVersion: "HTTP/1.1", + headers: headers, + body: nil + ) + let response = await relay.handle( + HTTPServer.Request(parsed: parsed, parseDuration: .zero) + ) + + return Exchange(sentRequest: stubbed.recorder.request, response: response) + } +} + +/// The body a test server received, handed across from its handler. +private final class ReceivedBody: @unchecked Sendable { + private let lock = NSLock() + private var _body: Data? + + var body: Data? { + lock.withLock { _body } + } + + func record(_ body: Data) { + lock.withLock { _body = body } + } +} + +private extension HTTPResponse { + /// The value of the first header matching `name`, case-insensitively. + func header(_ name: String) -> String? { + headers.first { $0.0.lowercased() == name.lowercased() }?.1 + } +} + +#endif diff --git a/ios/Tests/GutenbergKitTests/Media/RestRelayIntegrationTests.swift b/ios/Tests/GutenbergKitTests/Media/RestRelayIntegrationTests.swift new file mode 100644 index 000000000..c91c99482 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Media/RestRelayIntegrationTests.swift @@ -0,0 +1,253 @@ +#if canImport(Network) + +import Foundation +import GutenbergKitHTTP +import Testing +@testable import GutenbergKit + +/// End-to-end coverage of the relay against a real WordPress site: the request +/// crosses the loopback server, is forwarded natively, and the site's response +/// comes back to the caller. +/// +/// Skipped unless `WP_ENV_CREDENTIALS_PATH` points at the credentials file +/// `make wp-env-start` writes, so a normal test run — and CI — never needs a +/// site: +/// +/// ```sh +/// make wp-env-start +/// WP_ENV_CREDENTIALS_PATH="$PWD/.wp-env.credentials.json" swift test +/// ``` +/// +/// Includes the `canUser` chain: a deliberate `OPTIONS` has to reach the site +/// and its `Allow` header has to survive back to the caller, or the editor +/// reports that the user can do nothing at all. +@Suite("RestRelay against a live site", .enabled(if: WPEnvSite.current != nil), .serialized) +struct RestRelayIntegrationTests { + + // MARK: - Reads + + @Test("relays a GET and returns the site's response") + func relaysGet() async throws { + try await withRelay { server, site in + let (data, response) = try await site.relayed("wp/v2/posts?per_page=1&_locale=user", on: server) + + #expect(response.statusCode == 200) + #expect((try? JSONSerialization.jsonObject(with: data)) as? [Any] != nil) + } + } + + @Test("exposes the response headers the editor reads") + func exposesResponseHeaders() async throws { + try await withRelay { server, site in + let (_, response) = try await site.relayed("wp/v2/posts?per_page=1", on: server) + + let exposed = try #require(response.value(forHTTPHeaderField: "Access-Control-Expose-Headers")) + // `Allow` backs `canUser`, `Link` backs pagination, and the totals + // back list counts. Unexposed, each reads as null in JavaScript. + for header in ["Allow", "Link", "X-WP-Total", "X-WP-TotalPages"] { + #expect(exposed.contains(header)) + } + } + } + + @Test("relays the Allow header that reports what the user may do") + func relaysAllowHeader() async throws { + try await withRelay { server, site in + // `canUser` issues a deliberate `OPTIONS` — no + // `Access-Control-Request-Method` — and reads `Allow` off the + // response. Answering it locally as a CORS preflight instead of + // forwarding it reports no capabilities at all, and reports it as + // success, so nothing surfaces to the user. + let (_, response) = try await site.relayed("wp/v2/pages", method: "OPTIONS", on: server) + + let allow = try #require(response.value(forHTTPHeaderField: "Allow")) + #expect(allow.contains("GET")) + } + } + + @Test("serves concurrent requests past the library's default connection cap") + func servesConcurrentRequests() async throws { + try await withRelay { server, site in + // Editor boot fans out well past the default of 5, and a connection + // over the limit is closed rather than queued. + let statuses = try await withThrowingTaskGroup(of: Int.self) { group in + for page in 1...10 { + group.addTask { + let (_, response) = try await site.relayed( + "wp/v2/types?_locale=user&page=\(page)", on: server + ) + return response.statusCode + } + } + return try await group.reduce(into: [Int]()) { $0.append($1) } + } + + #expect(statuses.count == 10) + #expect(statuses.allSatisfy { $0 == 200 }) + } + } + + // MARK: - Writes + + @Test("relays a write body, and the site actually stores it") + func relaysWriteBody() async throws { + try await withRelay { server, site in + let title = "Relay integration \(UUID().uuidString.prefix(8))" + + let (created, createResponse) = try await site.relayed( + "wp/v2/posts", + method: "POST", + body: ["title": title, "status": "draft"], + on: server + ) + #expect(createResponse.statusCode == 201) + + let post = try #require((try? JSONSerialization.jsonObject(with: created)) as? [String: Any]) + let id = try #require(post["id"] as? Int) + + // The draft is deleted whether or not the assertions below hold. + // `defer` cannot `await`, and leaving the deletion as the last + // statement meant a failing `#require` — exactly what this test + // exists to catch — left the draft in the `wp/v2/posts` listing + // `relaysGet` reads, on a site the suite is re-run against. + do { + // Read it back rather than trusting the create response: the + // defect this covers sent an empty body, which WordPress + // accepts as a no-op while still answering 2xx. + let (fetched, _) = try await site.relayed("wp/v2/posts/\(id)?context=edit", on: server) + let stored = try #require((try? JSONSerialization.jsonObject(with: fetched)) as? [String: Any]) + let storedTitle = (stored["title"] as? [String: Any])?["raw"] as? String + #expect(storedTitle == title) + } catch { + await site.deletePost(id, on: server) + throw error + } + await site.deletePost(id, on: server) + } + } + + // MARK: - Refusals + + @Test("refuses a path that walks out of the API root, in a shape the editor can read") + func refusesEscapingPath() async throws { + try await withRelay { server, site in + let (data, response) = try await site.relayed("../wp-admin/admin-ajax.php", on: server) + + #expect(response.statusCode == 403) + #expect(response.value(forHTTPHeaderField: "Content-Type") == "application/json") + // A `text/plain` body reaches JavaScript as an unparseable + // `invalid_json` with the real reason lost. + let error = try #require((try? JSONSerialization.jsonObject(with: data)) as? [String: Any]) + #expect(error["code"] as? String == "relay_forbidden_path") + #expect(error["message"] is String) + } + } + + @Test("refuses a request without the relay token") + func refusesUnauthenticated() async throws { + try await withRelay { server, _ in + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(server.port)/proxy/wp/v2/posts")!) + request.setValue("file://", forHTTPHeaderField: "Origin") + + let (_, response) = try await URLSession.shared.data(for: request) + #expect((response as? HTTPURLResponse)?.statusCode == 407) + } + } + + // MARK: - Helpers + + /// Runs `body` against a local server hosting a relay for the wp-env site, + /// stopping it afterwards. + private func withRelay( + _ body: (MediaUploadServer, WPEnvSite) async throws -> Void + ) async throws { + let site = try #require(WPEnvSite.current) + let server = try await MediaUploadServer.start(restRelay: RestRelay(configuration: site.configuration)) + defer { server.stop() } + try await body(server, site) + } +} + +/// The local WordPress environment `make wp-env-start` provisions, as described +/// by the credentials file it writes. +struct WPEnvSite { + let apiRoot: URL + let authHeader: String + + /// The site described by `WP_ENV_CREDENTIALS_PATH`, or `nil` when the + /// variable is unset or the file cannot be read. + static let current: WPEnvSite? = { + struct Credentials: Decodable { + let siteApiRoot: String + let authHeader: String + } + guard let path = ProcessInfo.processInfo.environment["WP_ENV_CREDENTIALS_PATH"], + let data = FileManager.default.contents(atPath: path), + let credentials = try? JSONDecoder().decode(Credentials.self, from: data), + let apiRoot = URL(string: credentials.siteApiRoot) else { + return nil + } + return WPEnvSite(apiRoot: apiRoot, authHeader: credentials.authHeader) + }() + + /// A session that will actually open ten connections at once. The shared + /// session caps concurrency per host at six, which would leave the + /// connection-limit test unable to fail. + private static let session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpMaximumConnectionsPerHost = 10 + return URLSession(configuration: configuration) + }() + + var configuration: EditorConfiguration { + EditorConfigurationBuilder( + postType: .post, + siteURL: apiRoot, + siteApiRoot: apiRoot, + authHeader: authHeader + ).build() + } + + /// Deletes a post through the relay, ignoring the outcome: this is cleanup, + /// and a failure here must not replace the failure that ran it. + func deletePost(_ id: Int, on server: MediaUploadServer) async { + _ = try? await relayed( + "wp/v2/posts/\(id)?force=true", + method: "POST", + headers: ["X-HTTP-Method-Override": "DELETE"], + on: server + ) + } + + /// Issues a request through the relay the way the editor's `fetch()` does: + /// the upstream path below the route, the relay's own bearer token, and the + /// headers WebKit sets on every cross-origin fetch. + func relayed( + _ path: String, + method: String = "GET", + body: [String: Any]? = nil, + headers: [String: String] = [:], + on server: MediaUploadServer + ) async throws -> (Data, HTTPURLResponse) { + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(server.port)/proxy/\(path)")!) + request.httpMethod = method + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("file://", forHTTPHeaderField: "Origin") + request.setValue("application/json, */*;q=0.1", forHTTPHeaderField: "Accept") + for (name, value) in headers { + request.setValue(value, forHTTPHeaderField: name) + } + if let body { + request.httpBody = try JSONSerialization.data(withJSONObject: body) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + let (data, response) = try await Self.session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + return (data, http) + } +} + +#endif // canImport(Network) diff --git a/ios/Tests/GutenbergKitTests/Media/RestRelayTests.swift b/ios/Tests/GutenbergKitTests/Media/RestRelayTests.swift new file mode 100644 index 000000000..9f48ccdd6 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Media/RestRelayTests.swift @@ -0,0 +1,376 @@ +#if canImport(Network) + +import Foundation +import GutenbergKitHTTP +import Testing +@testable import GutenbergKit + +/// Covers how a relayed request's path becomes an upstream URL. This is the +/// relay's containment boundary: the web view supplies a path, never a URL, and +/// nothing it can put in that path may address anything outside the site API +/// root. +@Suite("RestRelay upstream URL") +struct RestRelayTests { + + /// A site on pretty permalinks. + private static let prettyRoot = URL(string: "https://example.com/wp-json/")! + + /// A site on plain permalinks, where the API root carries a query and the + /// path has to merge into it rather than start a second one. + private static let plainRoot = URL(string: "https://example.com/?rest_route=/")! + + // MARK: - Path resolution + + @Test("appends the path and query to the API root") + func appendsPathAndQuery() { + let relay = makeRelay(apiRoot: Self.prettyRoot) + #expect( + relay.upstreamURL(for: request("/proxy/wp/v2/posts?_locale=user"))?.absoluteString + == "https://example.com/wp-json/wp/v2/posts?_locale=user" + ) + } + + @Test("resolves the API root itself for a bare route") + func bareRouteResolvesToAPIRoot() { + let relay = makeRelay(apiRoot: Self.prettyRoot) + #expect(relay.upstreamURL(for: request("/proxy"))?.absoluteString == "https://example.com/wp-json/") + #expect(relay.upstreamURL(for: request("/proxy/"))?.absoluteString == "https://example.com/wp-json/") + } + + @Test("adds a trailing slash to an API root configured without one") + func normalizesAPIRootWithoutTrailingSlash() { + let relay = makeRelay(apiRoot: URL(string: "https://example.com/wp-json")!) + #expect( + relay.upstreamURL(for: request("/proxy/wp/v2/posts"))?.absoluteString + == "https://example.com/wp-json/wp/v2/posts" + ) + } + + @Test("continues the query of an API root that already carries one") + func mergesIntoAQueryCarryingAPIRoot() { + // Plain permalinks: `https://example.com/?rest_route=/` + `wp/v2/posts` + // has to produce one query string, not two — mirroring what + // `createRootURLMiddleware` does on the JavaScript side. + let relay = makeRelay(apiRoot: Self.plainRoot) + #expect( + relay.upstreamURL(for: request("/proxy/wp/v2/posts?_locale=user"))?.absoluteString + == "https://example.com/?rest_route=/wp/v2/posts&_locale=user" + ) + #expect( + relay.upstreamURL(for: request("/proxy/wp/v2/posts?a=1&b=2"))?.absoluteString + == "https://example.com/?rest_route=/wp/v2/posts&a=1&b=2" + ) + } + + @Test("decodes the route of a root advertised with it encoded") + func decodesAdvertisedPlainPermalinkRoot() { + // WordPress advertises a plain-permalink root through `add_query_arg`, + // which percent-encodes the route: `index.php?rest_route=%2F`. The + // slash termination has to land inside that value, since WordPress + // reads `rest_route=%2F/wp/v2/posts` as the route `//wp/v2/posts`. + let relay = makeRelay(apiRoot: URL(string: "https://example.com/index.php?rest_route=%2F")!) + #expect( + relay.upstreamURL(for: request("/proxy/wp/v2/posts?_locale=user"))?.absoluteString + == "https://example.com/index.php?rest_route=/wp/v2/posts&_locale=user" + ) + #expect( + relay.upstreamURL(for: request("/proxy"))?.absoluteString + == "https://example.com/index.php?rest_route=/" + ) + } + + @Test("preserves percent-encoding in the query") + func preservesPercentEncoding() { + let relay = makeRelay(apiRoot: Self.prettyRoot) + #expect( + relay.upstreamURL(for: request("/proxy/wp/v2/search?search=caf%C3%A9&per_page=100"))?.absoluteString + == "https://example.com/wp-json/wp/v2/search?search=caf%C3%A9&per_page=100" + ) + } + + // MARK: - Containment + + @Test("refuses a path that walks out of the API root") + func refusesDotSegments() { + let relay = makeRelay(apiRoot: Self.prettyRoot) + #expect(relay.upstreamURL(for: request("/proxy/../wp-admin/admin-ajax.php")) == nil) + #expect(relay.upstreamURL(for: request("/proxy/wp/v2/../../../wp-admin/")) == nil) + #expect(relay.upstreamURL(for: request("/proxy/wp/v2/./posts")) == nil) + } + + @Test("refuses percent-encoded dot segments") + func refusesEncodedDotSegments() { + // `URLSession` leaves these encoded, but the receiving server may decode + // before resolving, so they are refused here rather than forwarded. + let relay = makeRelay(apiRoot: Self.prettyRoot) + #expect(relay.upstreamURL(for: request("/proxy/%2e%2e/wp-admin/")) == nil) + #expect(relay.upstreamURL(for: request("/proxy/wp/%2E%2E/%2e%2e/")) == nil) + } + + @Test("refuses dot segments whose separators are encoded too") + func refusesDotSegmentsWithEncodedSeparators() { + // The whole traversal is one literal segment, so it is only a dot + // segment to a server that decodes the separator before normalizing. + let relay = makeRelay(apiRoot: Self.prettyRoot) + #expect(relay.upstreamURL(for: request("/proxy/%2e%2e%2f%2e%2e%2fwp-admin/admin-ajax.php")) == nil) + #expect(relay.upstreamURL(for: request("/proxy/wp%5C..%5Cwp-admin/")) == nil) + #expect(relay.upstreamURL(for: request("/proxy/wp\\..\\wp-admin/")) == nil) + } + + @Test("an encoded slash within a segment is not a dot segment") + func allowsEncodedSlashesWithinSegments() { + // A template ID is `theme//slug`, encoded into a single path segment. + let relay = makeRelay(apiRoot: Self.prettyRoot) + #expect( + relay.upstreamURL(for: request("/proxy/wp/v2/templates/twentytwentyfour%2F%2Fsingle"))?.absoluteString + == "https://example.com/wp-json/wp/v2/templates/twentytwentyfour%2F%2Fsingle" + ) + } + + @Test("a dot inside a path segment is not a dot segment") + func allowsDotsWithinSegments() { + let relay = makeRelay(apiRoot: Self.prettyRoot) + #expect( + relay.upstreamURL(for: request("/proxy/oembed/1.0/embed?url=https%3A%2F%2Fexample.com"))?.absoluteString + == "https://example.com/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fexample.com" + ) + } + + @Test("an absolute URL in the path stays under the API root") + func absoluteURLInPathStaysContained() { + // There is no URL to resolve, so a smuggled one becomes an ordinary + // (404ing) path segment rather than another host. + let relay = makeRelay(apiRoot: Self.prettyRoot) + let resolved = relay.upstreamURL(for: request("/proxy/https://elsewhere.example/x")) + #expect(resolved?.absoluteString.hasPrefix("https://example.com/wp-json/") == true) + #expect(relay.upstreamURL(for: request("/proxy//elsewhere.example/x"))?.host() == "example.com") + } + + @Test("forwards the query verbatim, rest_route included") + func forwardsQueryVerbatim() { + // WordPress prefers `$_GET['rest_route']` over the route the path + // names, so the caller picks the route whichever way it spells the + // parameter. Deliberately not filtered: see the type's Security notes. + let relay = makeRelay(apiRoot: Self.prettyRoot) + #expect( + relay.upstreamURL(for: request("/proxy/wp/v2/posts?rest_route=/wp/v2/users&_locale=user"))?.absoluteString + == "https://example.com/wp-json/wp/v2/posts?rest_route=/wp/v2/users&_locale=user" + ) + } + + @Test("refuses a request outside the relay route") + func refusesForeignRoute() { + let relay = makeRelay(apiRoot: Self.prettyRoot) + #expect(relay.upstreamURL(for: request("/upload")) == nil) + #expect(relay.upstreamURL(for: request("/proxying/wp/v2/posts")) == nil) + } + + // MARK: - Redirects + + @Test("follows a redirect that stays under the API root") + func followsContainedRedirect() { + let followed = redirectDecision(to: "https://example.com/wp-json/wp/v2/posts/1") + + #expect(followed?.url?.absoluteString == "https://example.com/wp-json/wp/v2/posts/1") + } + + @Test("refuses a redirect that leaves the API root") + func refusesEscapingRedirect() { + // Another host, another path on the same site, and a scheme downgrade. + // The credential should follow the request only to the API it was + // configured for. + for target in [ + "https://elsewhere.example/wp-json/wp/v2/posts", + "https://example.com/wp-login.php", + "http://example.com/wp-json/wp/v2/posts", + ] { + #expect(redirectDecision(to: target) == nil, "should refuse \(target)") + } + } + + @Test("follows a redirect that only upgrades the scheme") + func followsSchemeUpgrade() { + // A site whose `siteurl` is `http` but which answers on `https` is the + // deployment `relayUpstreamPath` already relays; refusing its redirect + // here would fail every request the layer above deliberately sent. + let redirectGuard = RestRelay.RedirectGuard(allowedPrefix: "http://example.com/wp-json/") + let followed = decide(redirectGuard, target: "https://example.com/wp-json/wp/v2/posts") + + #expect(followed?.url?.absoluteString == "https://example.com/wp-json/wp/v2/posts") + #expect(redirectGuard.refusedTarget == nil) + } + + @Test("the scheme upgrade admits nothing else") + func schemeUpgradeStaysContained() { + // Upgrading the scheme must not also relax the host or the path. + for target in [ + "https://elsewhere.example/wp-json/wp/v2/posts", + "https://example.com/wp-login.php", + "https://example.com:8443/wp-json/wp/v2/posts", + ] { + let redirectGuard = RestRelay.RedirectGuard(allowedPrefix: "http://example.com/wp-json/") + #expect(decide(redirectGuard, target: target) == nil, "should refuse \(target)") + } + } + + @Test("follows a redirect to an alias of the configured host") + func followsHostAlias() { + // The spellings `relayUpstreamPath` tolerates, so the two layers + // agree: a site whose canonical redirect names its `www.` alias, or + // wp-env answering on `127.0.0.1` for a root configured as + // `localhost`, would otherwise have every relayed request refused. + for (root, target) in [ + ("https://example.com/wp-json/", "https://www.example.com/wp-json/wp/v2/posts"), + ("https://www.example.com/wp-json/", "https://example.com/wp-json/wp/v2/posts"), + ("http://localhost:8888/wp-json/", "http://127.0.0.1:8888/wp-json/wp/v2/posts"), + ("http://example.com/wp-json/", "https://www.example.com/wp-json/wp/v2/posts"), + ("https://example.com/wp-json/", "https://Example.com:443/wp-json/wp/v2/posts"), + ] { + let redirectGuard = RestRelay.RedirectGuard(allowedPrefix: root) + #expect(decide(redirectGuard, target: target)?.url?.absoluteString == target, "should follow \(target)") + #expect(redirectGuard.refusedTarget == nil) + } + } + + @Test("the alias tolerance admits nothing else") + func hostAliasStaysContained() { + // Only the spellings of the same name: not a subdomain, a lookalike, + // another port on the alias, or another path. + for target in [ + "https://www2.example.com/wp-json/wp/v2/posts", + "https://example.com.evil.example/wp-json/wp/v2/posts", + "https://wwwexample.com/wp-json/wp/v2/posts", + "https://www.example.com:8443/wp-json/wp/v2/posts", + "https://www.example.com/wp-login.php", + ] { + #expect(redirectDecision(to: target) == nil, "should refuse \(target)") + } + } + + @Test("reports the refused target so the editor can say what happened") + func recordsRefusedTarget() { + // Without this the relay would hand back the 3xx itself, and `fetch` + // — which follows redirects by default — would chase it to the host + // the guard just declined. + let redirectGuard = RestRelay.RedirectGuard(allowedPrefix: "https://example.com/wp-json/") + #expect(redirectGuard.refusedTarget == nil) + + _ = decide(redirectGuard, target: "https://elsewhere.example/x") + + #expect(redirectGuard.refusedTarget == "https://elsewhere.example/x") + } + + // MARK: - Body replay + + @Test("reopens a streamed body for a redirect it follows") + func reopensStreamedBody() throws { + // A `307`/`308` has `URLSession` resend the body, and a body the + // parser spilled to disk went out as a one-shot stream. Without a + // fresh one the task fails with `requestBodyStreamExhausted` — for + // large uploads only, since a `Data` body replays on its own. + let contents = Data(repeating: 0x41, count: 100_000) + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try contents.write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + + let redirectGuard = RestRelay.RedirectGuard( + allowedPrefix: "https://example.com/wp-json/", + streamedBody: RequestBody(fileURL: file) + ) + var reopened: InputStream? + redirectGuard.urlSession(.shared, task: Self.unusedTask, needNewBodyStream: { reopened = $0 }) + + #expect(try readAll(#require(reopened)) == contents) + } + + @Test("supplies no stream when the body was not streamed") + func noStreamWithoutStreamedBody() { + let redirectGuard = RestRelay.RedirectGuard(allowedPrefix: "https://example.com/wp-json/") + var reopened: InputStream? = InputStream(data: Data()) + redirectGuard.urlSession(.shared, task: Self.unusedTask, needNewBodyStream: { reopened = $0 }) + + #expect(reopened == nil) + } + + // MARK: - Routing + + @Test("claims its own route and nothing else") + func routeMatching() { + #expect(RestRelay.handles(request("/proxy"))) + #expect(RestRelay.handles(request("/proxy/wp/v2/posts?_locale=user"))) + #expect(!RestRelay.handles(request("/upload"))) + #expect(!RestRelay.handles(request("/proxying"))) + } + + // MARK: - Helpers + + private func makeRelay(apiRoot: URL) -> RestRelay { + RestRelay( + configuration: EditorConfigurationBuilder( + postType: .post, + siteURL: URL(string: "https://example.com")!, + siteApiRoot: apiRoot, + authHeader: "Bearer test-token" + ).build() + ) + } + + private func request(_ target: String, method: String = "GET") -> ParsedHTTPRequest { + .complete(method: method, target: target, httpVersion: "HTTP/1.1", headers: [:], body: nil) + } + + /// The request a fresh guard would follow for a redirect to `target`, or + /// `nil` if it refuses. + private func redirectDecision(to target: String) -> URLRequest? { + decide( + RestRelay.RedirectGuard(allowedPrefix: "https://example.com/wp-json/"), + target: target + ) + } + + /// A task for the delegate signature, which the guard never inspects. + /// + /// Built once from a session the tests own and immediately invalidated: + /// asking `URLSession.shared` for one per assertion left a suspended task + /// retained for the life of the test process each time. + private static let unusedTask: URLSessionTask = { + let session = URLSession(configuration: .ephemeral) + defer { session.invalidateAndCancel() } + return session.dataTask(with: URL(string: "https://example.com/")!) + }() + + /// Asks `redirectGuard` whether to follow a redirect to `target`. + private func decide(_ redirectGuard: RestRelay.RedirectGuard, target: String) -> URLRequest? { + let url = URL(string: target)! + var followed: URLRequest? + redirectGuard.urlSession( + .shared, + task: Self.unusedTask, + willPerformHTTPRedirection: HTTPURLResponse( + url: URL(string: "https://example.com/wp-json/wp/v2/posts")!, + statusCode: 301, + httpVersion: "HTTP/1.1", + headerFields: ["Location": target] + )!, + newRequest: URLRequest(url: url), + completionHandler: { followed = $0 } + ) + return followed + } + + private func readAll(_ stream: InputStream) -> Data { + stream.open() + defer { stream.close() } + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: buffer.count) + guard read > 0 else { break } + data.append(buffer, count: read) + } + return data + } +} + +#endif // canImport(Network) diff --git a/ios/Tests/GutenbergKitTests/Media/StubURLProtocol.swift b/ios/Tests/GutenbergKitTests/Media/StubURLProtocol.swift new file mode 100644 index 000000000..cef99e884 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Media/StubURLProtocol.swift @@ -0,0 +1,150 @@ +#if canImport(Network) + +import Foundation + +/// A `URLProtocol` that answers from a canned response and records the request +/// it was asked to send, so a `URLSession` client can be tested without a +/// server. +/// +/// The stub and the recorder are held per-session rather than in a global: two +/// tests running in parallel each build their own session, and a shared slot +/// would hand one test the other's request. +final class StubURLProtocol: URLProtocol { + + /// The canned response one session answers with. + struct Stub: Sendable { + var status: Int = 200 + var headers: [String: String] = [:] + var body = Data() + /// When set, the request fails with this error instead of responding. + var failure: (any Error)? + } + + /// The request that reached the stub, if any. + final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var _request: URLRequest? + + var request: URLRequest? { + lock.withLock { _request } + } + + func record(_ request: URLRequest) { + lock.withLock { _request = request } + } + } + + /// Per-session configuration, keyed by a token carried in the session's + /// `httpAdditionalHeaders` — the only channel a `URLProtocol` subclass has + /// to the session that instantiated it. + private struct Registration { + let stub: Stub + let recorder: Recorder + } + + private static let lock = NSLock() + nonisolated(unsafe) private static var registrations: [String: Registration] = [:] + + private static let tokenHeader = "X-Stub-Session" + + /// A stubbed session and the recorder capturing what it was asked to send. + /// + /// Call ``finish()`` when the test is done. Invalidating the session alone + /// would leave the registration — and the stub's body — held for the life + /// of the test process. + struct Stubbed { + let session: URLSession + let recorder: Recorder + private let token: String + + init(session: URLSession, recorder: Recorder, token: String) { + self.session = session + self.recorder = recorder + self.token = token + } + + func finish() { + session.invalidateAndCancel() + StubURLProtocol.lock.withLock { + StubURLProtocol.registrations[token] = nil + } + } + } + + /// A session that answers every request with `stub`. + static func makeSession(stub: Stub) -> Stubbed { + let token = UUID().uuidString + let recorder = Recorder() + + lock.withLock { + registrations[token] = Registration(stub: stub, recorder: recorder) + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + configuration.httpAdditionalHeaders = [tokenHeader: token] + return Stubbed( + session: URLSession(configuration: configuration), + recorder: recorder, + token: token + ) + } + + override class func canInit(with request: URLRequest) -> Bool { + request.value(forHTTPHeaderField: tokenHeader) != nil + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let token = request.value(forHTTPHeaderField: Self.tokenHeader), + let registration = Self.lock.withLock({ Self.registrations[token] }) else { + client?.urlProtocol(self, didFailWithError: URLError(.unsupportedURL)) + return + } + + // `URLProtocol` hands over the body as a stream once the request is + // built, so read it back for the recorded copy. + var recorded = request + recorded.setValue(nil, forHTTPHeaderField: Self.tokenHeader) + if recorded.httpBody == nil, let stream = recorded.httpBodyStream { + recorded.httpBody = Self.readAll(stream) + } + registration.recorder.record(recorded) + + if let failure = registration.stub.failure { + client?.urlProtocol(self, didFailWithError: failure) + return + } + + let response = HTTPURLResponse( + url: request.url!, + statusCode: registration.stub.status, + httpVersion: "HTTP/1.1", + headerFields: registration.stub.headers + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: registration.stub.body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + private static func readAll(_ stream: InputStream) -> Data { + stream.open() + defer { stream.close() } + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: buffer.count) + guard read > 0 else { break } + data.append(buffer, count: read) + } + return data + } +} + +#endif diff --git a/src/utils/api-fetch-relay-plain-permalinks.test.js b/src/utils/api-fetch-relay-plain-permalinks.test.js new file mode 100644 index 000000000..eb90e0eaf --- /dev/null +++ b/src/utils/api-fetch-relay-plain-permalinks.test.js @@ -0,0 +1,153 @@ +/** + * External dependencies + */ +import { + describe, + it, + expect, + beforeAll, + beforeEach, + afterEach, + vi, +} from 'vitest'; + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { configureApiFetch } from './api-fetch'; +import { installFetchWrappers } from './fetch-chain'; +import { createRelayFetchWrapper } from './fetch-relay'; +import * as bridge from './bridge'; + +vi.mock( './bridge', async ( importOriginal ) => { + const actual = await importOriginal(); + return { + ...actual, + getGBKit: vi.fn(), + }; +} ); + +vi.mock( './logger', () => ( { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +} ) ); + +/** The root WordPress advertises for a site on plain permalinks. */ +const API_ROOT = 'https://example.com/index.php?rest_route=%2F'; +const RELAY_ROOT = 'http://127.0.0.1:5555/proxy/'; + +const GBKIT = { + siteApiRoot: API_ROOT, + siteApiNamespace: [ 'wp/v2' ], + namespaceExcludedPaths: [], + authHeader: 'Bearer site-token', + networkProxy: { + port: 5555, + token: 'relay-token', + baseURL: RELAY_ROOT, + }, +}; + +/** The fetch the relay wrapper delegates to; replaced per test. */ +let transport; + +/** + * A minimal stand-in for the `Response` the relay returns, carrying only what + * api-fetch and its middleware read. + * + * @param {Object} options Response shape. + * @param {number} options.status HTTP status. + * @param {any} options.body The decoded JSON body. + * @param {Object} options.headers Response headers, lowercase-keyed. + * @return {Object} The response stand-in. + */ +function makeResponse( { status = 200, body = {}, headers = {} } = {} ) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve( body ), + headers: { get: ( name ) => headers[ name.toLowerCase() ] ?? null }, + }; +} + +/** + * The URL the transport received on its nth call. + * + * @param {number} index Call index. + * @return {string} The URL. + */ +function transportURL( index = 0 ) { + return transport.mock.calls[ index ][ 0 ]; +} + +// The pretty-permalink counterpart lives in `api-fetch-relay.test.js`. A +// plain-permalink root is a separate file because the fetch chain installs +// once per page, and the relay reads its root at install time. +describe( 'REST relay transport on plain permalinks', () => { + beforeAll( () => { + window.GBKit = GBKIT; + bridge.getGBKit.mockReturnValue( GBKIT ); + + window.fetch = ( ...args ) => transport( ...args ); + installFetchWrappers( [ createRelayFetchWrapper() ] ); + + configureApiFetch(); + } ); + + beforeEach( () => { + bridge.getGBKit.mockReturnValue( GBKIT ); + transport = vi.fn( () => Promise.resolve( makeResponse() ) ); + } ); + + afterEach( () => { + vi.clearAllMocks(); + } ); + + it( 'relays a request whose route api-fetch re-encoded', async () => { + // The locale middleware runs after the root URL middleware and + // rebuilds the query, so the route arrives as `%2Fwp%2Fv2%2Fposts`. + await apiFetch( { path: '/wp/v2/posts' } ); + + expect( transportURL() ).toBe( + `${ RELAY_ROOT }wp/v2/posts?_locale=user` + ); + } ); + + it( 'carries the query of a relayed request', async () => { + await apiFetch( { path: '/wp/v2/posts?context=edit&per_page=10' } ); + + expect( transportURL() ).toBe( + `${ RELAY_ROOT }wp/v2/posts?context=edit&per_page=10&_locale=user` + ); + } ); + + it( 'relays the next page WordPress names in Link', async () => { + // WordPress builds the URL through `add_query_arg`, which encodes + // the route the same way. + transport = vi + .fn() + .mockResolvedValueOnce( + makeResponse( { + body: [ { id: 1 } ], + headers: { + link: '; rel="next"', + }, + } ) + ) + .mockResolvedValueOnce( makeResponse( { body: [ { id: 2 } ] } ) ); + + const result = await apiFetch( { path: '/wp/v2/posts?per_page=-1' } ); + + expect( transportURL( 1 ) ).toBe( + `${ RELAY_ROOT }wp/v2/posts?page=2&_locale=user` + ); + expect( result ).toEqual( [ { id: 1 }, { id: 2 } ] ); + } ); +} ); diff --git a/src/utils/api-fetch-relay.test.js b/src/utils/api-fetch-relay.test.js new file mode 100644 index 000000000..b17b506a1 --- /dev/null +++ b/src/utils/api-fetch-relay.test.js @@ -0,0 +1,370 @@ +/** + * External dependencies + */ +import { + describe, + it, + expect, + beforeAll, + beforeEach, + afterEach, + vi, +} from 'vitest'; + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { configureApiFetch } from './api-fetch'; +import { installFetchWrappers } from './fetch-chain'; +import { createRelayFetchWrapper } from './fetch-relay'; +import * as bridge from './bridge'; + +vi.mock( './bridge', async ( importOriginal ) => { + const actual = await importOriginal(); + return { + ...actual, + getGBKit: vi.fn(), + }; +} ); + +vi.mock( './logger', () => ( { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +} ) ); + +const API_ROOT = 'https://example.com/wp-json/'; +const RELAY_ROOT = 'http://127.0.0.1:5555/proxy/'; + +const GBKIT = { + siteApiRoot: API_ROOT, + siteApiNamespace: [ 'wp/v2' ], + namespaceExcludedPaths: [], + authHeader: 'Bearer site-token', + networkProxy: { + port: 5555, + token: 'relay-token', + baseURL: 'http://127.0.0.1:5555/proxy/', + }, +}; + +/** The fetch the relay wrapper delegates to; replaced per test. */ +let transport; + +/** + * A minimal stand-in for the `Response` the relay returns, carrying only what + * api-fetch and its middleware read. + * + * @param {Object} options Response shape. + * @param {number} options.status HTTP status. + * @param {any} options.body The decoded JSON body, or a rejecting decoder. + * @param {Object} options.headers Response headers, lowercase-keyed. + * @return {Object} The response stand-in. + */ +function makeResponse( { status = 200, body = {}, headers = {} } = {} ) { + return { + ok: status >= 200 && status < 300, + status, + json: () => + body instanceof Error + ? Promise.reject( body ) + : Promise.resolve( body ), + headers: { get: ( name ) => headers[ name.toLowerCase() ] ?? null }, + }; +} + +/** + * The arguments the transport received on its nth call. + * + * @param {number} index Call index. + * @return {{url: string, init: Object}} The call. + */ +function transportCall( index = 0 ) { + const [ url, init ] = transport.mock.calls[ index ]; + return { url, init }; +} + +/** + * A header from an intercepted call, case-insensitively. + * + * @param {Object} init The `fetch` init the transport received. + * @param {string} name The header name. + * @return {string|null} The header value. + */ +function header( init, name ) { + return new Headers( init.headers ).get( name ); +} + +describe( 'REST relay transport', () => { + beforeAll( () => { + window.GBKit = GBKIT; + bridge.getGBKit.mockReturnValue( GBKIT ); + + // A stable indirection so each test can swap the fetch the relay + // delegates to; the chain captures whatever `window.fetch` is at + // install time. + window.fetch = ( ...args ) => transport( ...args ); + installFetchWrappers( [ createRelayFetchWrapper() ] ); + + configureApiFetch(); + } ); + + beforeEach( () => { + bridge.getGBKit.mockReturnValue( GBKIT ); + transport = vi.fn( () => Promise.resolve( makeResponse() ) ); + } ); + + afterEach( () => { + vi.clearAllMocks(); + } ); + + describe( 'request', () => { + it( 'sends the upstream path below the API root to the relay route', async () => { + await apiFetch( { path: '/wp/v2/posts' } ); + + expect( transportCall().url ).toBe( + `${ RELAY_ROOT }wp/v2/posts?_locale=user` + ); + } ); + + it( 'authenticates to the relay without sending the site credential', async () => { + await apiFetch( { path: '/wp/v2/posts' } ); + + const { init } = transportCall(); + expect( header( init, 'Relay-Authorization' ) ).toBe( + 'Bearer relay-token' + ); + // The relay injects the site credential natively, so it must not + // travel over loopback. + expect( header( init, 'Authorization' ) ).toBeNull(); + } ); + + it( 'sends the Accept header WordPress uses to recognize a REST request', async () => { + await apiFetch( { path: '/wp/v2/posts' } ); + + expect( header( transportCall().init, 'Accept' ) ).toBe( + 'application/json, */*;q=0.1' + ); + } ); + + it( 'serializes `data` into a JSON body', async () => { + await apiFetch( { + path: '/wp/v2/posts/1', + method: 'POST', + data: { title: 'Hello' }, + } ); + + const { init } = transportCall(); + expect( init.body ).toBe( JSON.stringify( { title: 'Hello' } ) ); + expect( header( init, 'Content-Type' ) ).toBe( 'application/json' ); + } ); + + it( 'applies the HTTP v1 method override', async () => { + await apiFetch( { + path: '/wp/v2/posts/1', + method: 'PUT', + data: { title: 'Hello' }, + } ); + + const { init } = transportCall(); + expect( init.method ).toBe( 'POST' ); + expect( header( init, 'X-HTTP-Method-Override' ) ).toBe( 'PUT' ); + } ); + + it( 'forwards an abort signal', async () => { + const controller = new AbortController(); + await apiFetch( { + path: '/wp/v2/posts', + signal: controller.signal, + } ); + + expect( transportCall().init.signal ).toBe( controller.signal ); + } ); + + it( 'does not send credentials the relay would reject', async () => { + // The relay answers `Access-Control-Allow-Origin: *`, which a + // browser refuses to pair with a credentialed request — and + // api-fetch defaults `credentials` to `include`. + await apiFetch( { path: '/wp/v2/posts' } ); + + expect( transportCall().init.credentials ).toBe( 'omit' ); + } ); + + it( 'relays the absolute URL of a paginated next page', async () => { + // `fetchAllMiddleware` follows the absolute URL WordPress puts in + // the `Link` header, so the transport has to recognize the site's + // own API root in it. + transport = vi + .fn() + .mockResolvedValueOnce( + makeResponse( { + body: [ { id: 1 } ], + headers: { + link: `<${ API_ROOT }wp/v2/posts?page=2>; rel="next"`, + }, + } ) + ) + .mockResolvedValueOnce( + makeResponse( { body: [ { id: 2 } ] } ) + ); + + const result = await apiFetch( { + path: '/wp/v2/posts?per_page=-1', + } ); + + // `per_page=-1` is expanded by api-fetch's own middleware rather + // than forwarded verbatim, which WordPress would reject. + expect( transportCall( 0 ).url ).toContain( 'per_page=100' ); + expect( transportCall( 0 ).url ).not.toContain( 'per_page=-1' ); + expect( transportCall( 1 ).url ).toBe( + `${ RELAY_ROOT }wp/v2/posts?page=2&_locale=user` + ); + expect( result ).toEqual( [ { id: 1 }, { id: 2 } ] ); + } ); + + it( 'relays a next page that arrives on a host alias', async () => { + // WordPress builds `Link` from `home_url()`, which need not be the + // host the app was configured with — `www.` versus bare, a mapped + // domain, `http` behind `https`. wp-env is the everyday case: its + // credentials report `localhost` while WordPress reports + // `127.0.0.1`. + transport = vi + .fn() + .mockResolvedValueOnce( + makeResponse( { + body: [ { id: 1 } ], + headers: { + link: '; rel="next"', + }, + } ) + ) + .mockResolvedValueOnce( + makeResponse( { body: [ { id: 2 } ] } ) + ); + + const result = await apiFetch( { + path: '/wp/v2/posts?per_page=-1', + } ); + + expect( transportCall( 1 ).url ).toBe( + `${ RELAY_ROOT }wp/v2/posts?page=2&_locale=user` + ); + expect( result ).toEqual( [ { id: 1 }, { id: 2 } ] ); + } ); + + it( 'preserves a percent-encoded value in a relayed next page', async () => { + // Matching moves the target onto the API root's origin, which + // re-parses it. An encoded value has to survive that unchanged. + transport = vi + .fn() + .mockResolvedValueOnce( + makeResponse( { + body: [ { id: 1 } ], + headers: { + link: `<${ API_ROOT }wp/v2/posts?search=caf%C3%A9&page=2>; rel="next"`, + }, + } ) + ) + .mockResolvedValueOnce( + makeResponse( { body: [ { id: 2 } ] } ) + ); + + await apiFetch( { path: '/wp/v2/posts?per_page=-1' } ); + + expect( transportCall( 1 ).url ).toContain( 'search=caf%C3%A9' ); + } ); + + it( 'leaves a request for somewhere other than the site API alone', async () => { + await apiFetch( { url: 'https://elsewhere.example/x' } ); + + const { url, init } = transportCall(); + expect( url ).toBe( 'https://elsewhere.example/x?_locale=user' ); + expect( header( init, 'Relay-Authorization' ) ).toBeNull(); + } ); + } ); + + describe( 'response', () => { + it( 'resolves the decoded body', async () => { + transport = vi.fn( () => + Promise.resolve( makeResponse( { body: { id: 7 } } ) ) + ); + + await expect( + apiFetch( { path: '/wp/v2/posts/7' } ) + ).resolves.toEqual( { id: 7 } ); + } ); + + it( 'resolves a 204 to null', async () => { + transport = vi.fn( () => + Promise.resolve( makeResponse( { status: 204 } ) ) + ); + + await expect( + apiFetch( { path: '/wp/v2/posts/7' } ) + ).resolves.toBeNull(); + } ); + + it( 'returns the raw response when parsing is declined', async () => { + const response = makeResponse( { + headers: { allow: 'GET, POST' }, + } ); + transport = vi.fn( () => Promise.resolve( response ) ); + + // `canUser` reads the `Allow` header off an unparsed response. + const result = await apiFetch( { + path: '/wp/v2/posts', + method: 'OPTIONS', + parse: false, + } ); + + expect( result ).toBe( response ); + expect( result.headers.get( 'allow' ) ).toBe( 'GET, POST' ); + } ); + + it( 'throws the decoded error body of a failed request', async () => { + transport = vi.fn( () => + Promise.resolve( + makeResponse( { + status: 403, + body: { + code: 'rest_cannot_edit', + message: 'Sorry, you are not allowed to do that.', + }, + } ) + ) + ); + + await expect( + apiFetch( { path: '/wp/v2/posts/7' } ) + ).rejects.toMatchObject( { code: 'rest_cannot_edit' } ); + } ); + + it( 'normalizes an undecodable body', async () => { + transport = vi.fn( () => + Promise.resolve( + makeResponse( { body: new Error( 'not json' ) } ) + ) + ); + + await expect( + apiFetch( { path: '/wp/v2/posts/7' } ) + ).rejects.toMatchObject( { code: 'invalid_json' } ); + } ); + + it( 'normalizes a transport failure', async () => { + transport = vi.fn( () => + Promise.reject( new TypeError( 'Load failed' ) ) + ); + + await expect( + apiFetch( { path: '/wp/v2/posts/7' } ) + ).rejects.toMatchObject( { code: 'fetch_error' } ); + } ); + } ); +} ); diff --git a/src/utils/bridge.js b/src/utils/bridge.js index f04b50830..c2b70f843 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -221,19 +221,30 @@ export function onNetworkRequest( requestData ) { } } +/** + * @typedef {Object} NetworkProxy + * + * @property {number} port The port the loopback REST relay is listening on. + * @property {string} token Per-session auth token for requests to the relay. + * @property {string} baseURL The relay's route, slash-terminated, to append an + * upstream path to. Built natively so the route is + * spelled in one language. + */ + /** * @typedef GBKitConfig * - * @property {boolean} [themeStyles] Controls if theme styles are applied to the editor. - * @property {string} [siteApiRoot] The root URL of the site's API. - * @property {string[]} [siteApiNamespace] The namespace of the site's API; if multiple namespaces are provided, the first one is used as the default. - * @property {string[]} [namespaceExcludedPaths] The paths that should not be namespaced. - * @property {string} [authHeader] The authentication header. - * @property {string} [hideTitle] Whether to hide the title. - * @property {Post} [post] The post data. - * @property {boolean} [enableNetworkLogging] Enables logging of all network requests/responses to the native host via onNetworkRequest bridge method. - * @property {number} [nativeUploadPort] Port the local HTTP server is listening on. If absent, the native upload override is not activated. - * @property {string} [nativeUploadToken] Per-session auth token for requests to the local upload server. + * @property {boolean} [themeStyles] Controls if theme styles are applied to the editor. + * @property {string} [siteApiRoot] The root URL of the site's API. + * @property {string[]} [siteApiNamespace] The namespace of the site's API; if multiple namespaces are provided, the first one is used as the default. + * @property {string[]} [namespaceExcludedPaths] The paths that should not be namespaced. + * @property {string} [authHeader] The authentication header. + * @property {string} [hideTitle] Whether to hide the title. + * @property {Post} [post] The post data. + * @property {boolean} [enableNetworkLogging] Enables logging of all network requests/responses to the native host via onNetworkRequest bridge method. + * @property {number} [nativeUploadPort] Port the local HTTP server is listening on. If absent, the native upload override is not activated. + * @property {string} [nativeUploadToken] Per-session auth token for requests to the local upload server. + * @property {NetworkProxy} [networkProxy] The loopback REST relay's connection details. If absent, the host is not running a relay. */ /** diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index 92737ffde..af537a65e 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -11,7 +11,9 @@ import { configureLocale } from './localization'; import { loadEditorAssets } from './editor-loader'; import { configureAjax } from './ajax'; import { initializeVideoPressAjaxBridge } from './videopress-bridge'; -import { initializeFetchInterceptor } from './fetch-interceptor'; +import { installFetchWrappers } from './fetch-chain'; +import { createLoggingFetchWrapper } from './fetch-logging'; +import { createRelayFetchWrapper } from './fetch-relay'; import EditorLoadError from '../components/editor-load-error'; import { setLogLevel, error } from './logger'; import { setUpGlobalErrorHandlers } from './global-error-handler'; @@ -30,7 +32,7 @@ export async function setUpEditorEnvironment() { setBodyClasses(); await awaitGBKitGlobal(); setLogLevelFromGBKit(); - initializeFetchInterceptor(); + installEditorFetchWrappers(); const isRTL = await configureLocale(); injectEditorStyles( isRTL ); await initializeWordPressGlobals(); @@ -75,6 +77,24 @@ function setLogLevelFromGBKit() { } } +/** + * Wraps the global `fetch`, outermost wrapper first. + * + * The network log sits outside the relay so it records the request the editor + * made rather than the loopback rewrite of it — the native HTTP server already + * logs that hop. Each wrapper reports itself inapplicable by returning `null`: + * the log needs `enableNetworkLogging`, the relay needs `GBKit.networkProxy` + * (iOS Lockdown Mode). + * + * @return {void} + */ +function installEditorFetchWrappers() { + installFetchWrappers( [ + createLoggingFetchWrapper(), + createRelayFetchWrapper(), + ] ); +} + /** * Initialize WordPress global modules. Lazy-loaded to ensure the locale is * configured before importing these modules and referencing the corresponding diff --git a/src/utils/editor-environment.test.js b/src/utils/editor-environment.test.js index fc37543ef..752a61536 100644 --- a/src/utils/editor-environment.test.js +++ b/src/utils/editor-environment.test.js @@ -22,11 +22,15 @@ import { initializeWordPressGlobals } from './wordpress-globals.js'; import { configureLocale } from './localization.js'; import { configureApiFetch } from './api-fetch.js'; import { initializeEditor } from './editor.jsx'; -import { initializeFetchInterceptor } from './fetch-interceptor.js'; +import { installFetchWrappers } from './fetch-chain.js'; +import { createLoggingFetchWrapper } from './fetch-logging.js'; +import { createRelayFetchWrapper } from './fetch-relay.js'; import { injectEditorStyles } from './editor-styles.js'; vi.mock( './bridge.js' ); -vi.mock( './fetch-interceptor.js' ); +vi.mock( './fetch-chain.js' ); +vi.mock( './fetch-logging.js' ); +vi.mock( './fetch-relay.js' ); vi.mock( './logger.js' ); vi.mock( './editor-styles.js' ); vi.mock( './ajax.js' ); @@ -65,7 +69,7 @@ describe( 'setUpEditorEnvironment', () => { configureLocale.mockResolvedValue( false ); initializeWordPressGlobals.mockImplementation( () => {} ); configureApiFetch.mockImplementation( () => {} ); - initializeFetchInterceptor.mockImplementation( () => {} ); + installFetchWrappers.mockImplementation( () => {} ); configureAjax.mockImplementation( () => {} ); initializeVideoPressAjaxBridge.mockImplementation( () => {} ); initializeEditor.mockImplementation( () => {} ); @@ -83,8 +87,8 @@ describe( 'setUpEditorEnvironment', () => { return Promise.resolve(); } ); - initializeFetchInterceptor.mockImplementation( () => { - callOrder.push( 'initializeFetchInterceptor' ); + installFetchWrappers.mockImplementation( () => { + callOrder.push( 'installFetchWrappers' ); } ); configureLocale.mockImplementation( () => { @@ -120,7 +124,7 @@ describe( 'setUpEditorEnvironment', () => { expect( callOrder ).toEqual( [ 'awaitGBKitGlobal', - 'initializeFetchInterceptor', + 'installFetchWrappers', 'configureLocale', 'injectEditorStyles', 'loadRemainingGlobals', @@ -131,6 +135,22 @@ describe( 'setUpEditorEnvironment', () => { ] ); } ); + it( 'installs the network log outside the relay', async () => { + // Order is behavior: the log has to record the request the editor + // made, not the relay's loopback rewrite of it. + const logging = () => {}; + const relay = () => {}; + createLoggingFetchWrapper.mockReturnValue( logging ); + createRelayFetchWrapper.mockReturnValue( relay ); + + await setUpEditorEnvironment(); + + expect( installFetchWrappers ).toHaveBeenCalledWith( [ + logging, + relay, + ] ); + } ); + it( 'loads plugins when plugins enabled', async () => { getGBKit.mockReturnValue( { plugins: true } ); diff --git a/src/utils/fetch-chain.js b/src/utils/fetch-chain.js new file mode 100644 index 000000000..87a95a457 --- /dev/null +++ b/src/utils/fetch-chain.js @@ -0,0 +1,63 @@ +/** + * Internal dependencies + */ +import { debug } from './logger'; + +/** + * Marks the wrapped `fetch` as ours. Registered globally so a second copy of + * this module — a re-injected bundle — recognizes the mark rather than wrapping + * an already-wrapped `fetch`. + */ +const WRAPPED = Symbol.for( 'gutenbergkit.fetchWrappers' ); + +/** + * A transform on `fetch`: given the fetch to delegate to, returns a fetch. + * + * The same shape as an `apiFetch` middleware, one layer down. A wrapper may + * change where the request goes, observe it, or decline to touch it and hand it + * straight to `next`. + * + * @typedef {(next: typeof fetch) => typeof fetch} FetchWrapper + */ + +/** + * Wraps the global `fetch` with a chain of wrappers, outermost first. + * + * `[ a, b ]` produces `a( b( fetch ) )`, so `a` sees the request first and the + * response last. Order is behavior, not preference: a wrapper that rewrites the + * request changes what every wrapper inside it observes, so the network log has + * to sit outside the relay to record the request the editor made rather than + * the loopback rewrite of it. + * + * Entries that are `null` are skipped, so a wrapper module can report "not + * applicable" — network logging switched off, no relay configured — by + * returning nothing rather than by installing a pass-through. + * + * Installing twice is a no-op. A retried boot or a re-injected bundle would + * otherwise wrap the wrapped `fetch`, logging every request to the native host + * twice and sending it through two relay layers. A page load resets this along + * with `window.fetch` itself. + * + * @param {Array} wrappers The chain, outermost first. + * @return {void} + */ +export function installFetchWrappers( wrappers ) { + const active = wrappers.filter( Boolean ); + + if ( ! active.length ) { + return; + } + + if ( window.fetch[ WRAPPED ] ) { + debug( 'Fetch wrappers are already installed' ); + return; + } + + const wrapped = active.reduceRight( + ( next, wrap ) => wrap( next ), + window.fetch.bind( window ) + ); + wrapped[ WRAPPED ] = true; + window.fetch = wrapped; + debug( `Installed ${ active.length } fetch wrapper(s)` ); +} diff --git a/src/utils/fetch-chain.test.js b/src/utils/fetch-chain.test.js new file mode 100644 index 000000000..e8389dbac --- /dev/null +++ b/src/utils/fetch-chain.test.js @@ -0,0 +1,99 @@ +/** + * External dependencies + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +/** + * Internal dependencies + */ +import { installFetchWrappers } from './fetch-chain'; + +vi.mock( './logger', () => ( { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +} ) ); + +describe( 'installFetchWrappers', () => { + let originalFetch; + let calls; + + beforeEach( () => { + originalFetch = window.fetch; + calls = []; + window.fetch = vi.fn( () => { + calls.push( 'fetch' ); + return Promise.resolve( 'response' ); + } ); + } ); + + afterEach( () => { + window.fetch = originalFetch; + } ); + + /** + * A wrapper that records when it runs, relative to the others. + * + * @param {string} name Recorded on the way in and out. + * @return {import('./fetch-chain').FetchWrapper} The wrapper. + */ + function recorder( name ) { + return ( next ) => async ( input, init ) => { + calls.push( `${ name }:in` ); + const response = await next( input, init ); + calls.push( `${ name }:out` ); + return response; + }; + } + + it( 'runs wrappers outermost first and unwinds in reverse', async () => { + installFetchWrappers( [ recorder( 'a' ), recorder( 'b' ) ] ); + + await window.fetch( 'https://example.com/' ); + + expect( calls ).toEqual( [ + 'a:in', + 'b:in', + 'fetch', + 'b:out', + 'a:out', + ] ); + } ); + + it( 'passes the request through the chain to the underlying fetch', async () => { + const rewrite = ( next ) => ( input, init ) => + next( `${ input }rewritten`, init ); + installFetchWrappers( [ recorder( 'a' ), rewrite ] ); + + await window.fetch( 'https://example.com/', { method: 'POST' } ); + + expect( window.fetch ).not.toBe( originalFetch ); + expect( calls ).toEqual( [ 'a:in', 'fetch', 'a:out' ] ); + } ); + + it( 'skips entries that reported themselves inapplicable', async () => { + installFetchWrappers( [ null, recorder( 'a' ), null ] ); + + await window.fetch( 'https://example.com/' ); + + expect( calls ).toEqual( [ 'a:in', 'fetch', 'a:out' ] ); + } ); + + it( 'installs once, however many times it is called', async () => { + installFetchWrappers( [ recorder( 'a' ) ] ); + installFetchWrappers( [ recorder( 'b' ) ] ); + + await window.fetch( 'https://example.com/' ); + + expect( calls ).toEqual( [ 'a:in', 'fetch', 'a:out' ] ); + } ); + + it( 'leaves fetch untouched when nothing applies', () => { + const before = window.fetch; + + installFetchWrappers( [ null, null ] ); + + expect( window.fetch ).toBe( before ); + } ); +} ); diff --git a/src/utils/fetch-interceptor.js b/src/utils/fetch-logging.js similarity index 64% rename from src/utils/fetch-interceptor.js rename to src/utils/fetch-logging.js index 5144cf187..53bc12463 100644 --- a/src/utils/fetch-interceptor.js +++ b/src/utils/fetch-logging.js @@ -5,130 +5,120 @@ import { onNetworkRequest, getGBKit } from './bridge'; import { debug } from './logger'; /** - * Initializes the global fetch interceptor. - * Wraps window.fetch to log all network requests and responses. - * Only overrides window.fetch if network logging is enabled in config. + * A `fetch` wrapper that reports every request and response to the native host, + * or `null` when network logging is not enabled. * - * @return {void} + * Reporting is fire-and-forget: the response is returned as soon as it arrives + * and its body is serialized afterwards from a clone, so logging never delays + * or locks the response the caller sees. + * + * @return {import('./fetch-chain').FetchWrapper|null} The wrapper. */ -export function initializeFetchInterceptor() { - // Don't initialize if already done - if ( window.__fetchInterceptorInitialized ) { - return; +export function createLoggingFetchWrapper() { + if ( ! getGBKit().enableNetworkLogging ) { + debug( 'Network logging disabled' ); + return null; } - const config = getGBKit(); - - // Only override window.fetch if network logging is enabled - if ( ! config.enableNetworkLogging ) { - debug( 'Network logging disabled, fetch interceptor not initialized' ); - return; - } - - const originalFetch = window.fetch; - - window.fetch = async function ( input, init ) { - const startTime = performance.now(); - const requestDetails = extractRequestDetails( input, init ); + return ( next ) => + async function ( input, init ) { + const startTime = performance.now(); + const requestDetails = extractRequestDetails( input, init ); - let requestBody = null; - let clonedRequest = null; + let requestBody = null; + let clonedRequest = null; - // Try to read request body if present - try { - if ( init?.body ) { - // Body is provided in init options - if ( typeof init.body === 'string' ) { - requestBody = init.body; - } else { - requestBody = serializeRequestBody( init.body ); + // Try to read request body if present + try { + if ( init?.body ) { + // Body is provided in init options + if ( typeof init.body === 'string' ) { + requestBody = init.body; + } else { + requestBody = serializeRequestBody( init.body ); + } + } else if ( input instanceof Request ) { + // Body might be in Request object - clone to read it + clonedRequest = input.clone(); + requestBody = await serializeBody( clonedRequest ); } - } else if ( input instanceof Request ) { - // Body might be in Request object - clone to read it - clonedRequest = input.clone(); - requestBody = await serializeBody( clonedRequest ); + } catch ( error ) { + debug( `Error reading request body: ${ error.message }` ); + requestBody = `[Error reading request body: ${ error.message }]`; } - } catch ( error ) { - debug( `Error reading request body: ${ error.message }` ); - requestBody = `[Error reading request body: ${ error.message }]`; - } - - let response; - let responseStatus; - let responseHeaders = {}; - try { - // Call original fetch - response = await originalFetch( input, init ); + let response; + let responseStatus; + let responseHeaders = {}; - // Capture response metadata immediately - const responseClone = response.clone(); - responseStatus = response.status; - const responseStatusText = - response.statusText || getStatusText( response.status ); - responseHeaders = serializeHeaders( response.headers ); - const duration = Math.round( performance.now() - startTime ); - - // Log asynchronously without blocking the response return - // This prevents Android WebView Response locking issues - serializeBody( responseClone ) - .then( ( body ) => { - onNetworkRequest( { - url: requestDetails.url, - method: requestDetails.method, - requestHeaders: serializeHeaders( - requestDetails.headers - ), - requestBody, - status: responseStatus, - statusText: responseStatusText, - responseHeaders, - responseBody: body, - duration, - } ); - } ) - .catch( ( error ) => { - // Log without body if reading fails - onNetworkRequest( { - url: requestDetails.url, - method: requestDetails.method, - requestHeaders: serializeHeaders( - requestDetails.headers - ), - requestBody, - status: responseStatus, - statusText: responseStatusText, - responseHeaders, - responseBody: `[Error reading body: ${ error.message }]`, - duration, + try { + response = await next( input, init ); + + // Capture response metadata immediately + const responseClone = response.clone(); + responseStatus = response.status; + const responseStatusText = + response.statusText || getStatusText( response.status ); + responseHeaders = serializeHeaders( response.headers ); + const duration = Math.round( performance.now() - startTime ); + + // Log asynchronously without blocking the response return + // This prevents Android WebView Response locking issues + serializeBody( responseClone ) + .then( ( body ) => { + onNetworkRequest( { + url: requestDetails.url, + method: requestDetails.method, + requestHeaders: serializeHeaders( + requestDetails.headers + ), + requestBody, + status: responseStatus, + statusText: responseStatusText, + responseHeaders, + responseBody: body, + duration, + } ); + } ) + .catch( ( error ) => { + // Log without body if reading fails + onNetworkRequest( { + url: requestDetails.url, + method: requestDetails.method, + requestHeaders: serializeHeaders( + requestDetails.headers + ), + requestBody, + status: responseStatus, + statusText: responseStatusText, + responseHeaders, + responseBody: `[Error reading body: ${ error.message }]`, + duration, + } ); } ); - } ); - - // Return response immediately - don't wait for body serialization - return response; - } catch ( error ) { - // Log failed request - const duration = Math.round( performance.now() - startTime ); - - onNetworkRequest( { - url: requestDetails.url, - method: requestDetails.method, - requestHeaders: serializeHeaders( requestDetails.headers ), - requestBody, - status: 0, - statusText: '', - responseHeaders: {}, - responseBody: `[Network error: ${ error.message }]`, - duration, - } ); - // Re-throw the error - throw error; - } - }; + // Return response immediately - don't wait for body serialization + return response; + } catch ( error ) { + // Log failed request + const duration = Math.round( performance.now() - startTime ); + + onNetworkRequest( { + url: requestDetails.url, + method: requestDetails.method, + requestHeaders: serializeHeaders( requestDetails.headers ), + requestBody, + status: 0, + statusText: '', + responseHeaders: {}, + responseBody: `[Network error: ${ error.message }]`, + duration, + } ); - window.__fetchInterceptorInitialized = true; - debug( 'Fetch interceptor initialized' ); + // Re-throw the error + throw error; + } + }; } /** diff --git a/src/utils/fetch-interceptor.test.js b/src/utils/fetch-logging.test.js similarity index 90% rename from src/utils/fetch-interceptor.test.js rename to src/utils/fetch-logging.test.js index b431e09e6..c794d8f95 100644 --- a/src/utils/fetch-interceptor.test.js +++ b/src/utils/fetch-logging.test.js @@ -6,23 +6,30 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; /** * Internal dependencies */ -import { initializeFetchInterceptor } from './fetch-interceptor'; +import { createLoggingFetchWrapper } from './fetch-logging'; import * as bridge from './bridge'; vi.mock( './bridge' ); // Helper to await the nested, non-blocking async logging that occurs within the -// fetch interceptor. +// wrapper. const waitForAsyncLogging = () => new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); -describe( 'initializeFetchInterceptor', () => { +/** + * Wraps the current `window.fetch` with the logging wrapper, standing in for + * what `installFetchWrappers` does at runtime. + * + * @return {void} + */ +const installLogging = () => { + window.fetch = createLoggingFetchWrapper()( window.fetch.bind( window ) ); +}; + +describe( 'createLoggingFetchWrapper', () => { let originalFetch; beforeEach( () => { - // Reset window state - delete window.__fetchInterceptorInitialized; - // Store original fetch originalFetch = global.fetch; @@ -61,20 +68,14 @@ describe( 'initializeFetchInterceptor', () => { vi.clearAllMocks(); } ); - it( 'should not initialize when network logging is disabled', () => { - // Store the current fetch (which is the mock from beforeEach) - const currentFetch = window.fetch; - + it( 'should produce no wrapper when network logging is disabled', () => { bridge.getGBKit.mockReturnValue( { enableNetworkLogging: false, } ); - initializeFetchInterceptor(); - - // Should not have initialized - expect( window.__fetchInterceptorInitialized ).toBeUndefined(); - // Fetch should not have been wrapped (should still be the same mock) - expect( window.fetch ).toBe( currentFetch ); + // `null` rather than a pass-through, so the chain leaves `fetch` + // untouched instead of installing a layer that does nothing. + expect( createLoggingFetchWrapper() ).toBeNull(); } ); it( 'should derive statusText from status code when empty (HTTP/2)', async () => { @@ -102,7 +103,7 @@ describe( 'initializeFetchInterceptor', () => { } ) ); - initializeFetchInterceptor(); + installLogging(); await window.fetch( 'https://example.com/api', { method: 'POST' } ); @@ -118,7 +119,7 @@ describe( 'initializeFetchInterceptor', () => { describe( 'request header capture', () => { it( 'should capture headers from plain object with string URL', async () => { - initializeFetchInterceptor(); + installLogging(); await window.fetch( 'https://example.com/api', { method: 'POST', @@ -144,7 +145,7 @@ describe( 'initializeFetchInterceptor', () => { } ); it( 'should capture headers from Request object', async () => { - initializeFetchInterceptor(); + installLogging(); const request = new Request( 'https://example.com/api', { method: 'GET', @@ -169,7 +170,7 @@ describe( 'initializeFetchInterceptor', () => { } ); it( 'should merge Request headers with init override', async () => { - initializeFetchInterceptor(); + installLogging(); const request = new Request( 'https://example.com/api', { headers: { @@ -199,7 +200,7 @@ describe( 'initializeFetchInterceptor', () => { } ); it( 'should handle empty headers', async () => { - initializeFetchInterceptor(); + installLogging(); await window.fetch( 'https://example.com/api' ); @@ -213,7 +214,7 @@ describe( 'initializeFetchInterceptor', () => { } ); it( 'should handle Headers instance', async () => { - initializeFetchInterceptor(); + installLogging(); const headers = new Headers(); headers.append( 'Authorization', 'Bearer token123' ); @@ -239,7 +240,7 @@ describe( 'initializeFetchInterceptor', () => { describe( 'request body serialization', () => { it( 'should serialize FormData with files correctly', async () => { - initializeFetchInterceptor(); + installLogging(); // Create a FormData with a file const formData = new FormData(); @@ -276,7 +277,7 @@ describe( 'initializeFetchInterceptor', () => { } ); it( 'should serialize Blob bodies correctly', async () => { - initializeFetchInterceptor(); + installLogging(); const blob = new Blob( [ 'binary content' ], { type: 'image/png', @@ -299,7 +300,7 @@ describe( 'initializeFetchInterceptor', () => { } ); it( 'should serialize File bodies correctly', async () => { - initializeFetchInterceptor(); + installLogging(); const file = new File( [ 'file content' ], 'document.pdf', { type: 'application/pdf', @@ -322,7 +323,7 @@ describe( 'initializeFetchInterceptor', () => { } ); it( 'should serialize ArrayBuffer bodies correctly', async () => { - initializeFetchInterceptor(); + installLogging(); const buffer = new ArrayBuffer( 1024 ); @@ -341,7 +342,7 @@ describe( 'initializeFetchInterceptor', () => { } ); it( 'should serialize URLSearchParams bodies correctly', async () => { - initializeFetchInterceptor(); + installLogging(); const params = new URLSearchParams(); params.append( 'key1', 'value1' ); @@ -362,7 +363,7 @@ describe( 'initializeFetchInterceptor', () => { } ); it( 'should handle string bodies correctly', async () => { - initializeFetchInterceptor(); + installLogging(); const jsonString = JSON.stringify( { test: 'data' } ); @@ -381,7 +382,7 @@ describe( 'initializeFetchInterceptor', () => { } ); it( 'should handle FormData with mixed content types', async () => { - initializeFetchInterceptor(); + installLogging(); const formData = new FormData(); formData.append( 'text', 'simple text value' ); @@ -446,7 +447,7 @@ describe( 'initializeFetchInterceptor', () => { } ); it( 'should truncate long string values in FormData', async () => { - initializeFetchInterceptor(); + installLogging(); const formData = new FormData(); const longString = 'a'.repeat( 100 ); @@ -479,7 +480,7 @@ describe( 'initializeFetchInterceptor', () => { } ); it( 'should handle ReadableStream bodies', async () => { - initializeFetchInterceptor(); + installLogging(); const stream = new ReadableStream( { start( controller ) { @@ -504,7 +505,7 @@ describe( 'initializeFetchInterceptor', () => { } ); it( 'should handle missing body gracefully', async () => { - initializeFetchInterceptor(); + installLogging(); await window.fetch( 'https://example.com/api' ); diff --git a/src/utils/fetch-relay.js b/src/utils/fetch-relay.js new file mode 100644 index 000000000..fd4aad4fd --- /dev/null +++ b/src/utils/fetch-relay.js @@ -0,0 +1,346 @@ +/** + * Internal dependencies + */ +import { getGBKit } from './bridge'; +import { debug, warn } from './logger'; + +/** Hostnames that name the loopback interface. */ +const LOOPBACK_HOSTNAMES = new Set( [ 'localhost', '127.0.0.1', '[::1]' ] ); + +/** + * Wraps `fetch` so requests for the site's REST API go through the native + * loopback relay. + * + * 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 direct REST request rejects. The + * native host answers by running a loopback server and advertising it as + * `GBKit.networkProxy`; it forwards each request to the site's REST API + * natively and responds with CORS headers the web view accepts. + * + * The relay wraps `fetch` rather than registering an api-fetch middleware or + * fetch handler, either of which would replace api-fetch's own request building + * and response parsing — the `data`-to-body serialization, the method override, + * every parsing rule — and leave us reimplementing a package we do not control. + * Below `fetch`, this layer only changes where the request goes. + * + * @param {typeof fetch} next The fetch to delegate to. + * @param {Object} config Relay configuration. + * @param {import('./bridge').NetworkProxy} config.networkProxy Relay connection details. + * @param {string} config.siteApiRoot The site's REST API root. + * @return {typeof fetch} The wrapped fetch. + */ +export function createRelayFetch( next, { networkProxy, siteApiRoot } ) { + const apiRoot = normalizedApiRoot( siteApiRoot ); + const relayRoot = networkProxy.baseURL; + const localServerPort = String( networkProxy.port ); + const relayAuthorization = `Bearer ${ networkProxy.token }`; + const apiRootHost = canonicalHost( apiRoot.hostname ); + + // `async` so that a throw becomes a rejection, as it would from the `fetch` + // this stands in for. `new Headers()` rejects a malformed name or value by + // throwing, and api-fetch's middleware chain calls its `next` synchronously + // — so a synchronous throw here escapes `apiFetch()` itself rather than + // arriving at the caller's `.catch()`. + return async ( input, init ) => { + // A `no-cors` request has an opaque response by definition, so there is + // no CORS rejection for the relay to solve — and relaying one breaks it: + // a browser attaches only CORS-safelisted headers to a no-cors request, + // so `Relay-Authorization` is dropped and the loopback server answers + // 407. Opaque responses hide that, which is the trap: the connectivity + // probe in `offline-indicator` would report the site reachable whenever + // the relay's own server was up, which is always. + if ( init?.mode === 'no-cors' ) { + return next( input, init ); + } + + const target = requestURL( input ); + const upstreamPath = + target && ! addressesLocalServer( target, localServerPort ) + ? relayUpstreamPath( target, apiRoot, apiRootHost ) + : null; + + // Not a site REST request: media uploads to the loopback server, + // `blob:`/`data:`/`gbk-media-file:` reads, a third party's own API. + // Those keep the network path they had before a relay existed. + if ( upstreamPath === null ) { + return next( input, init ); + } + + const headers = new Headers( init?.headers ); + // The site credential is injected natively, so it never travels over + // loopback. The relay's own per-session token rides in + // `Relay-Authorization` because `fetch()` silently strips `Proxy-*`. + headers.delete( 'Authorization' ); + headers.set( 'Relay-Authorization', relayAuthorization ); + + return next( relayRoot + upstreamPath, { + ...init, + headers, + // The relay answers `Access-Control-Allow-Origin: *`, which a + // browser refuses to pair with a credentialed request — and + // api-fetch defaults `credentials` to `include`. Cookies are not + // how the loopback server authenticates anyway; the bearer token is. + credentials: 'omit', + } ); + }; +} + +/** + * The relay wrapper for the host's configuration, or `null` when the host is + * not running a relay. + * + * @return {import('./fetch-chain').FetchWrapper|null} The wrapper. + */ +export function createRelayFetchWrapper() { + const { networkProxy, siteApiRoot } = getGBKit(); + + if ( ! networkProxy || ! siteApiRoot ) { + return null; + } + + // The wrapper parses the root when it is installed, inside the editor's + // boot sequence, so a root that is not a URL is caught here — costing the + // editor its relay rather than the boot. + try { + new URL( siteApiRoot ); + } catch { + warn( + `Not relaying site REST requests: the site API root ${ siteApiRoot } is not a URL` + ); + return null; + } + + debug( `Relaying site REST requests through port ${ networkProxy.port }` ); + return ( next ) => createRelayFetch( next, { networkProxy, siteApiRoot } ); +} + +/** + * The site API root as a `URL`, slash-terminated, with the route value of a + * plain-permalink root decoded. + * + * Slash-terminated so a sibling cannot match the root as a prefix + * (`https://site/wp-json` would otherwise match `https://site/wp-jsonx/…`), + * and parsed so both sides of every comparison normalize the same way — + * default ports collapsed, host lowercased. + * + * A plain-permalink root carries its route in the query, and WordPress + * advertises it percent-encoded — `index.php?rest_route=%2F` — because it + * builds the URL through `add_query_arg`. The separators are decoded before + * the slash is added so it lands inside the route value: appended after + * `%2F`, it would make a root that no path can extend, since WordPress reads + * `rest_route=%2F/wp/v2/posts` as the route `//wp/v2/posts`. `RestRelay` + * normalizes the same way, so both sides agree on what the root is. + * + * @param {string} siteApiRoot The site's REST API root as configured. + * @return {URL} The normalized root. + */ +function normalizedApiRoot( siteApiRoot ) { + const separator = siteApiRoot.indexOf( '?' ); + const root = + separator === -1 + ? siteApiRoot + : siteApiRoot.slice( 0, separator ) + + decodeSlashes( siteApiRoot.slice( separator ) ); + return new URL( root.endsWith( '/' ) ? root : `${ root }/` ); +} + +/** + * A URL component with its percent-encoded slashes decoded, and nothing else. + * + * Only the separators are decoded so any other encoded byte reaches the site + * exactly as it was sent, rather than decoded once here and once more by PHP. + * + * @param {string} component A URL component. + * @return {string} The component with `%2F` spelled `/`. + */ +function decodeSlashes( component ) { + return component.replace( /%2f/gi, '/' ); +} + +/** + * A hostname reduced to the form its aliases share: every loopback spelling + * collapses to one, and a `www.` prefix is dropped. + * + * The native relay's redirect guard reads redirect targets through the same + * spellings (`RestRelay.RedirectGuard.canonicalHost`), and the two must stay + * the same: a spelling relayed here and refused there fails every request on + * a site whose canonical redirect uses it. + * + * @param {string} hostname A URL hostname. + * @return {string} The canonical form. + */ +function canonicalHost( hostname ) { + if ( LOOPBACK_HOSTNAMES.has( hostname ) ) { + return 'localhost'; + } + return hostname.replace( /^www\./, '' ); +} + +/** + * The URL a `fetch` call addresses, or `null` when it cannot be determined. + * + * A `Request` object returns `null` rather than being relayed: rewriting one + * means rebuilding it, and nothing in the editor's REST path constructs one — + * api-fetch always calls `fetch( url, init )`. Such a request keeps the direct + * path it had before a relay existed. + * + * @param {string|URL|Request} input The first argument to `fetch`. + * @return {URL|null} The target URL. + */ +function requestURL( input ) { + if ( typeof input !== 'string' && ! ( input instanceof URL ) ) { + return null; + } + try { + // Resolved against the document so a relative URL becomes a `file://` + // (or dev-server) URL, which cannot match the API root and so is never + // mistaken for a site request. + return new URL( input, document.baseURI ); + } catch { + return null; + } +} + +/** + * Whether a target addresses the native local server, in any spelling of + * loopback. + * + * The relay and the media upload route share one server, and they are addressed + * by different names: the relay route by address, the upload route by hostname + * (`localhost` is what Android hosts permit cleartext to). Neither is a site + * request, so both keep the path they had before a relay existed — the port + * identifies the server whichever name reached it. + * + * While the relay accepts only the configured site, `relayUpstreamPath`'s own + * host and port comparison excludes the server anyway, since its port is + * OS-assigned and cannot be the site's. This is kept as the exemption that + * has to survive the relay accepting other origins: without it an upload + * would be relayed to the server it was already addressed to, and a request + * the relay had already rewritten would be relayed again. + * + * @param {URL} target The request's target. + * @param {string} port The local server's port. + * @return {boolean} Whether the target is the local server. + */ +function addressesLocalServer( target, port ) { + return target.port === port && LOOPBACK_HOSTNAMES.has( target.hostname ); +} + +/** + * The upstream path for a relayed request: the part of its target below the + * site API root, without a leading slash. `null` for anything else. + * + * **Host aliases are tolerated; other hosts and path differences are not.** + * WordPress builds `Link` headers and `_links` hrefs from `home_url()`, which + * need not spell the host the app was configured with — `www.` versus bare, or + * wp-env's `localhost` versus the `127.0.0.1` its runtime writes into + * `WP_SITEURL` (the e2e fixtures work around the same thing by matching uploads + * on path rather than hostname, see `e2e/wp-env-fixtures.js`). Only the scheme + * may differ beyond that — an `http` `siteurl` behind a TLS-terminating proxy. + * The port has to match: another port on the same host is another server, and + * answering its request with the site's response is a different bug from the + * one this tolerance exists to fix. A *path* difference is likewise a different + * resource: in a subdirectory multisite `https://site/a/wp-json/` and + * `https://site/b/wp-json/` are separate sites. + * + * Anything else keeps the direct path it had before a relay existed, because + * rewriting it would send a third party's request to the user's own site with + * the site credential attached. The cost is an alias this cannot recognize — + * a site reached by LAN IP whose `home_url()` says `localhost`, a mapped + * domain, a migration — where a paginated `Link` target goes direct and fails + * under Lockdown Mode. Growing the list of spellings cannot close that: the + * fix is for the relay to canonicalize the URLs the site emits, since it knows + * the response came from the configured root and this layer can only guess. + * + * @param {URL} target The request's target. + * @param {URL} apiRoot The site's REST API root, normalized and slash-terminated. + * @param {string} apiRootHost `apiRoot`'s hostname in canonical form. + * @return {string|null} The upstream path, or `null` when it is not a site request. + */ +function relayUpstreamPath( target, apiRoot, apiRootHost ) { + if ( + canonicalHost( target.hostname ) !== apiRootHost || + target.port !== apiRoot.port + ) { + return null; + } + + if ( apiRoot.search ) { + return queryRoutedPath( target, apiRoot ); + } + + const aliased = new URL( target ); + // Only the scheme and the host spelling are left to reconcile; the port + // matched above, and assigning `hostname` leaves it in place. + aliased.protocol = apiRoot.protocol; + aliased.hostname = apiRoot.hostname; + + if ( ! aliased.href.startsWith( apiRoot.href ) ) { + return null; + } + return aliased.href.slice( apiRoot.href.length ); +} + +/** + * The upstream path for a request to a root that carries its route in the + * query — plain permalinks, `https://site/index.php?rest_route=/`. + * + * A prefix match on the href cannot decide this shape: the root spells the + * route `/`, and every request that reaches here spells it `%2F`. api-fetch's + * locale middleware rebuilds the query through `addQueryArgs`, and WordPress + * builds pagination `Link` URLs through `add_query_arg`; both percent-encode + * every value. So the route is read out of the query by name, its separators + * decoded, and the other parameters are carried verbatim after a `?` — the + * relay merges them into the root's query, as `createRootURLMiddleware` did. + * + * The host and port were matched by the caller. The scheme is not compared, + * for the same reason it is not for a pretty-permalink root: an `http` + * `siteurl` behind a TLS-terminating proxy. + * + * @param {URL} target The request's target. + * @param {URL} apiRoot The site's REST API root, whose query names the route. + * @return {string|null} The upstream path, or `null` when it is not a site request. + */ +function queryRoutedPath( target, apiRoot ) { + if ( target.pathname !== apiRoot.pathname ) { + return null; + } + + // The root's query is the single `rest_route=/` pair `get_rest_url()` + // emits; on a namespaced site the value is longer (`/sites/1/`), but it + // is still the one parameter every request continues. + const rootRoute = splitQueryPair( apiRoot.search.slice( 1 ) ); + const pairs = target.search.slice( 1 ).split( '&' ); + const index = pairs.findIndex( + ( pair ) => splitQueryPair( pair ).name === rootRoute.name + ); + if ( index === -1 ) { + return null; + } + + const route = decodeSlashes( splitQueryPair( pairs[ index ] ).value ); + if ( ! route.startsWith( rootRoute.value ) ) { + return null; + } + + const path = route.slice( rootRoute.value.length ); + const query = pairs.filter( ( _, i ) => i !== index ).join( '&' ); + return query ? `${ path }?${ query }` : path; +} + +/** + * A `name=value` query pair split at its first `=`, verbatim. + * + * @param {string} pair One pair of a query string. + * @return {{name: string, value: string}} The name and the value, the latter `''` if absent. + */ +function splitQueryPair( pair ) { + const separator = pair.indexOf( '=' ); + return separator === -1 + ? { name: pair, value: '' } + : { + name: pair.slice( 0, separator ), + value: pair.slice( separator + 1 ), + }; +} diff --git a/src/utils/fetch-relay.test.js b/src/utils/fetch-relay.test.js new file mode 100644 index 000000000..3edf67d15 --- /dev/null +++ b/src/utils/fetch-relay.test.js @@ -0,0 +1,359 @@ +/** + * External dependencies + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +/** + * Internal dependencies + */ +import { createRelayFetch, createRelayFetchWrapper } from './fetch-relay'; +import { getGBKit } from './bridge'; +import { warn } from './logger'; + +vi.mock( './bridge', () => ( { + getGBKit: vi.fn(), +} ) ); + +vi.mock( './logger', () => ( { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +} ) ); + +const RELAY_ROOT = 'http://127.0.0.1:5555/proxy/'; +const NETWORK_PROXY = { + port: 5555, + token: 'relay-token', + baseURL: RELAY_ROOT, +}; + +describe( 'createRelayFetch', () => { + let next; + + beforeEach( () => { + next = vi.fn( () => Promise.resolve( 'response' ) ); + } ); + + /** + * The wrapper under test, over the shared `next` spy. + * + * @param {string} siteApiRoot The site's REST API root. + * @return {typeof fetch} The wrapped fetch. + */ + function relayFetch( siteApiRoot = 'https://example.com/wp-json/' ) { + return createRelayFetch( next, { + networkProxy: NETWORK_PROXY, + siteApiRoot, + } ); + } + + /** The URL `next` was called with. */ + function calledURL() { + return next.mock.calls[ 0 ][ 0 ]; + } + + describe( 'requests it relays', () => { + it( 'rewrites a site API request onto the relay route', async () => { + await relayFetch()( 'https://example.com/wp-json/wp/v2/posts?x=1' ); + + expect( calledURL() ).toBe( `${ RELAY_ROOT }wp/v2/posts?x=1` ); + } ); + + it( 'swaps the site credential for the relay token', async () => { + await relayFetch()( 'https://example.com/wp-json/wp/v2/posts', { + headers: { Authorization: 'Bearer site-token' }, + } ); + + const headers = new Headers( next.mock.calls[ 0 ][ 1 ].headers ); + expect( headers.get( 'Relay-Authorization' ) ).toBe( + 'Bearer relay-token' + ); + expect( headers.get( 'Authorization' ) ).toBeNull(); + } ); + + it( 'tolerates a host alias', async () => { + // The same host under another spelling: `www.` versus bare. + await relayFetch()( + 'https://www.example.com/wp-json/wp/v2/posts?page=2' + ); + + expect( calledURL() ).toBe( `${ RELAY_ROOT }wp/v2/posts?page=2` ); + } ); + + it( 'tolerates a loopback host alias', async () => { + // wp-env: the site is configured as `localhost`, and WordPress + // writes `127.0.0.1` into the URLs it emits. + await relayFetch( 'http://localhost:8888/wp-json/' )( + 'http://127.0.0.1:8888/wp-json/wp/v2/posts' + ); + + expect( calledURL() ).toBe( `${ RELAY_ROOT }wp/v2/posts` ); + } ); + + it( 'tolerates a scheme and default port that differ from the root', async () => { + await relayFetch()( 'http://example.com:80/wp-json/wp/v2/posts' ); + + expect( calledURL() ).toBe( `${ RELAY_ROOT }wp/v2/posts` ); + } ); + + it( 'accepts a root configured without a trailing slash', async () => { + await relayFetch( 'https://example.com/wp-json' )( + 'https://example.com/wp-json/wp/v2/posts' + ); + + expect( calledURL() ).toBe( `${ RELAY_ROOT }wp/v2/posts` ); + } ); + + describe( 'a root that carries its route in the query', () => { + // Plain permalinks. WordPress advertises the root through + // `add_query_arg`, which percent-encodes the route. + const PLAIN_ROOT = 'https://example.com/index.php?rest_route=%2F'; + + it( 'reads the route out of the query and carries the rest', async () => { + await relayFetch( PLAIN_ROOT )( + 'https://example.com/index.php?rest_route=/wp/v2/posts&x=1' + ); + + expect( calledURL() ).toBe( `${ RELAY_ROOT }wp/v2/posts?x=1` ); + } ); + + it( 'recognizes the route with its separators encoded', async () => { + // The spelling every request actually arrives in: api-fetch's + // locale middleware rebuilds the query through `addQueryArgs`, + // and WordPress builds `Link` through `add_query_arg`, both of + // which percent-encode the value. + await relayFetch( PLAIN_ROOT )( + 'https://example.com/index.php?rest_route=%2Fwp%2Fv2%2Fposts&page=2&_locale=user' + ); + + expect( calledURL() ).toBe( + `${ RELAY_ROOT }wp/v2/posts?page=2&_locale=user` + ); + } ); + + it( 'accepts a root whose route is spelled with a literal slash', async () => { + await relayFetch( 'https://example.com/?rest_route=/' )( + 'https://example.com/?rest_route=%2Fwp%2Fv2%2Fposts' + ); + + expect( calledURL() ).toBe( `${ RELAY_ROOT }wp/v2/posts` ); + } ); + + it( 'relays the root itself', async () => { + await relayFetch( PLAIN_ROOT )( + 'https://example.com/index.php?rest_route=%2F' + ); + + expect( calledURL() ).toBe( RELAY_ROOT ); + } ); + + it( 'continues a route that names a site', async () => { + // A namespaced site's root carries more than `/` in the route, + // and every request continues it. + await relayFetch( + 'https://public-api.example/wp-json/?rest_route=/sites/1/' + )( + 'https://public-api.example/wp-json/?rest_route=%2Fsites%2F1%2Fwp%2Fv2%2Fposts&_locale=user' + ); + + expect( calledURL() ).toBe( + `${ RELAY_ROOT }wp/v2/posts?_locale=user` + ); + } ); + + it( 'leaves the same page alone without the route', async () => { + const url = 'https://example.com/index.php?p=1'; + await relayFetch( PLAIN_ROOT )( url ); + + expect( next ).toHaveBeenCalledWith( url, undefined ); + } ); + + it( 'leaves a route outside the root alone', async () => { + const url = + 'https://public-api.example/wp-json/?rest_route=%2Fsites%2F2%2Fwp%2Fv2%2Fposts'; + await relayFetch( + 'https://public-api.example/wp-json/?rest_route=/sites/1/' + )( url ); + + expect( next ).toHaveBeenCalledWith( url, undefined ); + } ); + } ); + } ); + + describe( 'requests it leaves alone', () => { + /** + * Asserts the wrapper passed the call through untouched. + * + * @param {any} input The `fetch` input. + */ + async function expectPassthrough( input ) { + await relayFetch()( input ); + expect( next ).toHaveBeenCalledWith( input, undefined ); + } + + it( 'a request to the relay server itself', async () => { + // The upload route shares the relay's server and is addressed as + // `localhost` rather than by address, so the server is recognized + // by port under any loopback spelling. Today the site match's own + // port comparison would exclude these too; the exemption is what + // has to hold once the relay accepts other origins. + await expectPassthrough( + 'http://localhost:5555/upload?_embed=wp:featuredmedia' + ); + await expectPassthrough( + 'http://127.0.0.1:5555/upload?_embed=wp:featuredmedia' + ); + await expectPassthrough( `${ RELAY_ROOT }wp/v2/posts` ); + } ); + + it( 'a no-cors request, whose response is opaque anyway', async () => { + // The connectivity probe. Relayed, it would lose the bearer token + // (a no-cors request carries only safelisted headers), take a 407, + // and still resolve — reporting the site reachable because the + // loopback server answered. + const init = { method: 'HEAD', mode: 'no-cors' }; + await relayFetch()( 'https://example.com/wp-json/', init ); + + expect( next ).toHaveBeenCalledWith( + 'https://example.com/wp-json/', + init + ); + } ); + + it( 'a blob, data, or custom-scheme read', async () => { + await expectPassthrough( 'blob:https://example.com/abc-123' ); + await expectPassthrough( 'data:text/plain,hello' ); + await expectPassthrough( 'gbk-media-file:///photo.jpg' ); + } ); + + it( 'another origin entirely', async () => { + await expectPassthrough( + 'https://public-api.wordpress.com/wpcom/v2/jetpack-ai-query' + ); + } ); + + it( 'another service on the site host', async () => { + // A different port is a different server, however its paths are + // shaped. Relaying it would answer one service's request with + // another's response. + await expectPassthrough( + 'https://example.com:8443/wp-json/wp/v2/posts' + ); + await relayFetch( 'http://localhost:8888/wp-json/' )( + 'http://127.0.0.1:3000/wp-json/wp/v2/posts' + ); + expect( next ).toHaveBeenCalledWith( + 'http://127.0.0.1:3000/wp-json/wp/v2/posts', + undefined + ); + } ); + + it( 'a host alias the canonical form does not cover', async () => { + // A documented limitation, not a decision: a site reached by LAN IP + // emits `localhost` URLs, which are not recognized as the same host, + // so paginated `Link` targets take the direct path — and fail under + // Lockdown Mode. Provenance belongs at the relay, which knows the + // response came from the configured site; see `relayUpstreamPath`. + await relayFetch( 'http://192.168.1.50:8888/wp-json/' )( + 'http://localhost:8888/wp-json/wp/v2/posts?page=2' + ); + + expect( next ).toHaveBeenCalledWith( + 'http://localhost:8888/wp-json/wp/v2/posts?page=2', + undefined + ); + } ); + + it( 'another WordPress site whose path matches the root', async () => { + // A different host is a different server, however its paths are + // shaped. Rewriting one onto the configured site would send its + // request to the user's own site with the site credential. + await expectPassthrough( + 'https://other-wp.example/wp-json/wp/v2/posts' + ); + } ); + + it( 'a sibling of the API root', async () => { + // `https://site/wp-json` must not match `https://site/wp-jsonx/…`. + await expectPassthrough( 'https://example.com/wp-jsonx/secrets' ); + } ); + + it( 'a different site in a subdirectory multisite', async () => { + // A path difference is a different site, not an alias. Matching + // across them would route site B through site A's API root. + await relayFetch( 'https://example.com/a/wp-json/' )( + 'https://example.com/b/wp-json/wp/v2/posts' + ); + + expect( calledURL() ).toBe( + 'https://example.com/b/wp-json/wp/v2/posts' + ); + } ); + + it( 'a Request object, which would have to be rebuilt', async () => { + const request = new Request( + 'https://example.com/wp-json/wp/v2/posts' + ); + await relayFetch()( request ); + + expect( next ).toHaveBeenCalledWith( request, undefined ); + } ); + } ); + + describe( 'the wrapper', () => { + it( 'declines a site API root that is not a URL', () => { + // The wrapper parses the root at install time, inside the editor's + // boot sequence, where a throw would replace the editor with the + // load-error page. + getGBKit.mockReturnValue( { + networkProxy: NETWORK_PROXY, + siteApiRoot: 'wp-json/', + } ); + + expect( createRelayFetchWrapper() ).toBeNull(); + expect( warn ).toHaveBeenCalledWith( + expect.stringContaining( 'wp-json/' ) + ); + } ); + + it( 'declines when no relay is advertised', () => { + getGBKit.mockReturnValue( { + siteApiRoot: 'https://example.com/wp-json/', + } ); + + expect( createRelayFetchWrapper() ).toBeNull(); + } ); + + it( 'wraps when a relay is advertised', async () => { + getGBKit.mockReturnValue( { + networkProxy: NETWORK_PROXY, + siteApiRoot: 'https://example.com/wp-json/', + } ); + + await createRelayFetchWrapper()( next )( + 'https://example.com/wp-json/wp/v2/posts' + ); + + expect( calledURL() ).toBe( `${ RELAY_ROOT }wp/v2/posts` ); + } ); + } ); + + describe( 'failures', () => { + it( 'rejects rather than throwing when a header is malformed', async () => { + // api-fetch's middleware calls its `next` synchronously, so a throw + // here would escape `apiFetch()` itself instead of reaching the + // caller's `.catch()`. + let pending; + expect( () => { + pending = relayFetch()( + 'https://example.com/wp-json/wp/v2/posts', + { headers: { 'Bad Header': 'value' } } + ); + } ).not.toThrow(); + + await expect( pending ).rejects.toThrow( TypeError ); + expect( next ).not.toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/wp-env/mu-plugins/gutenbergkit-cors.php b/wp-env/mu-plugins/gutenbergkit-cors.php index 666b745f2..2059fae5d 100644 --- a/wp-env/mu-plugins/gutenbergkit-cors.php +++ b/wp-env/mu-plugins/gutenbergkit-cors.php @@ -33,9 +33,33 @@ function gutenbergkit_cors_send_origin_headers( $origin ) { } header( 'Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS' ); - header( 'Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce' ); + // api-fetch turns every PUT/PATCH/DELETE into a POST carrying + // `X-HTTP-Method-Override`, which is not CORS-safelisted, so the browser + // announces it in the preflight and blocks the request without it here. + header( 'Access-Control-Allow-Headers: Authorization, Content-Type, X-HTTP-Method-Override, X-WP-Nonce' ); } +/** + * Exposes the `Allow` header to cross-origin callers. + * + * `canUser` issues `OPTIONS /wp/v2/{resource}` and reads `Allow` to decide + * whether the user may create a page, update settings, upload media, or edit + * global styles. Core sets that header from the matched route's permission + * callbacks, but as of 6.8.3 exposes only `X-WP-Total`, `X-WP-TotalPages` and + * `Link` — so cross-origin the header is on the wire and invisible to + * JavaScript, and every capability reads as false with no error surfaced. + * + * Added through core's filter rather than by sending the header directly: + * core sends its own `Access-Control-Expose-Headers`, so a second `header()` + * call would replace that value rather than extend it. + * + * @see https://github.com/WordPress/wordpress-develop/blob/6.8.3/src/wp-includes/rest-api/class-wp-rest-server.php#L395-L408 + */ +add_filter( 'rest_exposed_cors_headers', function ( $headers ) { + $headers[] = 'Allow'; + return $headers; +} ); + add_action( 'rest_api_init', function () { // Remove default WordPress CORS headers to avoid duplicates. remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' ); @@ -46,12 +70,32 @@ function gutenbergkit_cors_send_origin_headers( $origin ) { }); }, 15 ); -// Handle preflight OPTIONS requests early. +// Answer CORS preflights early, before WordPress routes the request. +// +// This runs site-wide rather than on `rest_api_init` because the editor also +// sends an authenticated request to `admin-ajax.php`, which core does not +// answer preflights for. +// +// Only a genuine preflight is answered here. A preflight always carries +// `Access-Control-Request-Method`; an `OPTIONS` a client sent on its own behalf +// never does, and core answers those itself — `rest_handle_options_request` +// builds the response and `rest_send_allow_header` sets `Allow` from the +// matched route's permission callbacks. Short-circuiting both made every +// `canUser` check report that the user could do nothing, with no error +// surfaced, because the request "succeeded". +// +// @see https://github.com/WordPress/wordpress-develop/blob/6.8.3/src/wp-includes/rest-api.php#L252-L256 add_action( 'init', function () { - if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'OPTIONS' === $_SERVER['REQUEST_METHOD'] ) { - gutenbergkit_cors_send_origin_headers( get_http_origin() ); - header( 'Access-Control-Max-Age: 86400' ); - status_header( 204 ); - exit; + if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'OPTIONS' !== $_SERVER['REQUEST_METHOD'] ) { + return; } + + if ( ! isset( $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'] ) ) { + return; + } + + gutenbergkit_cors_send_origin_headers( get_http_origin() ); + header( 'Access-Control-Max-Age: 86400' ); + status_header( 204 ); + exit; });