diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 9edaff7a8..c729f45b4 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -107,7 +107,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro private let editorService: EditorService private let mediaPicker: MediaPickerController? private let controller: GutenbergEditorController - private let bundleProvider = EditorAssetBundleProvider() + private let bundleProvider: EditorAssetBundleProvider // MARK: - Private Properties (UI) @@ -153,11 +153,21 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, mediaPicker: MediaPickerController? = nil, + httpClient: EditorHTTPClient? = nil, isWarmupMode: Bool = false ) { + let httpClient = httpClient ?? EditorHTTPClient( + urlSession: URLSession.shared, + authHeader: configuration.authHeader + ) + self.configuration = configuration self.dependencies = dependencies - self.editorService = EditorService(configuration: configuration) + self.editorService = EditorService( + configuration: configuration, + httpClient: httpClient + ) + self.bundleProvider = EditorAssetBundleProvider(httpClient: httpClient) self.mediaPicker = mediaPicker self.controller = GutenbergEditorController(configuration: configuration) diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift index 9ba256767..92c313d88 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift @@ -97,10 +97,27 @@ public struct EditorAssetBundle: Sendable, Equatable, Hashable { ) } + /// Checks whether the given asset URL resolves to a valid path within the bundle root. + /// + /// Use this method to validate URLs before calling `assetDataPath(for:)` or `hasAssetData(for:)` + /// to avoid precondition failures for paths that escape the bundle root (e.g., plugin assets + /// referenced in CSS that weren't downloaded into the bundle). + /// + /// - Parameter url: The asset URL to validate. + /// - Returns: `true` if the URL resolves to a path within the bundle root, `false` otherwise. + public func isValidAssetPath(for url: URL) -> Bool { + let path = url.path(percentEncoded: false) + let bundlePath = self.bundleRoot.appending(rawPath: path).standardizedFileURL + let bundleRootPath = self.bundleRoot.standardizedFileURL.path + return bundlePath.path.hasPrefix(bundleRootPath + "/") || bundlePath.path == bundleRootPath + } + /// Checks whether this bundle contains cached data for the given asset URL. /// /// - Parameter url: The original remote URL of the asset. /// - Returns: `true` if the asset is cached in this bundle, `false` otherwise. + /// - Precondition: The URL path must not escape the bundle root directory. + /// Use `isValidAssetPath(for:)` to check validity before calling this method. public func hasAssetData(for url: URL) -> Bool { FileManager.default.fileExists(at: self.assetDataPath(for: url)) } diff --git a/ios/Sources/GutenbergKit/Sources/Services/EditorAssetBundleProvider.swift b/ios/Sources/GutenbergKit/Sources/Services/EditorAssetBundleProvider.swift index b3a623d66..05a78cf07 100644 --- a/ios/Sources/GutenbergKit/Sources/Services/EditorAssetBundleProvider.swift +++ b/ios/Sources/GutenbergKit/Sources/Services/EditorAssetBundleProvider.swift @@ -9,6 +9,10 @@ import WebKit /// asset manifest to JavaScript) and a URL scheme handler (to serve individual cached /// files via the `gbk-cache-https` scheme). /// +/// When an asset is not found in the local cache (e.g., images referenced in CSS that +/// weren't downloaded), the provider fetches the asset from its original HTTPS URL +/// and serves it to the WebView. +/// /// ## Usage /// /// ```swift @@ -19,10 +23,17 @@ import WebKit /// /// The provider must be bound to the WebView configuration before loading the editor, /// and must have a bundle set before the editor requests assets. -public final class EditorAssetBundleProvider: NSObject, @unchecked Sendable { +@MainActor +public final class EditorAssetBundleProvider: NSObject { - private let lock = NSLock() private var bundle: EditorAssetBundle? + private let httpClient: EditorHTTPClient + private var runningTasks: [Task] = [] + + public init(httpClient: EditorHTTPClient) { + self.httpClient = httpClient + super.init() + } /// Sets the asset bundle to serve to the WebView. /// @@ -30,9 +41,7 @@ public final class EditorAssetBundleProvider: NSObject, @unchecked Sendable { /// /// - Parameter bundle: The downloaded asset bundle containing cached scripts and styles. public func set(bundle: EditorAssetBundle) { - lock.withLock { - self.bundle = bundle - } + self.bundle = bundle } /// Registers this provider with a WebView configuration. @@ -90,36 +99,33 @@ extension EditorAssetBundleProvider: WKScriptMessageHandlerWithReply { extension EditorAssetBundleProvider: WKURLSchemeHandler { public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { logExecutionTime("Retrieved cached asset") { - var loggerMessages = ["📚 Editor requested a cached asset"] - - defer { - Logger.assetLibrary.info("\(loggerMessages.joined(separator: "\n"))") - } + Logger.assetLibrary.info("📚 Editor requested a cached asset") guard let url = urlSchemeTask.request.url else { - loggerMessages.append(" URL: ") + Logger.assetLibrary.info(" URL: ") urlSchemeTask.didFailWithError(URLError(.badURL)) return } - loggerMessages.append(" URL: \(url)") + Logger.assetLibrary.info(" URL: \(url)") guard let bundle else { preconditionFailure("Cannot read asset with no bundle present. This is a programmer error.") } - guard bundle.hasAssetData(for: url) else { - loggerMessages.append(" Path: ") + // Check if the path is valid and the asset exists in the bundle. + // If not, fetch from the original HTTPS URL (e.g., for plugin SVGs + // referenced in CSS that weren't downloaded into the bundle). + let shouldFetchFromRemote = !bundle.isValidAssetPath(for: url) || !bundle.hasAssetData(for: url) - loggerMessages.append(" Not found – sending 404") - let response = HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)! - urlSchemeTask.didReceive(response) - urlSchemeTask.didFinish() + guard !shouldFetchFromRemote else { + Logger.assetLibrary.info(" Asset not in bundle – fetching from remote") + self.fetchFromRemote(for: urlSchemeTask) return } do { - loggerMessages.append(" Path: \(bundle.assetDataPath(for: url))") + Logger.assetLibrary.info(" Path: \(bundle.assetDataPath(for: url))") let data = try bundle.assetData(for: url) let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! @@ -127,14 +133,70 @@ extension EditorAssetBundleProvider: WKURLSchemeHandler { urlSchemeTask.didReceive(data) urlSchemeTask.didFinish() } catch { - loggerMessages.append(" Error: \(error.localizedDescription)") + Logger.assetLibrary.warning(" Error: \(error.localizedDescription)") urlSchemeTask.didFailWithError(error) } - } } public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { // No-op: since we're reading from disk synchronously, there's nothing to cancel } + + /// Fetches an asset from its original remote URL and serves it to the WebView. + /// + /// This is used when an asset isn't in the local bundle (e.g., images referenced + /// in CSS files that weren't downloaded because only JS/CSS files are cached). + private func fetchFromRemote(for urlSchemeTask: any WKURLSchemeTask) { + guard let originalRequest = self.originalRequest(for: urlSchemeTask.request) else { + Logger.assetLibrary.info(" Failed to construct original URL") + let response = HTTPURLResponse( + url: urlSchemeTask.request.url!, + statusCode: 400, + httpVersion: nil, + headerFields: nil + )! + urlSchemeTask.didReceive(response) + urlSchemeTask.didFinish() + return + } + + Logger.assetLibrary.info(" Fetching: \(originalRequest)") + + let taskHandle = Task { + do { + let (data, response) = try await self.httpClient.perform(originalRequest) + + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + catch { + Logger.assetLibrary.error("📚 Failed to fetch remote asset: \(error.localizedDescription)") + urlSchemeTask.didFailWithError(error) + } + } + + self.runningTasks.append(taskHandle) + } + + /// Converts a `gbk-cache-https` URL back to its original `https` URL. + /// + /// For example: `gbk-cache-https://example.com/path` → `https://example.com/path` + private func originalRequest(for request: URLRequest) -> URLRequest? { + guard let url = request.url, var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + + // The scheme "gbk-cache-https" encodes the original scheme after the prefix + let schemePrefix = "gbk-cache-" + guard let scheme = components.scheme, scheme.hasPrefix(schemePrefix) else { + return nil + } + components.scheme = String(scheme.dropFirst(schemePrefix.count)) + + var mutableCopy = request + mutableCopy.url = components.url + return mutableCopy + } } diff --git a/ios/Tests/GutenbergKitTests/Model/EditorAssetBundleTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorAssetBundleTests.swift index 504ddc3c8..dbb261609 100644 --- a/ios/Tests/GutenbergKitTests/Model/EditorAssetBundleTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/EditorAssetBundleTests.swift @@ -257,6 +257,53 @@ struct EditorAssetBundleTests { try? FileManager.default.removeItem(at: tempDir) } + // MARK: - isValidAssetPath Tests + + @Test("isValidAssetPath returns true for valid path within bundle") + func isValidAssetPathReturnsTrueForValidPath() { + let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let bundle = makeBundle(bundleRoot: tempDir) + + let url = URL(string: "https://example.com/wp-content/plugins/script.js")! + #expect(bundle.isValidAssetPath(for: url)) + } + + @Test("isValidAssetPath returns true for nested paths") + func isValidAssetPathReturnsTrueForNestedPaths() { + let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let bundle = makeBundle(bundleRoot: tempDir) + + let url = URL(string: "https://example.com/wp-content/plugins/jetpack/assets/js/script.js")! + #expect(bundle.isValidAssetPath(for: url)) + } + + @Test("isValidAssetPath returns false for path traversal attempt") + func isValidAssetPathReturnsFalseForPathTraversal() { + let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let bundle = makeBundle(bundleRoot: tempDir) + + let url = URL(string: "https://example.com/../../../etc/passwd")! + #expect(!bundle.isValidAssetPath(for: url)) + } + + @Test("isValidAssetPath returns false for path escaping via encoded traversal") + func isValidAssetPathReturnsFalseForEncodedTraversal() { + let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let bundle = makeBundle(bundleRoot: tempDir) + + let url = URL(string: "https://example.com/%2e%2e/%2e%2e/etc/passwd")! + #expect(!bundle.isValidAssetPath(for: url)) + } + + @Test("isValidAssetPath handles paths with dot segments that stay within bundle") + func isValidAssetPathHandlesDotSegmentsWithinBundle() { + let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let bundle = makeBundle(bundleRoot: tempDir) + + let url = URL(string: "https://example.com/wp-content/./plugins/script.js")! + #expect(bundle.isValidAssetPath(for: url)) + } + // MARK: - assetDataPath Tests @Test("assetDataPath returns correct path based on URL path") diff --git a/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift b/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift index 0e354a441..95405b7fc 100644 --- a/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift +++ b/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift @@ -59,7 +59,7 @@ struct EditorAssetLibraryTests { func existingBundleReturnsNilForMissingChecksum() async throws { let library = makeLibrary() - let result = try await library.existingBundle(forManifestChecksum: "nonexistent-checksum-12345") + let result = await library.existingBundle(forManifestChecksum: "nonexistent-checksum-12345") #expect(result == nil) }