Skip to content

[Security follow-up C-5] Rate-limit MCP pairing attempts (token bucket, client-side)Β #46

@hoainho

Description

@hoainho

🌟 Before claiming this issue

Two quick steps before you open a PR:

  1. ⭐ Star the repository β€” low-friction signal that you'll follow through
  2. πŸ’¬ Comment I'll take this (or similar) below β€” prevents two contributors racing on the same issue

Full policy: CONTRIBUTING.md β†’ How to claim. If a claim is older than 7 days with no PR, you can reclaim it politely.


Tracks follow-up #5 from PR #17 security review. Blocked on mcp-server-v1 M3 (~Jul 28, 2026).

What

MCPPairingPanel currently allows unlimited pairing attempts. An attacker with access to the panel can brute-force tokens at hundreds of requests/sec. Add client-side rate limiting (token bucket) so failed attempts back off.

Why

Even with timing-safe comparison (C-2) and expiration (C-4), an unlimited attempt rate makes brute-force feasible at scale. The server should also rate-limit (defense in depth), but the client-side rate-limit short-circuits attacks before they reach the network β€” saving server resources AND signaling "stop trying" UX.

Acceptance criteria

  • Failed validate token attempts increment a counter stored in chrome.storage.session keyed by pairing_attempts_v1
  • After N=5 failures in W=60s, the Submit button is disabled with a countdown timer ("Too many attempts β€” try again in Xs")
  • Successful pairing resets the counter
  • Token bucket refills 1 attempt per 12s (so users who fat-fingered the token aren't locked out forever)
  • Unit test: 5 failures in <60s β†’ 6th attempt blocked. Wait 12s β†’ 1 attempt restored. Successful attempt β†’ counter reset.

Implementation hint

const MAX_ATTEMPTS = 5;
const WINDOW_MS = 60_000;
const REFILL_INTERVAL_MS = 12_000;

type RateLimitState = {
  failures: number;
  windowStart: number; // ms epoch
};

function checkRateLimit(state: RateLimitState): { allowed: boolean; retryAfterMs?: number } {
  const now = Date.now();
  const elapsed = now - state.windowStart;
  // Refill: 1 attempt per 12s elapsed
  const refilled = Math.min(MAX_ATTEMPTS, state.failures - Math.floor(elapsed / REFILL_INTERVAL_MS));
  if (refilled < MAX_ATTEMPTS) {
    return { allowed: true };
  }
  return { allowed: false, retryAfterMs: REFILL_INTERVAL_MS - (elapsed % REFILL_INTERVAL_MS) };
}

UX hint

When the button is disabled, show the countdown via a useState + setInterval:

{rateLimited && (
  <p className="error">
    Too many attempts. Try again in <span className="countdown">{secondsRemaining}s</span>.
  </p>
)}

Context

Metadata

Metadata

Assignees

No one assigned

    Labels

    help wantedExtra attention is neededsecuritySecurity-related (see SECURITY.md for vulnerabilities)

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions