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/Networking/HTTPHandler.swift b/Packages/OsaurusCore/Networking/HTTPHandler.swift index 0f8877549..442c34f09 100644 --- a/Packages/OsaurusCore/Networking/HTTPHandler.swift +++ b/Packages/OsaurusCore/Networking/HTTPHandler.swift @@ -1533,6 +1533,11 @@ final class HTTPHandler: ChannelInboundHandler, Sendable { requestBodyString = nil } + // Resolve the remote IP for the per-IP rate limit. UNIX-socket and + // unknown-address callers are bucketed under a sentinel so a misconfig + // can't grant "unknown" callers unlimited /pair attempts. + let remoteIPForGuard = context.channel.remoteAddress?.ipAddress ?? "unknown" + guard let req = try? JSONDecoder().decode(PairRequest.self, from: data) else { var headers = [("Content-Type", "application/json; charset=utf-8")] headers.append(contentsOf: stateRef.value.corsHeaders) @@ -1550,6 +1555,29 @@ final class HTTPHandler: ChannelInboundHandler, Sendable { return } + // Rate-limit before we spend cycles on signature verification. A + // misbehaving or hostile LAN peer that floods /pair shouldn't be + // able to keep the event loop busy with secp256k1 recoveries. + if !PairingReplayGuard.shared.allowAttempt(ip: remoteIPForGuard) { + var headers = [("Content-Type", "application/json; charset=utf-8")] + headers.append(contentsOf: stateRef.value.corsHeaders) + let body = #"{"error":"Too many pairing attempts. Try again later."}"# + sendResponse( + context: context, version: head.version, status: .tooManyRequests, + headers: headers, body: body + ) + logRequest( + method: "POST", + path: "/pair", + userAgent: userAgent, + requestBody: requestBodyString, + responseBody: body, + responseStatus: 429, + startTime: startTime + ) + return + } + let cors = stateRef.value.corsHeaders let loop = context.eventLoop let ctx = NIOLoopBound(context, eventLoop: loop) @@ -1598,6 +1626,39 @@ final class HTTPHandler: ChannelInboundHandler, Sendable { return } + // 1b. Single-use nonce. Reject if (connectorAddress, nonce) has + // already been consumed within the replay window. Without + // this, an attacker who captured a valid pairing request + // could replay it to mint a new master key for themselves; + // the signature alone proves only that the original + // connector approved *some* pairing — not *this* attempt. + if !PairingReplayGuard.shared.consumeNonce( + connector: req.connectorAddress, nonce: req.nonce) + { + hop { + var headers = [("Content-Type", "application/json; charset=utf-8")] + headers.append(contentsOf: cors) + let body = #"{"error":"Replayed pairing request"}"# + self.sendResponse( + context: ctx.value, + version: head.version, + status: .unauthorized, + headers: headers, + body: body + ) + logSelf.logRequest( + method: "POST", + path: "/pair", + userAgent: logUserAgent, + requestBody: logRequestBody, + responseBody: body, + responseStatus: 401, + startTime: logStartTime + ) + } + return + } + // 2. Resolve the target agent. let agents = await MainActor.run { AgentManager.shared.agents } guard let agentUUID = UUID(uuidString: req.agentId), diff --git a/Packages/OsaurusCore/Networking/PairingReplayGuard.swift b/Packages/OsaurusCore/Networking/PairingReplayGuard.swift new file mode 100644 index 000000000..82aee55bc --- /dev/null +++ b/Packages/OsaurusCore/Networking/PairingReplayGuard.swift @@ -0,0 +1,120 @@ +// +// PairingReplayGuard.swift +// osaurus +// +// Shared state for /pair endpoint hardening: +// * Per-(connectorAddress, nonce) replay store with a sliding 5-minute TTL. +// A captured signed pairing request can no longer be replayed within the +// window; outside it, the signature is irrelevant because the user must +// re-approve the pop-up anyway, but rotating the nonce on every attempt +// makes signature replay strictly impossible during the live window. +// +// * Per-source-IP sliding-window rate limit. The /pair endpoint sits behind +// no Bearer auth (it has to, by design, so an unpaired peer can initiate +// pairing). That means a LAN peer can spam pairing attempts and either +// annoy the user with prompts, or chew through event-loop cycles on +// secp256k1 signature recovery. A small bucket of attempts per minute +// per remote IP is sufficient — a real pairing flow uses 1 attempt. +// +// All state is bounded: nonces expire after 5 minutes, and IP timestamp lists +// are pruned on every access. Memory growth is proportional to the rate of +// attempts, not to lifetime traffic. +// + +import Foundation + +/// Thread-safe singleton consulted by `HTTPHandler.handlePairEndpoint`. +public final class PairingReplayGuard: @unchecked Sendable { + public static let shared = PairingReplayGuard() + + private let lock = NSLock() + private var seenNonces: [String: Date] = [:] + private var ipTimestamps: [String: [Date]] = [:] + + /// How long a (connectorAddress, nonce) pair is remembered. Real pairing + /// flows finish in seconds; 5 minutes is comfortably above the slowest + /// realistic user-approval round trip while keeping the replay window + /// well below the time a real attacker would need to coordinate. + public static let nonceTTL: TimeInterval = 300 + + /// Maximum number of /pair attempts allowed from a single source IP + /// within `rateLimitWindow`. A successful pairing uses exactly one + /// attempt; a rejected approval typically uses one more after the user + /// dismisses the prompt. Ten gives plenty of slack for retries while + /// stopping flood/probe traffic cold. + public static let rateLimitMax: Int = 10 + + /// Sliding window for `rateLimitMax`. + public static let rateLimitWindow: TimeInterval = 60 + + // MARK: - Rate limit + + /// Record one /pair attempt from `ip`. Returns `true` if the attempt is + /// allowed; `false` if the IP has exceeded `rateLimitMax` within the + /// sliding `rateLimitWindow`. Called before signature verification so + /// flooders can't spend our CPU. + public func allowAttempt(ip: String) -> Bool { + lock.lock() + defer { lock.unlock() } + let now = Date() + pruneExpired_locked(now: now) + + let recent = ipTimestamps[ip, default: []] + if recent.count >= Self.rateLimitMax { + return false + } + ipTimestamps[ip, default: []].append(now) + return true + } + + // MARK: - Nonce replay + + /// Reserve `(connector, nonce)` for the next `nonceTTL` seconds. Returns + /// `true` if the pair has not been seen yet (i.e. consumption succeeded); + /// `false` if it was already consumed within the window — that's a replay. + public func consumeNonce(connector: String, nonce: String) -> Bool { + lock.lock() + defer { lock.unlock() } + let now = Date() + pruneExpired_locked(now: now) + + let key = "\(connector)|\(nonce)" + if seenNonces[key] != nil { + return false + } + seenNonces[key] = now.addingTimeInterval(Self.nonceTTL) + return true + } + + // MARK: - Internals + + /// Drop expired nonces and trim old timestamps. Caller must hold `lock`. + private func pruneExpired_locked(now: Date) { + // Nonces: each entry stores its own expiration timestamp. + seenNonces = seenNonces.filter { _, expires in expires > now } + + // Rate-limit window: drop timestamps older than the window. Entire + // IP entries are removed when they become empty so the map doesn't + // grow unbounded with one-shot attempts. + let cutoff = now.addingTimeInterval(-Self.rateLimitWindow) + for (ip, stamps) in ipTimestamps { + let kept = stamps.filter { $0 > cutoff } + if kept.isEmpty { + ipTimestamps.removeValue(forKey: ip) + } else { + ipTimestamps[ip] = kept + } + } + } + + // MARK: - Test helpers (internal) + + /// Reset all guard state. Used by tests so independent test cases don't + /// inherit each other's nonces / rate counters. + internal func _reset() { + lock.lock() + defer { lock.unlock() } + seenNonces.removeAll() + ipTimestamps.removeAll() + } +} diff --git a/Packages/OsaurusCore/Tests/Networking/PairingReplayGuardTests.swift b/Packages/OsaurusCore/Tests/Networking/PairingReplayGuardTests.swift new file mode 100644 index 000000000..792b6ea12 --- /dev/null +++ b/Packages/OsaurusCore/Tests/Networking/PairingReplayGuardTests.swift @@ -0,0 +1,83 @@ +// +// PairingReplayGuardTests.swift +// osaurusTests +// +// Behavioural tests for the /pair endpoint's rate-limit + nonce-replay +// shared state. The HTTP handler consults this guard before doing any +// signature work, and again after signature verification, so its +// correctness directly affects whether replay and flood attempts are +// rejected. +// + +import Foundation +import Testing + +@testable import OsaurusCore + +@Suite(.serialized) +struct PairingReplayGuardTests { + + @Test func allowsFirstAttemptFromIP() { + PairingReplayGuard.shared._reset() + #expect(PairingReplayGuard.shared.allowAttempt(ip: "203.0.113.1")) + } + + @Test func enforcesRateLimitWindow() { + PairingReplayGuard.shared._reset() + let ip = "203.0.113.2" + var allowed = 0 + // Try one more than the configured maximum so we observe the + // transition from "ok" to "rate-limited". + for _ in 0..<(PairingReplayGuard.rateLimitMax + 1) { + if PairingReplayGuard.shared.allowAttempt(ip: ip) { + allowed += 1 + } + } + #expect(allowed == PairingReplayGuard.rateLimitMax) + } + + @Test func rateLimitIsPerIP() { + PairingReplayGuard.shared._reset() + let ipA = "203.0.113.3" + let ipB = "203.0.113.4" + for _ in 0..