diff --git a/Packages/OsaurusCore/Managers/Model/ModelManager.swift b/Packages/OsaurusCore/Managers/Model/ModelManager.swift index c87d515f6f..dc65956958 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/Networking/HTTPHandler.swift b/Packages/OsaurusCore/Networking/HTTPHandler.swift index 0f88775490..b94032639c 100644 --- a/Packages/OsaurusCore/Networking/HTTPHandler.swift +++ b/Packages/OsaurusCore/Networking/HTTPHandler.swift @@ -1040,15 +1040,61 @@ final class HTTPHandler: ChannelInboundHandler, Sendable { } /// Loads the dev proxy URL for a plugin from the dev-proxy.json config file. + /// + /// dev-proxy.json is a developer convenience that lets plugin authors hot- + /// reload their web UI from a local dev server (vite, webpack-dev-server, + /// next dev, …). The file lives under `~/.osaurus/config/` and is + /// trusted-on-the-machine. + /// + /// Two guards apply: + /// + /// * Release builds ignore the file entirely. A user who didn't go out of + /// their way to enable DEBUG can never have a plugin web UI re-routed to + /// an arbitrary URL just because some other process wrote that config + /// file. (Anyone with that level of local access has worse options + /// available to them, but defense-in-depth.) + /// + /// * The URL is constrained to http(s)://localhost or http(s)://127.x or + /// http(s)://[::1]. The dev-server use case only ever needs loopback; + /// anything else is either a misconfiguration or an attempt to use this + /// plugin-web channel as an SSRF primitive into RFC1918 or the public + /// internet. private static func loadDevProxyURL(for pluginId: String) -> String? { - let configFile = OsaurusPaths.config().appendingPathComponent("dev-proxy.json") - guard let data = try? Data(contentsOf: configFile), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let configPluginId = obj["plugin_id"] as? String, - configPluginId == pluginId, - let proxyURL = obj["web_proxy"] as? String - else { return nil } - return proxyURL + #if !DEBUG + return nil + #else + let configFile = OsaurusPaths.config().appendingPathComponent("dev-proxy.json") + guard let data = try? Data(contentsOf: configFile), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let configPluginId = obj["plugin_id"] as? String, + configPluginId == pluginId, + let proxyURL = obj["web_proxy"] as? String, + Self.isLoopbackProxyURL(proxyURL) + else { return nil } + return proxyURL + #endif + } + + /// Returns true if `urlString` parses to an http(s) URL whose host is on + /// the loopback family (`localhost`, `127.0.0.0/8`, or `::1`). Used to + /// constrain the developer-only dev-proxy.json config to its intended + /// purpose — hitting a local dev server, never a remote host. + static func isLoopbackProxyURL(_ urlString: String) -> Bool { + guard let url = URL(string: urlString), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = url.host?.lowercased() + else { return false } + + if host == "localhost" || host == "::1" { return true } + + // IPv4 dotted notation: only allow 127.x.x.x. + let octets = host.split(separator: ".").compactMap { UInt8($0) } + if octets.count == 4 { + return octets[0] == 127 + } + + return false } /// Proxies a web request to a local dev server for HMR support. diff --git a/Packages/OsaurusCore/Tests/Networking/DevProxyURLGuardTests.swift b/Packages/OsaurusCore/Tests/Networking/DevProxyURLGuardTests.swift new file mode 100644 index 0000000000..e20549d9a0 --- /dev/null +++ b/Packages/OsaurusCore/Tests/Networking/DevProxyURLGuardTests.swift @@ -0,0 +1,70 @@ +// +// DevProxyURLGuardTests.swift +// osaurusTests +// +// Unit tests for `HTTPHandler.isLoopbackProxyURL`. The dev-proxy.json feature +// reads a developer-supplied URL and proxies plugin web traffic to it. This +// guard constrains the allowed targets to loopback so an attacker (or a +// misconfigured editor / tool that drops a config file) can't turn the +// dev-proxy channel into an SSRF primitive against RFC1918 hosts or the +// public internet. +// + +import Foundation +import Testing + +@testable import OsaurusCore + +struct DevProxyURLGuardTests { + + @Test func acceptsLocalhost() { + #expect(HTTPHandler.isLoopbackProxyURL("http://localhost:5173")) + #expect(HTTPHandler.isLoopbackProxyURL("https://localhost")) + } + + @Test func acceptsLoopbackV4() { + #expect(HTTPHandler.isLoopbackProxyURL("http://127.0.0.1:3000")) + #expect(HTTPHandler.isLoopbackProxyURL("http://127.0.0.1/")) + #expect(HTTPHandler.isLoopbackProxyURL("http://127.255.255.255/x")) + } + + @Test func acceptsLoopbackV6() { + #expect(HTTPHandler.isLoopbackProxyURL("http://[::1]:1234/")) + } + + @Test func rejectsRFC1918() { + // 10.x, 172.16-31.x, 192.168.x — common LAN ranges. dev-proxy is a + // local-loopback feature; it has no business proxying to LAN hosts. + #expect(!HTTPHandler.isLoopbackProxyURL("http://10.0.0.1:3000")) + #expect(!HTTPHandler.isLoopbackProxyURL("http://172.16.0.1:3000")) + #expect(!HTTPHandler.isLoopbackProxyURL("http://192.168.1.1:3000")) + } + + @Test func rejectsPublicHost() { + #expect(!HTTPHandler.isLoopbackProxyURL("http://example.com")) + #expect(!HTTPHandler.isLoopbackProxyURL("https://1.1.1.1/dns-query")) + #expect(!HTTPHandler.isLoopbackProxyURL("http://8.8.8.8/")) + } + + @Test func rejectsLinkLocalAndCloudMetadata() { + // 169.254.169.254 is the AWS / GCP metadata service. A real exploit + // of an SSRF-style dev-proxy would target exactly this address. + #expect(!HTTPHandler.isLoopbackProxyURL("http://169.254.169.254/latest/meta-data/")) + #expect(!HTTPHandler.isLoopbackProxyURL("http://169.254.0.1/")) + } + + @Test func rejectsNonHTTPSchemes() { + // file:// would let an attacker exfiltrate local file contents + // through the dev-proxy code path. Reject anything that isn't + // http or https. + #expect(!HTTPHandler.isLoopbackProxyURL("file:///etc/passwd")) + #expect(!HTTPHandler.isLoopbackProxyURL("data:text/plain,abc")) + #expect(!HTTPHandler.isLoopbackProxyURL("ftp://localhost/x")) + } + + @Test func rejectsMalformedURL() { + #expect(!HTTPHandler.isLoopbackProxyURL("")) + #expect(!HTTPHandler.isLoopbackProxyURL("not a url")) + #expect(!HTTPHandler.isLoopbackProxyURL("http://")) + } +}