diff --git a/ios/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift index 5abec3156..282245d4d 100644 --- a/ios/Demo-iOS/Sources/ConfigurationItem.swift +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -68,27 +68,18 @@ 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? { - if let path = ProcessInfo.processInfo.environment["WP_ENV_CREDENTIALS_PATH"], - let data = FileManager.default.contents(atPath: path), - let credentials = try? JSONDecoder().decode(LocalWordPressCredentials.self, from: data) { - return credentials + guard let 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 } - - return .bakedIn + return credentials } - - /// Debug automation: physical devices can't read the wp-env credentials - /// file from the Mac's filesystem, so fall back to compiled-in wp-env - /// credentials that point at the Mac's LAN IP. These are throwaway local - /// dev credentials generated by `make wp-env-start`. - static let bakedIn = LocalWordPressCredentials( - siteUrl: "http://192.168.0.57:8888", - siteApiRoot: "http://192.168.0.57:8888/wp-json/", - username: "admin", - appPassword: "lsei gHof sVsj ITvL pMuC qB5U", - authHeader: "Basic YWRtaW46bHNlaSBnSG9mIHNWc2ogSVR2TCBwTXVDIHFCNVU=" - ) } // MARK: - Account Helpers diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index 5df6f83ac..4c600a3a4 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -1,6 +1,5 @@ import SwiftUI import OSLog -import WebKit import GutenbergKit final class Navigation: ObservableObject { @@ -45,12 +44,13 @@ struct GutenbergApp: App { EditorLogger.shared = OSLogEditorLogger() EditorLogger.logLevel = .debug - // Keep the device awake while the demo app is foregrounded — the - // debugging workflows here (probes, Web Inspector, devicectl console) - // break when the device auto-locks. - UIApplication.shared.isIdleTimerDisabled = true - - OriginProbeRunner.runIfRequested() + // Opt-in: keep the device awake while the demo app is foregrounded. + // The debugging workflows here (the upload probe, 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 { @@ -79,137 +79,6 @@ struct GutenbergApp: App { } } -/// Serves a trivial HTML page for the custom-scheme origin probe variant. -final class ProbeSchemeHandler: NSObject, WKURLSchemeHandler { - func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { - guard let url = urlSchemeTask.request.url else { return } - let html = Data("probe".utf8) - let response = URLResponse(url: url, mimeType: "text/html", expectedContentLength: html.count, textEncodingName: "utf-8") - urlSchemeTask.didReceive(response) - urlSchemeTask.didReceive(html) - urlSchemeTask.didFinish() - } - - func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {} -} - -/// Debug automation: probes network capabilities from bare web views with -/// different page origins to characterize Lockdown Mode restrictions. -/// Enabled with GUTENBERG_ORIGIN_PROBE=1; results print to stdout. -@MainActor -final class OriginProbeRunner: NSObject, WKNavigationDelegate { - static let shared = OriginProbeRunner() - - /// The CORS-instrumented echo server run on the Mac during investigation. - private let echoBase = "http://192.168.0.57:8890" - - private var webViews: [WKWebView] = [] - private var loadContinuations: [ObjectIdentifier: CheckedContinuation] = [:] - - static func runIfRequested() { - guard ProcessInfo.processInfo.environment["GUTENBERG_ORIGIN_PROBE"] == "1" else { return } - Task { @MainActor in - await shared.run() - } - } - - private enum LoadMode { - case file - case htmlString(base: URL?) - case customScheme - } - - private func run() async { - print("ORIGIN_PROBE_START") - await runVariant(name: "custom_scheme", universalPrefs: false, load: .customScheme) - await runVariant(name: "file_with_universal_prefs", universalPrefs: true, load: .file) - print("ORIGIN_PROBE_DONE") - webViews.removeAll() - } - - private func runVariant(name: String, universalPrefs: Bool, load: LoadMode) async { - let config = WKWebViewConfiguration() - if universalPrefs { - config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") - config.setValue(true, forKey: "allowUniversalAccessFromFileURLs") - } - if case .customScheme = load { - config.setURLSchemeHandler(ProbeSchemeHandler(), forURLScheme: "gbk-probe") - } - - let webView = WKWebView(frame: .zero, configuration: config) - webView.isInspectable = true - webView.navigationDelegate = self - webViews.append(webView) - - let lockdown = config.defaultWebpagePreferences.isLockdownModeEnabled - print("ORIGIN_PROBE_VARIANT name=\(name) lockdown=\(lockdown)") - - await withCheckedContinuation { (continuation: CheckedContinuation) in - loadContinuations[ObjectIdentifier(webView)] = continuation - switch load { - case .file: - let dir = FileManager.default.temporaryDirectory.appendingPathComponent("origin-probe", isDirectory: true) - let file = dir.appendingPathComponent("probe.html") - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try? "probe".write(to: file, atomically: true, encoding: .utf8) - webView.loadFileURL(file, allowingReadAccessTo: dir) - case .htmlString(let base): - webView.loadHTMLString("probe", baseURL: base) - case .customScheme: - webView.load(URLRequest(url: URL(string: "gbk-probe://probe-host/probe.html")!)) - } - } - - do { - let result = try await webView.callAsyncJavaScript( - Self.probeJS, - arguments: ["echoBase": echoBase], - contentWorld: .page - ) - print("ORIGIN_PROBE_RESULT name=\(name) \(result ?? "nil")") - } catch { - print("ORIGIN_PROBE_ERROR name=\(name) \(error)") - } - } - - nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - let id = ObjectIdentifier(webView) - Task { @MainActor in - loadContinuations.removeValue(forKey: id)?.resume() - } - } - - nonisolated func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - let id = ObjectIdentifier(webView) - Task { @MainActor in - loadContinuations.removeValue(forKey: id)?.resume() - } - } - - private static let probeJS = """ - const out = {}; - const S = e => (e && e.name ? e.name + ': ' + e.message : String(e)); - const T = () => AbortSignal.timeout(8000); - const j = async (p) => { try { const r = await p; return r.status; } catch (e) { return 'REJECT ' + S(e); } }; - out.origin = String(location.origin); - out.href = location.href.split('?')[0].slice(0, 90); - out.star_get = await j(fetch(echoBase + '/star/get', {signal: T()})); - out.star_post_text = await j(fetch(echoBase + '/star/post', {method: 'POST', body: 'x', signal: T()})); - const fd = new FormData(); - fd.append('probe', 'x'); - out.star_post_formdata = await j(fetch(echoBase + '/star/fd', {method: 'POST', body: fd, signal: T()})); - out.star_post_preflight = await j(fetch(echoBase + '/star/pf', {method: 'POST', headers: {'X-Probe': '1'}, body: 'x', signal: T()})); - out.star_put = await j(fetch(echoBase + '/star/put', {method: 'PUT', body: 'x', signal: T()})); - out.echo_post_text = await j(fetch(echoBase + '/echo/post', {method: 'POST', body: 'x', signal: T()})); - try { const r = await fetch(echoBase + '/star/nc', {method: 'POST', mode: 'no-cors', body: 'x', signal: T()}); out.nocors_post = 'ok type=' + r.type + ' status=' + r.status; } catch (e) { out.nocors_post = 'REJECT ' + S(e); } - out.https_get = await j(fetch('https://public-api.wordpress.com/rest/v1.1/sites/en.blog.wordpress.com', {signal: T()})); - out.https_post = await j(fetch('https://public-api.wordpress.com/rest/v1.1/sites/en.blog.wordpress.com/posts/new', {method: 'POST', body: 'x', signal: T()})); - try { const b = new Blob(['xy']); out.blob_arrayBuffer = 'ok len=' + (await b.arrayBuffer()).byteLength; } catch (e) { out.blob_arrayBuffer = 'FAIL ' + S(e); } - return JSON.stringify(out, null, 1); - """ -} - struct OSLogEditorLogger: GutenbergKit.EditorLogging { private let logger: Logger diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 2edb3cfae..3cda9a92d 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -241,9 +241,6 @@ private struct _EditorView: UIViewControllerRepresentable { out.typeof_apiFetch = typeof (window.wp && window.wp.apiFetch); out.gbk_nativeUploadPort = !!(window.GBKit && window.GBKit.nativeUploadPort); out.gbk_networkProxy = !!(window.GBKit && window.GBKit.networkProxy); - const ECHO = 'http://192.168.0.57:8890'; - try { const r = await fetch(ECHO + '/star/get', {signal: T()}); out.echo_star_get = r.status; } catch (e) { out.echo_star_get = 'REJECT ' + S(e); } - try { const fdE = new FormData(); fdE.append('probe', 'x'); const r = await fetch(ECHO + '/star/fd', {method: 'POST', body: fdE, signal: T()}); out.echo_star_post_formdata = r.status; } catch (e) { out.echo_star_post_formdata = 'REJECT ' + S(e); } try { const r = await fetch(apiRoot, {method: 'GET', signal: T()}); out.site_get_direct = r.status; } catch (e) { out.site_get_direct = 'REJECT ' + S(e); } try { const fd1 = new FormData(); diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index ca2beab15..8c56ac1d9 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -160,6 +160,11 @@ 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 @@ -421,7 +426,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } - /// Starts the loopback network proxy when the web view is subject to /// Loads the editor HTML without any dependencies (warmup mode only). /// /// This method is used exclusively by the warmup mechanism to preload editor resources @@ -442,18 +446,18 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// private func buildEditorConfiguration(dependencies: EditorDependencies) throws -> WKUserScript { // The upload pipeline and the REST relay share one local server, but - // each is advertised to JavaScript only when its feature is active: - // `nativeUploadPort` requires a delegate to process uploads, and - // `networkProxy` is only useful under Lockdown Mode. - let hasUploadPipeline = mediaUploadDelegate != nil + // 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) } : nil let gbkitGlobal = try GBKitGlobal( configuration: self.configuration, dependencies: dependencies, - nativeUploadPort: hasUploadPipeline ? uploadServer.map { Int($0.port) } : nil, - nativeUploadToken: hasUploadPipeline ? uploadServer?.token : nil, + nativeUploadPort: isUploadPipelineEnabled ? uploadServer.map { Int($0.port) } : nil, + nativeUploadToken: isUploadPipelineEnabled ? uploadServer?.token : nil, networkProxy: networkProxyGlobal ) let stringValue = try gbkitGlobal.toString() @@ -493,15 +497,15 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // it because the WebView has no auth cookies). Without both there is nothing // to upload through, so leave the upload pipeline down and let uploads fall // to the default WebView path rather than start a pipeline that could only fail. - let needsUploadPipeline = mediaUploadDelegate != nil && !configuration.authHeader.isEmpty + isUploadPipelineEnabled = mediaUploadDelegate != nil && !configuration.authHeader.isEmpty isRestRelayEnabled = webView.configuration.defaultWebpagePreferences.isLockdownModeEnabled && !configuration.isOfflineModeEnabled - guard needsUploadPipeline || isRestRelayEnabled else { + guard isUploadPipelineEnabled || isRestRelayEnabled else { return } - let defaultUploader = needsUploadPipeline ? DefaultMediaUploader( + let defaultUploader = isUploadPipelineEnabled ? DefaultMediaUploader( httpClient: httpClient.uploadClient(), siteApiRoot: configuration.siteApiRoot, siteApiNamespace: configuration.siteApiNamespace @@ -514,6 +518,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro 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 69428bea8..01cf4812a 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -26,6 +26,18 @@ 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: @@ -59,7 +71,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(), @@ -88,10 +104,10 @@ final class MediaUploadServer: Sendable { private static func handleRequest(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse { let parsed = request.parsed - // REST relay route: `/proxy` requests are forwarded to the site's REST - // API (Lockdown Mode support). The upstream URL rides in the query - // string, so the library's permissive CORS policy covers the preflight. - if let restRelay = context.restRelay, parsed.path == "/proxy" { + // 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) } diff --git a/ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift b/ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift index f909c6d92..7674a7ca5 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift @@ -25,39 +25,64 @@ import GutenbergKitHTTP /// /// - Requests reach the relay only through the local server's loopback /// listener and per-session bearer token. -/// - Forwarding is restricted to URLs under the configured site API root, -/// so the relay cannot be used to reach arbitrary hosts. +/// - The 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. /// - The upstream `Authorization` header is injected natively from the editor /// configuration; any client-supplied value is discarded. struct RestRelay: Sendable { - /// Query parameter carrying the absolute upstream URL to forward to. + /// The 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?…`. /// - /// The URL rides in the query string rather than a custom header so the - /// HTTP library's permissive CORS policy (which enumerates allowed - /// headers) covers the preflight without additions. - static let upstreamURLQueryItem = "url" + /// 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 prefix (the site's API root) that forwarded requests must match. - private let allowedPrefix: String + /// 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 - 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 session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 120 + configuration.httpCookieStorage = nil + return URLSession(configuration: configuration) + }() init(configuration: EditorConfiguration) { - var prefix = configuration.siteApiRoot.absoluteString - if !prefix.hasSuffix("/") { - prefix += "/" + var root = configuration.siteApiRoot.absoluteString + if !root.hasSuffix("/") { + root += "/" } - self.allowedPrefix = prefix + self.apiRoot = root self.authHeader = configuration.authHeader + } - let sessionConfiguration = URLSessionConfiguration.ephemeral - sessionConfiguration.timeoutIntervalForRequest = 120 - sessionConfiguration.httpCookieStorage = nil - self.session = URLSession(configuration: sessionConfiguration) + /// Whether a request targets the relay. + static func handles(_ request: ParsedHTTPRequest) -> Bool { + request.path == route || request.path.hasPrefix("\(route)/") } /// Forwards a relayed request to the site's REST API and returns the @@ -65,14 +90,13 @@ struct RestRelay: Sendable { func handle(_ request: HTTPServer.Request) async -> HTTPResponse { let parsed = request.parsed - guard let upstreamURL = Self.upstreamURL(from: parsed.query) else { - return Self.errorResponse(status: 400, body: "Missing or invalid `\(Self.upstreamURLQueryItem)` query parameter") - } - - // SSRF guard: only forward to the configured site API root. - guard upstreamURL.absoluteString.hasPrefix(allowedPrefix) else { - Logger.restRelay.error("Refusing to relay request outside the site API root") - return Self.errorResponse(status: 403, body: "Upstream URL is outside the allowed API root") + 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 = URLRequest(url: upstreamURL) @@ -91,18 +115,49 @@ struct RestRelay: Sendable { } else { // Large bodies are buffered to disk by the request parser; // stream them to avoid loading uploads fully into memory. + // + // `count` is a file-size lookup, and reports zero when it + // fails. Sending that as the `Content-Length` of a body the + // parser says exists would upload nothing, and WordPress + // answers a no-op with a 2xx the editor would take for success. do { + let length = body.count + guard length > 0 else { + Logger.restRelay.error("Refusing to relay a request body whose length could not be read") + return Self.errorResponse(status: 500, code: "relay_body_unreadable", message: "Failed to read the request body.") + } upstreamRequest.httpBodyStream = try body.makeInputStream() - upstreamRequest.setValue("\(body.count)", forHTTPHeaderField: "Content-Length") + upstreamRequest.setValue("\(length)", forHTTPHeaderField: "Content-Length") } catch { Logger.restRelay.error("Failed to open request body stream: \(error)") - return Self.errorResponse(status: 500, body: "Failed to read request body") + return Self.errorResponse(status: 500, code: "relay_body_unreadable", message: "Failed to read the request body.") } } } do { - let upstream = HTTPResponse(try await session.data(for: upstreamRequest)) + // 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) + let upstream = HTTPResponse(try await Self.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, @@ -111,7 +166,115 @@ struct RestRelay: Sendable { ) } catch { Logger.restRelay.error("Upstream request failed: \(error.localizedDescription)") - return Self.errorResponse(status: 502, body: "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? { + let path = request.path + guard path == Self.route || path.hasPrefix("\(Self.route)/") else { return nil } + + // 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, so another path on + /// the same site (`/wp-login.php`), a scheme downgrade, and an alias of the + /// configured host are all 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. + /// + /// `@unchecked Sendable`: `allowedPrefix` is a `let` set at init; the + /// refusal is recorded under a lock. + final class RedirectGuard: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private let allowedPrefix: String + 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) { + self.allowedPrefix = allowedPrefix + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + guard let url = request.url, url.absoluteString.hasPrefix(allowedPrefix) else { + lock.withLock { _refusedTarget = request.url?.absoluteString ?? "an unreadable URL" } + completionHandler(nil) + return + } + completionHandler(request) } } @@ -121,19 +284,28 @@ struct RestRelay: Sendable { /// permissive CORS policy stamps `Access-Control-Allow-Origin` and friends; /// the exposed headers keep paginated REST responses readable to /// `api-fetch` callers. + /// + /// `Allow` is what `canUser` reads off an `OPTIONS` response to decide + /// whether the user may create a page, update settings, upload media, or + /// edit global styles; without it every such capability reads as false with + /// no error surfaced. `Link` backs `fetchAllMiddleware`'s pagination and + /// `X-WP-Total`/`X-WP-TotalPages` back list counts. Nothing else in the + /// editor reads a response header. private static let corsHeaders: [(String, String)] = [ - ("Access-Control-Expose-Headers", "X-WP-Total, X-WP-TotalPages, Link"), + ("Access-Control-Expose-Headers", "Allow, Link, X-WP-Total, X-WP-TotalPages"), ] /// Request headers that must not be forwarded upstream. /// /// `host`/`content-length`/`accept-encoding` are recalculated by URLSession; - /// `origin` and `referer` would leak the local page context to the server - /// (and WordPress rejects `file://` origins — the exact problem the relay - /// exists to solve); the rest are relay-internal. + /// `origin`, `referer`, and `sec-fetch-*` describe the web view's fetch + /// context and would leak the local page to the server (and WordPress + /// rejects `file://` origins — the exact problem the relay exists to + /// solve); the rest are relay-internal. private static let requestHeadersToStrip: Set = [ "host", "content-length", "accept-encoding", "connection", "origin", "referer", + "sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest", "sec-fetch-user", "authorization", "relay-authorization", "proxy-authorization", ] @@ -165,22 +337,18 @@ struct RestRelay: Sendable { upstream.filter { !Self.responseHeadersToStrip.contains($0.0.lowercased()) } + cors } - /// Extracts the upstream URL from the relay request's query string. - private static func upstreamURL(from query: String) -> URL? { - var components = URLComponents() - components.percentEncodedQuery = query - guard let value = components.queryItems?.first(where: { $0.name == upstreamURLQueryItem })?.value, - let url = URL(string: value) else { - return nil - } - return url - } - - private static func errorResponse(status: Int, body: String) -> HTTPResponse { - HTTPResponse( + /// 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 { + let payload = ["code": code, "message": message] + let body = (try? JSONSerialization.data(withJSONObject: payload)) + ?? Data(#"{"code":"relay_error","message":"The editor could not reach the site."}"#.utf8) + return HTTPResponse( status: status, - headers: corsHeaders + [("Content-Type", "text/plain")], - body: Data(body.utf8) + headers: corsHeaders + [("Content-Type", "application/json")], + body: body ) } } diff --git a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift index b77d9c237..ce8ca3914 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift @@ -101,6 +101,14 @@ public struct GBKitGlobal: Sendable, Codable { public struct NetworkProxy: Sendable, Codable { let port: Int let token: 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) { + self.port = port + self.token = token + } } let networkProxy: NetworkProxy? diff --git a/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift index 26524d11e..36a13ece9 100644 --- a/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift +++ b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift @@ -31,8 +31,8 @@ public enum CORSPolicy: Sendable { // anyway: the editor loads from `file://` (Origin `null`), which // 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", "Accept, Authorization, Content-Type, Relay-Authorization, X-HTTP-Method-Override"), ("Access-Control-Max-Age", "86400"), ] } diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift index ac05fbb01..6359578f5 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift @@ -37,10 +37,14 @@ 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. @@ -141,6 +145,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 +178,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 +220,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 +233,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, @@ -331,6 +343,7 @@ public final class HTTPServer: Sendable { queue: DispatchQueue, token: String, requiresAuthentication: Bool, + requiresBrowserOrigin: Bool, maxRequestBodySize: Int64, readTimeout: Duration, bodyReadTimeout: Duration, @@ -370,20 +383,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,7 +462,7 @@ 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. response = HTTPResponse(status: 204) @@ -480,6 +503,9 @@ public final class HTTPServer: Sendable { 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) + } catch HTTPServerError.forbiddenOrigin { + Logger.httpServer.warning("Rejected a request that did not originate from a web view") + await send(HTTPResponse(status: 403, statusText: "Forbidden", body: Data("Forbidden".utf8)), on: connection, cors: cors) } catch HTTPServerError.lengthRequired { await send(HTTPResponse(status: 411, statusText: "Length Required", body: Data("Length Required".utf8)), on: connection, cors: cors) } catch HTTPServerError.unexpectedBody { @@ -772,6 +798,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..719b77b20 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 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/Tests/GutenbergKitHTTPTests/HTTPServerAuthenticationTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPServerAuthenticationTests.swift index 427526eaa..c8066dc09 100644 --- a/ios/Tests/GutenbergKitHTTPTests/HTTPServerAuthenticationTests.swift +++ b/ios/Tests/GutenbergKitHTTPTests/HTTPServerAuthenticationTests.swift @@ -241,10 +241,50 @@ 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 + ) { _ 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 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. let server = try await HTTPServer.start( name: "auth-test", requiresAuthentication: true @@ -255,10 +295,49 @@ struct HTTPServerAuthenticationTests { 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", 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..8a62c240c 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -77,6 +77,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 +87,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 +113,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 +137,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 +166,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 +202,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 +235,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 +264,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 +291,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 +317,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 +854,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/RestRelayIntegrationTests.swift b/ios/Tests/GutenbergKitTests/Media/RestRelayIntegrationTests.swift new file mode 100644 index 000000000..dc888fa28 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Media/RestRelayIntegrationTests.swift @@ -0,0 +1,238 @@ +#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) + + // 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) + + _ = try await site.relayed( + "wp/v2/posts/\(id)?force=true", + method: "POST", + headers: ["X-HTTP-Method-Override": "DELETE"], + 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() + } + + /// 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..aa23eac9b --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Media/RestRelayTests.swift @@ -0,0 +1,231 @@ +#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("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("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("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: - 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 + ) + } + + /// 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: URLSession.shared.dataTask(with: url), + 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 + } +} + +#endif // canImport(Network) diff --git a/src/utils/api-fetch-relay.test.js b/src/utils/api-fetch-relay.test.js new file mode 100644 index 000000000..d8fe3be91 --- /dev/null +++ b/src/utils/api-fetch-relay.test.js @@ -0,0 +1,366 @@ +/** + * 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' }, +}; + +/** 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/api-fetch.js b/src/utils/api-fetch.js index 8fdf82801..5f8885e4d 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -9,7 +9,7 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { getGBKit, POST_FALLBACKS } from './bridge'; -import { debug, info, error as logError } from './logger'; +import { info, error as logError } from './logger'; /** * @typedef {import('@wordpress/api-fetch').APIFetchMiddleware} APIFetchMiddleware @@ -26,9 +26,6 @@ const MEDIA_UPLOAD_PATH = /^\/wp\/v2\/media(\?|$)/; export function configureApiFetch() { const { siteApiRoot = '', preloadData = null } = getGBKit(); - // Registered first so it runs innermost (after all option transforms), - // where it can retry the fully-built request through the native proxy. - apiFetch.use( networkProxyFallbackMiddleware ); apiFetch.use( apiFetch.createRootURLMiddleware( siteApiRoot ) ); apiFetch.use( corsMiddleware ); apiFetch.use( apiPathModifierMiddleware ); @@ -42,141 +39,6 @@ export function configureApiFetch() { ); } -/** - * Tracks whether the native network proxy successfully served a request. - * Once it has, subsequent requests go straight to the proxy instead of - * paying for a doomed direct attempt first. - */ -let isNetworkProxyPreferred = false; - -/** - * Middleware that retries failed requests through the native loopback proxy. - * - * Under iOS Lockdown Mode the editor's `file://` page loses its CORS - * exemption and WordPress sanitizes its `Origin: file://` into an empty - * `Access-Control-Allow-Origin`, so every REST request rejects with - * api-fetch's generic `fetch_error`. When the native host provides a - * loopback proxy (`GBKit.networkProxy`), such failures are retried through - * it: the proxy forwards the request to the site's REST API natively and - * responds with CORS headers the web view accepts. - * - * This middleware must run innermost so `options` carries the final - * request (absolute `url`, headers, body) built by the other middleware. - * - * @type {APIFetchMiddleware} - */ -function networkProxyFallbackMiddleware( options, next ) { - const { networkProxy } = getGBKit(); - - if ( ! networkProxy ) { - return next( options ); - } - - if ( isNetworkProxyPreferred ) { - return proxyFetch( options, networkProxy ); - } - - return next( options ).catch( ( fetchError ) => { - if ( fetchError?.code !== 'fetch_error' ) { - throw fetchError; - } - - debug( - 'api-fetch: direct request failed, retrying through the native network proxy' - ); - return proxyFetch( options, networkProxy ).then( ( result ) => { - isNetworkProxyPreferred = true; - return result; - } ); - } ); -} - -/** - * Performs a request through the native loopback proxy. - * - * The absolute upstream URL travels in the `url` query parameter (a query - * parameter rather than a custom header, so the local server's stock CORS - * policy covers the preflight) and the per-session proxy token in - * `Relay-Authorization` (`Proxy-*` headers are stripped by `fetch()`). The - * upstream `Authorization` header is injected natively, so any value present - * here is dropped. - * - * @param {Object} options Fully-transformed api-fetch options. - * @param {Object} networkProxy Proxy connection details. - * @param {number} networkProxy.port Loopback port. - * @param {string} networkProxy.token Per-session bearer token. - * @return {Promise} The parsed response, mirroring api-fetch semantics. - */ -async function proxyFetch( options, networkProxy ) { - const upstreamUrl = options.url ?? options.path; - const headers = { ...( options.headers || {} ) }; - delete headers.Authorization; - headers[ 'Relay-Authorization' ] = `Bearer ${ networkProxy.token }`; - - let response; - try { - response = await window.fetch( - `http://127.0.0.1:${ - networkProxy.port - }/proxy?url=${ encodeURIComponent( upstreamUrl ) }`, - { - method: options.method || 'GET', - headers, - body: options.body, - } - ); - } catch ( proxyError ) { - logError( - 'api-fetch: native network proxy request failed', - proxyError - ); - throw { - code: 'fetch_error', - message: 'Could not get a valid response from the server.', - }; - } - - return parseProxyResponse( response, options.parse ?? true ); -} - -/** - * Parses a proxied response, mirroring api-fetch's default handler: - * unparsed requests get the raw `Response`, 204s resolve to `null`, - * error statuses throw the decoded JSON body. - * - * @param {Response} response The proxy response. - * @param {boolean} shouldParse Whether the caller requested parsing. - * @return {Promise} The parsed body or raw response. - */ -async function parseProxyResponse( response, shouldParse ) { - if ( ! shouldParse ) { - if ( ! response.ok ) { - throw response; - } - return response; - } - - if ( response.status === 204 ) { - return null; - } - - let json; - try { - json = await response.json(); - } catch { - throw { - code: 'invalid_json', - message: 'The response is not a valid JSON response.', - }; - } - - if ( ! response.ok ) { - throw json; - } - - return json; -} - /** * Middleware setting the CORS mode and remove a specific header causing CORS errors. * diff --git a/src/utils/bridge.js b/src/utils/bridge.js index f04b50830..4406514df 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -221,19 +221,27 @@ 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. + */ + /** * @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..3ff386154 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(); @@ -42,6 +44,24 @@ export async function setUpEditorEnvironment() { } } +/** + * 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(), + ] ); +} + /** * Adds conditional CSS classes to `document.body`. * diff --git a/src/utils/editor-environment.test.js b/src/utils/editor-environment.test.js index fc37543ef..f58285650 100644 --- a/src/utils/editor-environment.test.js +++ b/src/utils/editor-environment.test.js @@ -22,11 +22,13 @@ 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 { 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 +67,7 @@ describe( 'setUpEditorEnvironment', () => { configureLocale.mockResolvedValue( false ); initializeWordPressGlobals.mockImplementation( () => {} ); configureApiFetch.mockImplementation( () => {} ); - initializeFetchInterceptor.mockImplementation( () => {} ); + installFetchWrappers.mockImplementation( () => {} ); configureAjax.mockImplementation( () => {} ); initializeVideoPressAjaxBridge.mockImplementation( () => {} ); initializeEditor.mockImplementation( () => {} ); @@ -83,8 +85,8 @@ describe( 'setUpEditorEnvironment', () => { return Promise.resolve(); } ); - initializeFetchInterceptor.mockImplementation( () => { - callOrder.push( 'initializeFetchInterceptor' ); + installFetchWrappers.mockImplementation( () => { + callOrder.push( 'installFetchWrappers' ); } ); configureLocale.mockImplementation( () => { @@ -120,7 +122,7 @@ describe( 'setUpEditorEnvironment', () => { expect( callOrder ).toEqual( [ 'awaitGBKitGlobal', - 'initializeFetchInterceptor', + 'installFetchWrappers', 'configureLocale', 'injectEditorStyles', 'loadRemainingGlobals', 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..521dd8a75 --- /dev/null +++ b/src/utils/fetch-relay.js @@ -0,0 +1,200 @@ +/** + * Internal dependencies + */ +import { getGBKit } from './bridge'; +import { debug } 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 } ) { + // 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. + const apiRoot = new URL( + siteApiRoot.endsWith( '/' ) ? siteApiRoot : `${ siteApiRoot }/` + ); + const relayRoot = `http://127.0.0.1:${ networkProxy.port }/proxy/`; + const localServerPort = String( networkProxy.port ); + + return ( input, init ) => { + const target = requestURL( input ); + const upstreamPath = + target && ! addressesLocalServer( target, localServerPort ) + ? relayUpstreamPath( target, apiRoot ) + : 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', `Bearer ${ networkProxy.token }` ); + + 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; + } + + debug( `Relaying site REST requests through port ${ networkProxy.port }` ); + return ( next ) => createRelayFetch( next, { networkProxy, siteApiRoot } ); +} + +/** + * 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. + * + * @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. + * @return {string|null} The upstream path, or `null` when it is not a site request. + */ +function relayUpstreamPath( target, apiRoot ) { + if ( + canonicalHost( target.hostname ) !== + canonicalHost( apiRoot.hostname ) || + target.port !== apiRoot.port + ) { + return null; + } + + 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 ); +} + +/** + * A hostname reduced to the form its aliases share: every loopback spelling + * collapses to one, and a `www.` prefix is dropped. + * + * @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\./, '' ); +} diff --git a/src/utils/fetch-relay.test.js b/src/utils/fetch-relay.test.js new file mode 100644 index 000000000..6421ec556 --- /dev/null +++ b/src/utils/fetch-relay.test.js @@ -0,0 +1,212 @@ +/** + * External dependencies + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +/** + * Internal dependencies + */ +import { createRelayFetch } from './fetch-relay'; + +vi.mock( './logger', () => ( { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +} ) ); + +const NETWORK_PROXY = { port: 5555, token: 'relay-token' }; +const RELAY_ROOT = 'http://127.0.0.1:5555/proxy/'; + +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` ); + } ); + + it( 'merges into a root that already carries a query', async () => { + // Plain permalinks: `https://site/?rest_route=/`. + await relayFetch( 'https://example.com/?rest_route=/' )( + 'https://example.com/?rest_route=/wp/v2/posts&x=1' + ); + + expect( calledURL() ).toBe( `${ RELAY_ROOT }wp/v2/posts&x=1` ); + } ); + } ); + + 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. Without matching the port, + // the guard would come down to the path — which a root configured + // as a bare `https://site/` would not distinguish. + 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 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 ); + } ); + } ); +} ); 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; });