Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions ios/Sources/GutenbergKit/Sources/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
17 changes: 17 additions & 0 deletions ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,20 +23,25 @@ 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<Void, Never>] = []

public init(httpClient: EditorHTTPClient) {
self.httpClient = httpClient
super.init()
}

/// Sets the asset bundle to serve to the WebView.
///
/// This method is thread-safe and can be called from any thread.
///
/// - 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.
Expand Down Expand Up @@ -90,51 +99,104 @@ 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: <missing>")
Logger.assetLibrary.info(" URL: <missing>")
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: <missing>")
// 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)!
urlSchemeTask.didReceive(response)
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
}
}
47 changes: 47 additions & 0 deletions ios/Tests/GutenbergKitTests/Model/EditorAssetBundleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading