Skip to content
Draft
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
22 changes: 21 additions & 1 deletion Packages/OsaurusCore/Managers/Model/ModelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 46 additions & 2 deletions Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,23 @@
private(set) var hostAPIPtr: UnsafeMutablePointer<osr_host_api>?

/// 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
Expand Down Expand Up @@ -1535,6 +1545,40 @@
}
}

// 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 {
Expand Down Expand Up @@ -1766,7 +1810,7 @@
Task.detached(priority: .userInitiated) {
let value = await work()
blockingBridgeQueue.async {
box.value = value

Check warning on line 1813 in Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift

View workflow job for this annotation

GitHub Actions / test-core

capture of 'value' with non-Sendable type 'T' in a '@sendable' closure
sem.signal()
}
}
Expand All @@ -1783,7 +1827,7 @@
Task.detached(priority: .userInitiated) { @MainActor in
let value = work()
blockingBridgeQueue.async {
box.value = value

Check warning on line 1830 in Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift

View workflow job for this annotation

GitHub Actions / test-core

capture of 'value' with non-Sendable type 'T' in a '@sendable' closure
sem.signal()
}
}
Expand Down
Loading