Skip to content

[Security][Advanced] RateLimiter keys on plugin_id only, one client exhausts quota for all users globally #312

@anshul23102

Description

@anshul23102

Summary

RateLimiter.can_execute() in backend/secuscan/ratelimit.py tracks request history keyed by plugin_id alone. Because the key contains no per-client identifier, any single user who exhausts the rate limit for a plugin blocks every other user from running that same plugin for the entire window. This is a global resource starvation vulnerability, not a per-client throttle.

Affected File

backend/secuscan/ratelimit.pyRateLimiter.can_execute()

The history dict is indexed by plugin_id (e.g. "nmap"). When the call count for that key reaches the configured limit, can_execute() returns False for all callers regardless of their identity, until the oldest entry ages out.

Reproduction

  1. Send 50 rapid POST /tasks requests for plugin_id = "nmap" from client A (or a single unauthenticated IP).
  2. Client B submits a legitimate nmap scan.
  3. can_execute("nmap") returns False for client B even though client B has never submitted a single request this window.
  4. Client B receives a rate-limit error that is caused entirely by client A's activity.

Since there is no authentication on the API (see issue #266), any anonymous caller can trigger this for every plugin simultaneously with a small burst.

Impact

  • Denial of service: one attacker permanently occupies the rate-limit bucket for a plugin, making that plugin unavailable to all legitimate users.
  • Silent starvation: the error response looks like a normal rate-limit reply, so operators have no signal that the exhaustion is externally caused.
  • Scales across all plugins: a loop over all registered plugin_id values can take down every scan capability at once.

Fix

Key the history by (client_ip, plugin_id) or (user_id, plugin_id) so each client has its own independent quota:

# Before
history = self._history.setdefault(plugin_id, deque())

# After
bucket_key = (client_identifier, plugin_id)
history = self._history.setdefault(bucket_key, deque())

client_identifier should be the authenticated user ID when auth is present, or the remote IP as a fallback.

Environment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions