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
61 changes: 61 additions & 0 deletions Packages/OsaurusCore/Networking/HTTPHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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),
Expand Down
120 changes: 120 additions & 0 deletions Packages/OsaurusCore/Networking/PairingReplayGuard.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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..<PairingReplayGuard.rateLimitMax {
_ = PairingReplayGuard.shared.allowAttempt(ip: ipA)
}
// ipA is now saturated; ipB must still be allowed.
#expect(PairingReplayGuard.shared.allowAttempt(ip: ipA) == false)
#expect(PairingReplayGuard.shared.allowAttempt(ip: ipB) == true)
}

@Test func consumeNonceAcceptsFirstUseAndRejectsReplay() {
PairingReplayGuard.shared._reset()
let connector = "0xabc"
let nonce = "challenge-\(UUID().uuidString)"
#expect(PairingReplayGuard.shared.consumeNonce(connector: connector, nonce: nonce))
#expect(PairingReplayGuard.shared.consumeNonce(connector: connector, nonce: nonce) == false)
}

@Test func consumeNonceIsKeyedOnConnectorAddress() {
PairingReplayGuard.shared._reset()
let connectorA = "0xaaa"
let connectorB = "0xbbb"
let nonce = "shared-nonce"
#expect(PairingReplayGuard.shared.consumeNonce(connector: connectorA, nonce: nonce))
// The same nonce string under a different connector address is a
// distinct pair — should not be treated as a replay.
#expect(PairingReplayGuard.shared.consumeNonce(connector: connectorB, nonce: nonce))
}

@Test func resetClearsBothStates() {
PairingReplayGuard.shared._reset()
let ip = "203.0.113.5"
for _ in 0..<PairingReplayGuard.rateLimitMax {
_ = PairingReplayGuard.shared.allowAttempt(ip: ip)
}
_ = PairingReplayGuard.shared.consumeNonce(connector: "x", nonce: "y")

PairingReplayGuard.shared._reset()

#expect(PairingReplayGuard.shared.allowAttempt(ip: ip) == true)
#expect(PairingReplayGuard.shared.consumeNonce(connector: "x", nonce: "y") == true)
}
}
Loading