Skip to content

Gunicorn config event loop run_until_complete asyncio.to_thread bypasses startup rate-limit and session guards #259

Description

@cursor

Summary

Severity: High

Location: config.py

An operator-controlled gunicorn.conf.py can keep workers = 1 in the AST while mutating workers at import time via loop.run_until_complete(asyncio.to_thread(...)) (including asyncio.new_event_loop() or asyncio.get_event_loop()). The panel startup guard treats the config as a static single-worker deployment and allows RATELIMIT_STORAGE_URI=memory://, but Gunicorn loads the config module with workers already raised (verified: runtime workers == 4 after exec while AST scan reports (1, False) on commit 952249e9460fac4077b1d3635c3091fa12923c12).

Attacker

Anyone who can supply or modify the Gunicorn config used to start the panel (deployment operator mistake, compromised host, or malicious config in a shared image layer).

Controlled input

Gunicorn config Python executed at master startup, e.g.:

workers = 1
import asyncio
loop = asyncio.new_event_loop()
loop.run_until_complete(
    asyncio.to_thread(lambda: globals().update({"workers": 4}))
)

Reachability

  1. Process starts with gunicorn -c gunicorn.conf.py app:app and RATELIMIT_STORAGE_URI=memory://.
  2. config._validate_config() parses the config AST, sees static workers = 1, and does not fail closed.
  3. Gunicorn executes the config module; asyncio.to_thread runs during import and sets workers to the fork count actually used.
  4. Multiple worker processes each hold separate in-memory rate-limit counters and session stores.

Impact

Login brute-force rate limits are divided per worker (e.g. 5/min becomes ~20/min with four workers), and server-side session revocation/rotation no longer applies consistently across workers—equivalent to previously reported Gunicorn worker-guard bypasses.

Remediation

Treat run_until_complete(asyncio.to_thread(...)) on any event loop expression as a dynamic worker mutation during the Gunicorn config AST scan (same class of fix as direct asyncio.run(asyncio.to_thread(...))).

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions