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
62 changes: 54 additions & 8 deletions Packages/OsaurusCore/Networking/HTTPHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
70 changes: 70 additions & 0 deletions Packages/OsaurusCore/Tests/Networking/DevProxyURLGuardTests.swift
Original file line number Diff line number Diff line change
@@ -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://"))
}
}
Loading