Skip to content

Security: /pair lacks replay defense + per-IP rate limit — LAN flood / signature replay#12

Draft
mimeding wants to merge 2 commits into
mainfrom
cursor/pair-nonce-replay-ratelimit-2812
Draft

Security: /pair lacks replay defense + per-IP rate limit — LAN flood / signature replay#12
mimeding wants to merge 2 commits into
mainfrom
cursor/pair-nonce-replay-ratelimit-2812

Conversation

@mimeding

Copy link
Copy Markdown
Owner

Summary

Why this matters (business)

/pair is the one endpoint that can hand out a long-lived master-scoped API key to an unauthenticated caller. It has to be unauthenticated by design — an unpaired peer needs a way to introduce itself. The trust gate is the user clicking "Approve" on the on-device prompt.

That gate is the only line of defense, which means everything around it needs to make abuse expensive. Two specific gaps:

  1. Signature replay. A signed pairing request can be captured on the LAN (or in a memory dump of a compromised process) and re-played to the same agent. The signature is still valid. If the user is in a "rapid-approve" pattern (multi-device setup, family members on the same LAN, demo environments) the second approval is more likely than you'd think, and it yields a second, independently-revocable master key the legitimate user doesn't know about.
  2. Flood / DoS. A LAN peer can spam /pair. Each request triggers a secp256k1 signature recovery on the event loop, then (if the request is well-formed) a SwiftUI approval prompt. No rate limit at all today — the only ceiling is how fast the attacker can post.

What's wrong (technical)

The current handler does signature verification → agent resolution → approval prompt → key generation. There's no nonce tracking, so the same (connector, nonce, signature) triple is accepted twice; and no per-IP throttle, so secp256k1 work scales with attacker throughput.

Fix

Add a single thread-safe singleton — PairingReplayGuard — with two in-memory bounded stores:

public func allowAttempt(ip: String) -> Bool        // sliding 60s / max 10 per IP
public func consumeNonce(connector: String, nonce: String) -> Bool  // 5-minute TTL replay set

Both stores prune lazily on every access, so memory is bounded by the rate of attempts (not by lifetime traffic).

Wire them into handlePairEndpoint:

  • allowAttempt(ip:) immediately after JSON decode, before any crypto. Failure returns 429 + a human-readable error. This bounds the per-IP secp256k1 cost.
  • consumeNonce(connector:nonce:) immediately after signature verification. Calling it post-sig-verify means a flooder with bad signatures can't poison the nonce store with junk. Failure returns 401 + "Replayed pairing request."

Defaults tuned for the LAN-pairing use case: a real flow uses 1 attempt per pairing, so 10/min/IP is generous; the 5-minute nonce TTL is comfortably above realistic user-approval round-trip time and well below any reasonable replay coordination window.

Test coverage

PairingReplayGuardTests (in Tests/Networking/) covers:

  • First attempt from a fresh IP is allowed.
  • After rateLimitMax attempts in the window, additional attempts from the same IP are rejected.
  • Rate limit is per-IP — saturating IP A does not block IP B.
  • Same (connector, nonce) consumed twice → second call returns false.
  • Same nonce under a different connector is not a replay — important because the connector address is part of the trust identity.
  • _reset() clears both stores (used by other tests to isolate state).

Scope decisions

  • The 401-vs-429 split mirrors typical bearer-token conventions (rate-limit = 429, identity / replay problem = 401). Worth checking with the maintainer if they prefer a different status code.
  • Stores are in-memory only. A process restart clears them, which is acceptable here because the underlying crypto requires a fresh nonce on the next attempt anyway (the user has to re-confirm).
  • This PR does not also flip the new key from master-scoped to agent-scoped (a separate suggestion from the audit). That's a behavior change to the wire response and deserves its own focused PR with a migration plan for already-paired peers.

Changes

  • Behavior change (rejects replayed pairing requests and bursts beyond 10/min/IP)
  • UI change
  • Refactor / chore
  • Tests (new PairingReplayGuardTests)
  • Docs

Test Plan

cd Packages/OsaurusCore && swift test --filter PairingReplayGuardTests

Manually:

  1. Pair successfully from device A. Immediately replay the captured request body with curl -X POST ... to the same /pair. Expected: HTTP 401 Replayed pairing request. Previously: a second approval prompt + new master key minted.
  2. for i in {1..30}; do curl -X POST .../pair -d '{...}'; done from a single source IP. Expected: first 10 follow the normal flow, the rest get HTTP 429.
  3. Re-run from a different host on the LAN: counter is per-IP, so the second host pairs normally.

Checklist

  • I have read CONTRIBUTING.md
  • I added/updated tests where reasonable
  • I updated docs/README as needed (consider a follow-up to docs/SECURITY.md)
  • I verified build on macOS with Xcode 16.4+ (authored in a Linux sandbox; verified each touched file via swiftc -frontend -parse; the test relies only on Foundation types — no platform-specific frameworks)
Open in Web Open in Cursor 

cursoragent and others added 2 commits May 27, 2026 04:29
/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 <mimeding@users.noreply.github.com>
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 <mimeding@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants