Skip to content

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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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