From 4142a5623ef8d38c61bd33779fde3a7a4feef9b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 04:29:38 +0000 Subject: [PATCH 1/2] Harden /pair: per-IP rate limit + single-use nonce replay store /pair is the only unauthenticated endpoint that can mint master-scoped API keys. The signature-over-nonce check correctly authenticates the *connector* but does nothing to prevent: 1. Replay: an attacker who captures a single valid pairing request can re-submit it to the same target and (assuming the user approves the prompt again, which is likely if the prompt is rapid-fire or the same connector triggers it) end up with a second, independently-revocable master key. 2. Flood: a LAN peer can spam /pair to either annoy the user with prompts or burn event-loop cycles on secp256k1 signature recovery. /pair is in the public-paths set and cannot have Bearer auth in front of it by design (an unpaired peer must be able to initiate pairing). Add PairingReplayGuard, a small thread-safe singleton with two in-memory stores: * (connector, nonce) replay set with a 5-minute TTL. consumeNonce() is atomic: first call returns true and reserves the pair, second call returns false. Called after signature verification, so the store can't be poisoned by forged signatures. * Per-source-IP sliding 1-minute window of attempt timestamps, capped at 10. allowAttempt() returns false when the window is saturated. Called before signature verification so a flooder can't keep the event loop busy on secp256k1 work. Both stores prune lazily on every access; memory growth is bounded by the rate of attempts, not by lifetime traffic. Wired into handlePairEndpoint: * allowAttempt() check immediately after JSON decode, returning 429 + 'Too many pairing attempts. Try again later.' on failure. * consumeNonce() check immediately after signature verification, returning 401 + 'Replayed pairing request' on failure. Includes PairingReplayGuardTests covering: first attempt accepted, rate limit observed, per-IP isolation, nonce single-use, nonces are scoped by connector address (so the same nonce under a different connector is not a replay), and _reset() helper for test isolation. Co-authored-by: Michael Meding --- .../OsaurusCore/Networking/HTTPHandler.swift | 61 +++++++++ .../Networking/PairingReplayGuard.swift | 120 ++++++++++++++++++ .../Networking/PairingReplayGuardTests.swift | 83 ++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 Packages/OsaurusCore/Networking/PairingReplayGuard.swift create mode 100644 Packages/OsaurusCore/Tests/Networking/PairingReplayGuardTests.swift 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.. 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