From 0c50dbf9b111f765ba2f422e2b11124d61279865 Mon Sep 17 00:00:00 2001 From: qw Date: Fri, 17 Apr 2026 13:40:16 +0800 Subject: [PATCH] fix(watch-http): harden pairing flow against LAN brute force MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4-digit → 6-digit pairing code (10k → 1M values) - Per-peer-IP failure ledger with rolling 5-min window - Rotate code after 3 failures from a peer - Block peer for 5 min after 10 failures (429) - Constant-time code comparison - Accumulating HTTP parser with Content-Length handling + 64 KiB cap (previously assumed one packet = one request, silently failing on TCP fragmentation or oversized bodies) - Response builder now constructs bytes explicitly, not via string interpolation of Content-Length Follow-up not in this PR: TLS + loopback/interface binding option. The endpoint still listens on all interfaces in plaintext — bearer tokens and SSE payloads remain sniffable on untrusted LANs until TLS lands. Co-Authored-By: Claude Opus 4.7 --- .../OpenIslandCore/WatchHTTPEndpoint.swift | 181 ++++++++++++++---- 1 file changed, 143 insertions(+), 38 deletions(-) diff --git a/Sources/OpenIslandCore/WatchHTTPEndpoint.swift b/Sources/OpenIslandCore/WatchHTTPEndpoint.swift index f3d3574a2..265ee6c40 100644 --- a/Sources/OpenIslandCore/WatchHTTPEndpoint.swift +++ b/Sources/OpenIslandCore/WatchHTTPEndpoint.swift @@ -106,16 +106,29 @@ public typealias WatchActiveSessionCountProvider = @Sendable () -> Int /// /// Uses `NWListener` for TCP + Bonjour advertising of `_openisland._tcp`. /// Implements a minimal HTTP/1.1 parser for 4 endpoints: -/// - `POST /pair` — submit 4-digit pairing code, receive session token +/// - `POST /pair` — submit 6-digit pairing code, receive session token /// - `GET /events` — SSE stream of agent events /// - `POST /resolution` — submit Watch action decisions /// - `GET /status` — connection and session status public final class WatchHTTPEndpoint: @unchecked Sendable { private static let logger = Logger(subsystem: "app.openisland", category: "WatchHTTPEndpoint") private static let serviceType = "_openisland._tcp" - private static let pairingCodeLength = 4 + private static let pairingCodeLength = 6 private static let pairingCodeExpiry: TimeInterval = 120 // 2 minutes + // Brute-force protection tunables. A 6-digit code is 1M values; combined + // with a 5-minute rolling window of 10 failures per peer IP and a + // 5-minute penalty box, a determined LAN attacker needs ~1000 years of + // sustained guessing to exhaust the space — long enough that user + // rotation (2-min expiry) and manual revocation dominate. + private static let pairFailuresBeforeCodeRotation = 3 + private static let pairFailuresBeforeBlock = 10 + private static let pairFailureWindow: TimeInterval = 300 + private static let pairBlockDuration: TimeInterval = 300 + // Max request size we accept. The four JSON bodies this endpoint handles + // are all tiny; anything bigger than 64 KiB is pathological. + private static let maxRequestBytes = 64 * 1024 + private let queue = DispatchQueue(label: "app.openisland.watch.http", qos: .userInitiated) // Pairing state @@ -123,6 +136,14 @@ public final class WatchHTTPEndpoint: @unchecked Sendable { private var pairingCodeGeneratedAt: Date = .distantPast private var validTokens: Set = [] + // Per-peer brute-force accounting. Keyed by peer IP (not port) so a + // determined attacker can't sidestep by rotating source ports. + private struct PairAttemptLedger { + var failures: [Date] = [] + var blockedUntil: Date = .distantPast + } + private var pairAttempts: [String: PairAttemptLedger] = [:] + // SSE connections private var sseConnections: [UUID: NWConnection] = [:] @@ -248,11 +269,15 @@ public final class WatchHTTPEndpoint: @unchecked Sendable { private func handleNewConnection(_ connection: NWConnection) { connection.start(queue: queue) - receiveHTTPRequest(on: connection) + receiveHTTPRequest(on: connection, buffer: Data()) } - private func receiveHTTPRequest(on connection: NWConnection) { - connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, error in + /// Accumulate request bytes until we have full headers (CRLFCRLF) plus + /// the Content-Length body. Previously a single `receive` was assumed + /// to carry the whole request; any TCP fragmentation or body > the + /// first chunk silently produced a parse failure. + private func receiveHTTPRequest(on connection: NWConnection, buffer: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] content, _, isComplete, error in guard let self else { return } if let error { @@ -261,27 +286,57 @@ public final class WatchHTTPEndpoint: @unchecked Sendable { return } - guard let data = content, !data.isEmpty else { + var accumulated = buffer + if let chunk = content, !chunk.isEmpty { + accumulated.append(chunk) + } + + if accumulated.count > Self.maxRequestBytes { + self.sendHTTPResponse(connection: connection, status: "413 Payload Too Large", body: #"{"error":"request too large"}"#) + return + } + + let separator = Data("\r\n\r\n".utf8) + guard let headerEnd = accumulated.range(of: separator) else { if isComplete { connection.cancel() + return } + self.receiveHTTPRequest(on: connection, buffer: accumulated) return } - self.routeHTTPRequest(data: data, connection: connection) - } - } + let headerData = accumulated.subdata(in: 0..= contentLength { + let body: String? = contentLength > 0 + ? String(data: bodySoFar.subdata(in: 0.. Date() { + sendHTTPResponse(connection: connection, status: "429 Too Many Requests", body: #"{"error":"too many failed attempts"}"#) + return + } + guard let body, let bodyData = body.data(using: .utf8), let request = try? JSONDecoder().decode(WatchPairRequest.self, from: bodyData) else { sendHTTPResponse(connection: connection, status: "400 Bad Request", body: #"{"error":"invalid body"}"#) @@ -316,16 +378,16 @@ public final class WatchHTTPEndpoint: @unchecked Sendable { return } - guard request.code == currentPairingCode else { + guard Self.constantTimeEquals(request.code, currentPairingCode) else { + recordPairFailure(peerIP: peerIP) sendHTTPResponse(connection: connection, status: "403 Forbidden", body: #"{"error":"invalid pairing code"}"#) return } - // Generate token + // Success: wipe the peer's failure ledger, rotate the code, issue token. + pairAttempts.removeValue(forKey: peerIP) let token = UUID().uuidString validTokens.insert(token) - - // Regenerate pairing code after successful pair regeneratePairingCodeUnsafe() let response = WatchPairResponse(token: token) @@ -444,16 +506,64 @@ public final class WatchHTTPEndpoint: @unchecked Sendable { return validTokens.contains(token) } - // MARK: - Private: HTTP Helpers + // MARK: - Private: Brute-force accounting + + /// Must be called on `queue`. + private func recordPairFailure(peerIP: String) { + let now = Date() + var ledger = pairAttempts[peerIP] ?? PairAttemptLedger() + + ledger.failures = ledger.failures.filter { now.timeIntervalSince($0) < Self.pairFailureWindow } + ledger.failures.append(now) + + if ledger.failures.count >= Self.pairFailuresBeforeBlock { + ledger.blockedUntil = now.addingTimeInterval(Self.pairBlockDuration) + regeneratePairingCodeUnsafe() + Self.logger.warning("Pair attempts from \(peerIP, privacy: .public) blocked for \(Int(Self.pairBlockDuration))s after \(ledger.failures.count) failures") + } else if ledger.failures.count >= Self.pairFailuresBeforeCodeRotation { + regeneratePairingCodeUnsafe() + Self.logger.info("Rotated pairing code after \(ledger.failures.count) failures from \(peerIP, privacy: .public)") + } - private func parseHTTPRequest(_ raw: String) -> (method: String, path: String, headers: [String: String], body: String?) { - let parts = raw.components(separatedBy: "\r\n\r\n") - let headerSection = parts[0] - let body = parts.count > 1 ? parts[1] : nil + pairAttempts[peerIP] = ledger + } + + private static func peerIP(for connection: NWConnection) -> String { + switch connection.endpoint { + case let .hostPort(host, _): + switch host { + case let .ipv4(addr): + return "\(addr)" + case let .ipv6(addr): + return "\(addr)" + case let .name(name, _): + return name + @unknown default: + return "unknown" + } + default: + return "unknown" + } + } - let lines = headerSection.components(separatedBy: "\r\n") + /// Constant-time string comparison to avoid leaking pairing code prefix + /// via timing side channels. Length mismatch short-circuits because the + /// code length is a fixed, attacker-known constant anyway. + private static func constantTimeEquals(_ a: String, _ b: String) -> Bool { + let ab = Array(a.utf8) + let bb = Array(b.utf8) + guard ab.count == bb.count else { return false } + var diff: UInt8 = 0 + for i in 0.. (method: String, path: String, headers: [String: String]) { + let lines = header.components(separatedBy: "\r\n") guard let requestLine = lines.first else { - return ("", "", [:], nil) + return ("", "", [:]) } let requestParts = requestLine.split(separator: " ", maxSplits: 2) @@ -463,27 +573,22 @@ public final class WatchHTTPEndpoint: @unchecked Sendable { var headers: [String: String] = [:] for line in lines.dropFirst() { if let colonIndex = line.firstIndex(of: ":") { - let key = String(line[line.startIndex..