From c3afce5453ae77f1aff190df4393cff357d23a6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 04:42:25 +0000 Subject: [PATCH 1/2] Restrict dev-proxy.json to DEBUG builds + loopback-only targets The dev-proxy.json feature lets plugin authors hot-reload their web UI from a local dev server (vite, webpack-dev-server, etc.) by writing a small config file under ~/.osaurus/config/. The HTTP handler picks that URL up at request time and proxies plugin traffic to it, returning the dev server's HTML/JS/CSS to the plugin webview with an extra 'Access-Control-Allow-Origin: *' tacked on. Two problems with the previous behavior: 1. The config file applied in release builds too. Any user-mode process that could write to ~/.osaurus/config/dev-proxy.json could re-route a plugin's web channel to a server it controls -- complete with the CORS-relaxing wildcard -- without going through any plugin install / signature flow. 2. The proxy target URL was a free-form string. No host check, no scheme check. A misconfig or attack could point it at http://169.254.169.254/latest/meta-data/ (cloud-metadata), http://192.168.1.1/admin (LAN device), or even file:///etc/passwd, turning a developer convenience into an SSRF / local-file-disclosure primitive routed through Osaurus. Fix both: * Wrap loadDevProxyURL in '#if !DEBUG / return nil'. Release builds silently ignore dev-proxy.json. Users who want hot-reload during development opt in by running a DEBUG build. * Constrain the URL via a new helper isLoopbackProxyURL. The host must be 'localhost', a 127.x.x.x literal, or '::1', and the scheme must be http or https. Anything else is rejected and the proxy is treated as not configured. The helper is split out as an internal static so tests can exercise the boundary cases (RFC1918, cloud-metadata, file:// scheme, malformed URLs) without standing up an HTTP server, and so future code (e.g. a sandbox-side dev-proxy) can reuse the same definition of 'loopback' without copy-paste. Co-authored-by: Michael Meding --- .../OsaurusCore/Networking/HTTPHandler.swift | 62 +++++++++++++--- .../Networking/DevProxyURLGuardTests.swift | 70 +++++++++++++++++++ 2 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 Packages/OsaurusCore/Tests/Networking/DevProxyURLGuardTests.swift diff --git a/Packages/OsaurusCore/Networking/HTTPHandler.swift b/Packages/OsaurusCore/Networking/HTTPHandler.swift index 0f8877549..b94032639 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 000000000..e20549d9a --- /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://")) + } +} From 8c53fe222485b4f342545fda5b10a7720064bd57 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 05:08:34 +0000 Subject: [PATCH 2/2] Fix flake: skip ModelManager launch-time HF fetch under xctest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModelManager.init kicks off an unstructured Task that calls loadOsaurusAIOrgModels(), which fetches the OsaurusAI organization listing from Hugging Face and feeds the result through applyOsaurusOrgFetch. The unit-test runner repeatedly constructs ModelManager() to drive applyOsaurusOrgFetch directly. The background launch-time fetch races with those test calls — whichever finishes last wins, and the merge result is non-deterministic. That's the root cause of the flaky ModelManagerSuggestedTests failures seen across many of the recent PR CI runs (applyOsaurusOrgFetch_dropsStaleAutoFetched OnReapply, applyOsaurusOrgFetch_addsNewEntriesAfterCurated, etc.). Gate the launch-time fetch on a small isRunningInTestEnvironment helper that checks for any of XCTestConfigurationFilePath, XCTestBundlePath, or XCTestSessionIdentifier in the process environment. Those variables are only present inside an xctest host process; production app launches still get the HF fetch exactly as before. This is a network call, so removing it under tests also has the side benefit of making the test suite work offline / on hermetic CI runners. Co-authored-by: Michael Meding --- .../Managers/Model/ModelManager.swift | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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