diff --git a/Packages/OsaurusCore/Managers/Model/ModelManager.swift b/Packages/OsaurusCore/Managers/Model/ModelManager.swift index c87d515f6..dc6595695 100644 --- a/Packages/OsaurusCore/Managers/Model/ModelManager.swift +++ b/Packages/OsaurusCore/Managers/Model/ModelManager.swift @@ -188,7 +188,27 @@ final class ModelManager: NSObject, ObservableObject { // Pull the OsaurusAI HF org listing once on launch so newly published // models surface in the Recommended tab without requiring a code push. - Task { [weak self] in await self?.loadOsaurusAIOrgModels() } + // + // The unit-test runner constructs `ModelManager()` repeatedly to drive + // `applyOsaurusOrgFetch` directly. If the launch-time HF fetch races + // with those test calls, whichever finishes last wins and the merge + // result is non-deterministic — that's the regression class behind + // `ModelManagerSuggestedTests/applyOsaurusOrgFetch_*` flaking in CI. + // Skip the background fetch under XCTest; production launches still + // get it because `XCTestConfigurationFilePath` is only set inside + // a test host. + if !Self.isRunningInTestEnvironment { + Task { [weak self] in await self?.loadOsaurusAIOrgModels() } + } + } + + /// True when the current process was launched by xctest. Used to gate + /// network-touching launch-time side effects so tests can drive the + /// affected code paths deterministically. + nonisolated private static var isRunningInTestEnvironment: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + || ProcessInfo.processInfo.environment["XCTestBundlePath"] != nil + || ProcessInfo.processInfo.environment["XCTestSessionIdentifier"] != nil } // MARK: - Public Methods diff --git a/Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift b/Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift index c92c0b5df..eee35fe3b 100644 --- a/Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift +++ b/Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift @@ -59,13 +59,23 @@ final class PluginHostContext: @unchecked Sendable { private(set) var hostAPIPtr: UnsafeMutablePointer? /// Shared URLSession for plugin HTTP requests (thread-safe). + /// Shared URLSession used by the plugin host `http_request` callback when + /// the caller has asked for redirect-following. The session-level delegate + /// re-runs `checkSSRF` on every redirect target so a 30x to a private + /// address can't bypass the initial SSRF guard. See SSRFCheckedRedirectDelegate. private static let httpSession: URLSession = { let config = URLSessionConfiguration.ephemeral config.httpMaximumConnectionsPerHost = 10 - return URLSession(configuration: config) + return URLSession( + configuration: config, + delegate: SSRFCheckedRedirectDelegate.shared, + delegateQueue: nil + ) }() - /// Shared URLSession that suppresses redirects. Singleton to avoid per-request session leaks. + /// Shared URLSession that suppresses redirects entirely. Singleton to + /// avoid per-request session leaks. Used when the plugin explicitly + /// passes `follow_redirects: false`. private static let noRedirectSession: URLSession = { let config = URLSessionConfiguration.ephemeral config.httpMaximumConnectionsPerHost = 10 @@ -1535,6 +1545,40 @@ private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate, @unche } } +// MARK: - SSRF-Checked Redirect URLSession Delegate + +/// URLSession delegate that re-runs `PluginHostContext.checkSSRF` on every +/// redirect target before allowing the redirect to proceed. +/// +/// Without this, an upstream `http://attacker.example.com` could 30x-redirect +/// to `http://127.0.0.1:1337/...` or any RFC1918 address and the initial SSRF +/// guard on the request URL would be bypassed. This delegate is attached to +/// the plugin host's redirect-following URLSession; the no-redirect session +/// retains its existing block-everything behavior. +private final class SSRFCheckedRedirectDelegate: NSObject, URLSessionTaskDelegate, + @unchecked Sendable +{ + static let shared = SSRFCheckedRedirectDelegate() + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + if let url = request.url, PluginHostContext.checkSSRF(url: url) != nil { + // Refuse the redirect. URLSession will surface the original 3xx + // response (status code + headers) to the caller, who can detect + // the blocked redirect from the status without ever connecting + // to the private target. + completionHandler(nil) + return + } + completionHandler(request) + } +} + // MARK: - Task State Serialization extension PluginHostContext {