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
- Process starts with
gunicorn -c gunicorn.conf.py app:app and RATELIMIT_STORAGE_URI=memory://.
config._validate_config() parses the config AST, sees static workers = 1, and does not fail closed.
- Gunicorn executes the config module;
asyncio.to_thread runs during import and sets workers to the fork count actually used.
- 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(...))).
Summary
Severity: High
Location:
config.pyAn operator-controlled
gunicorn.conf.pycan keepworkers = 1in the AST while mutatingworkersat import time vialoop.run_until_complete(asyncio.to_thread(...))(includingasyncio.new_event_loop()orasyncio.get_event_loop()). The panel startup guard treats the config as a static single-worker deployment and allowsRATELIMIT_STORAGE_URI=memory://, but Gunicorn loads the config module withworkersalready raised (verified: runtimeworkers == 4after exec while AST scan reports(1, False)on commit952249e9460fac4077b1d3635c3091fa12923c12).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.:
Reachability
gunicorn -c gunicorn.conf.py app:appandRATELIMIT_STORAGE_URI=memory://.config._validate_config()parses the config AST, sees staticworkers = 1, and does not fail closed.asyncio.to_threadruns during import and setsworkersto the fork count actually used.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 directasyncio.run(asyncio.to_thread(...))).