From a9ef02a742631ab45d9f9ac5cec6b39e5e923cde Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 02:06:44 +0000 Subject: [PATCH 1/3] fix(config): close Gunicorn worker-scan gaps for Thread.run, asyncio.to_thread, pool imap/starmap/apply_async Extend the static gunicorn.conf.py AST scan so import-time mutations through Thread.run(), asyncio.run(asyncio.to_thread(...)), ThreadPool.apply_async().get(), and ThreadPool imap/starmap are treated as dynamic worker assignments. This blocks memory:// rate-limit storage when those patterns can raise the real worker count across processes. Adds regression tests for the 2026-09-20 security review findings (#254-#257). Co-authored-by: Alexander Wagner --- config.py | 145 +++++++++++++++++- .../test_gunicorn_indirect_workers_bypass.py | 20 +++ 2 files changed, 162 insertions(+), 3 deletions(-) diff --git a/config.py b/config.py index 163309c..2f22c18 100644 --- a/config.py +++ b/config.py @@ -3662,6 +3662,8 @@ def _expression_is_mutating_lazy_iterator( active_names, ): return True + if _call_is_thread_pool_imap_iterator(expr, operator_bindings): + return True if _call_is_mutating_generator(expr, operator_bindings): return True if _name_is_mutating_lazy_iterator( @@ -4222,12 +4224,18 @@ def _thread_pool_map_arguments(call): return callback, iterables +_THREAD_POOL_CALLBACK_METHODS = frozenset({"map", "imap", "starmap"}) + + def _call_is_thread_pool_map_mutation(call, operator_bindings): - """Return True when a proven pool.map performs a workers mutation.""" + """Return True when a proven pool map-like method performs a workers mutation.""" if not isinstance(call, ast.Call): return False func = call.func - if not (isinstance(func, ast.Attribute) and func.attr == "map"): + if not ( + isinstance(func, ast.Attribute) + and func.attr in _THREAD_POOL_CALLBACK_METHODS + ): return False constructor = _thread_pool_receiver_constructor( func.value, @@ -4254,6 +4262,127 @@ def _call_is_thread_pool_map_mutation(call, operator_bindings): ) +def _call_is_thread_pool_apply_async_get_mutation(call, operator_bindings): + """Return True when apply_async(...).get() runs a workers mutation.""" + if not isinstance(call, ast.Call): + return False + func = call.func + if not (isinstance(func, ast.Attribute) and func.attr == "get"): + return False + apply_async = func.value + if not ( + isinstance(apply_async, ast.Call) + and isinstance(apply_async.func, ast.Attribute) + and apply_async.func.attr == "apply_async" + ): + return False + constructor = _thread_pool_receiver_constructor( + apply_async.func.value, + operator_bindings, + ) + if constructor is None: + return False + callback = apply_async.args[0] if apply_async.args else next( + ( + keyword.value + for keyword in apply_async.keywords + if keyword.arg in {"func", "callback"} + ), + None, + ) + if callback is None: + return False + if _thread_pool_constructor_has_mutating_initializer( + constructor, + operator_bindings, + ): + return True + return _thread_pool_callback_mutates_workers(callback, operator_bindings) + + +def _call_is_thread_pool_imap_iterator(expr, operator_bindings): + """Return True when a proven pool.imap yields mutating callbacks.""" + if not isinstance(expr, ast.Call): + return False + func = expr.func + if not (isinstance(func, ast.Attribute) and func.attr == "imap"): + return False + constructor = _thread_pool_receiver_constructor( + func.value, + operator_bindings, + ) + if constructor is None: + return False + callback, iterables = _thread_pool_map_arguments(expr) + if callback is None or not iterables: + return False + if _thread_pool_constructor_has_mutating_initializer( + constructor, + operator_bindings, + ): + return True + if _thread_pool_callback_mutates_workers(callback, operator_bindings): + return True + return any( + _expression_is_mutating_lazy_iterator( + iterable, + operator_bindings, + ) + for iterable in iterables + ) + + +def _asyncio_module_active_at_line(node, operator_bindings, reference_line): + """Return True when ``node`` resolves to the asyncio module at ``reference_line``.""" + if not isinstance(node, ast.Name): + return False + asyncio_events = operator_bindings[47] if len(operator_bindings) > 47 else {} + return _imported_alias_is_active(asyncio_events, node.id, reference_line) + + +def _call_is_asyncio_run_to_thread_mutation(call, operator_bindings): + """Return True when asyncio.run(asyncio.to_thread(...)) mutates workers.""" + if not isinstance(call, ast.Call): + return False + func = call.func + if not (isinstance(func, ast.Attribute) and func.attr == "run"): + return False + reference_line = getattr(call, "lineno", 0) + if not _asyncio_module_active_at_line( + func.value, + operator_bindings, + reference_line, + ): + return False + if not call.args: + return False + to_thread = call.args[0] + if not ( + isinstance(to_thread, ast.Call) + and isinstance(to_thread.func, ast.Attribute) + and to_thread.func.attr == "to_thread" + ): + return False + if not _asyncio_module_active_at_line( + to_thread.func.value, + operator_bindings, + reference_line, + ): + return False + target = to_thread.args[0] if to_thread.args else next( + ( + keyword.value + for keyword in to_thread.keywords + if keyword.arg == "func" + ), + None, + ) + return target is not None and _thread_pool_callback_mutates_workers( + target, + operator_bindings, + ) + + def _call_is_thread_pool_submit_mutation(call, operator_bindings): """Return True when ThreadPoolExecutor.submit schedules a risky callback.""" if not isinstance(call, ast.Call): @@ -4318,7 +4447,7 @@ def _expression_starts_mutating_thread( if ( isinstance(expr, ast.Call) and isinstance(expr.func, ast.Attribute) - and expr.func.attr == "start" + and expr.func.attr in {"start", "run"} ): owner = expr.func.value if isinstance(owner, ast.Name) and owner.id in active_thread_names: @@ -4487,6 +4616,8 @@ def _call_is_special_lazy_iterator_consumer(call, operator_bindings): ) or _call_is_thread_pool_map_mutation(call, operator_bindings) or _call_is_thread_pool_submit_mutation(call, operator_bindings) + or _call_is_thread_pool_apply_async_get_mutation(call, operator_bindings) + or _call_is_asyncio_run_to_thread_mutation(call, operator_bindings) or _attribute_call_consumes_mutating_lazy_iterator( call, operator_bindings, @@ -6439,6 +6570,14 @@ def _scan_gunicorn_config_worker_details(tree): *operator_bindings, import_time_workers_mutators, ) + asyncio_module_alias_events = _collect_imported_module_alias_events( + tree, + "asyncio", + ) + operator_bindings = ( + *operator_bindings, + asyncio_module_alias_events, + ) if _statements_start_mutating_thread( tree.body, operator_bindings, diff --git a/tests/test_gunicorn_indirect_workers_bypass.py b/tests/test_gunicorn_indirect_workers_bypass.py index 5008593..ff4795f 100644 --- a/tests/test_gunicorn_indirect_workers_bypass.py +++ b/tests/test_gunicorn_indirect_workers_bypass.py @@ -545,6 +545,26 @@ def test_codex_pr253_thread_pool_followups_are_dynamic(tmp_path, config_content) assert config._workers_from_gunicorn_config_path(str(config_file)) == (1, True) +# Security review 2026-09-20: Thread.run, asyncio.to_thread, pool imap/starmap/apply_async +@pytest.mark.parametrize( + "config_content", + [ + "workers = 1\nimport threading\nthreading.Thread(target=lambda: globals().update({'workers': 4})).run()\n", + "workers = 1\nimport asyncio\nasyncio.run(asyncio.to_thread(lambda: globals().update({'workers': 4})))\n", + "workers = 1\nfrom multiprocessing.pool import ThreadPool\nThreadPool(1).apply_async(lambda: globals().update({'workers': 4})).get()\n", + "workers = 1\nfrom multiprocessing.pool import ThreadPool\nlist(ThreadPool(1).imap(lambda _: globals().update({'workers': 4}), [1]))\n", + "workers = 1\nfrom multiprocessing.pool import ThreadPool\nThreadPool(1).starmap(lambda _: globals().update({'workers': 4}), [(1,)])\n", + ], +) +def test_security_review_sep20_thread_asyncio_pool_gaps_are_dynamic( + tmp_path, + config_content, +): + config_file = tmp_path / "gunicorn.conf.py" + config_file.write_text(config_content, encoding="utf-8") + assert config._workers_from_gunicorn_config_path(str(config_file)) == (1, True) + + def test_codex_pr253_custom_submitter_stays_static(tmp_path): config_file = tmp_path / "gunicorn.conf.py" config_file.write_text( From cccb3f5e49a6313921d5dd85f26997435f89ab69 Mon Sep 17 00:00:00 2001 From: Alexander Wagner Date: Sun, 20 Sep 2026 14:49:42 +0200 Subject: [PATCH 2/3] fix(config): address PR #258 review findings --- config.py | 159 +++++++++++++----- .../test_gunicorn_indirect_workers_bypass.py | 18 ++ 2 files changed, 134 insertions(+), 43 deletions(-) diff --git a/config.py b/config.py index 2f22c18..e5b2929 100644 --- a/config.py +++ b/config.py @@ -3727,28 +3727,39 @@ def _attribute_call_consumes_mutating_lazy_iterator(call, operator_bindings): ) -def _thread_constructor_is_active(call, operator_bindings): - """Return whether a Thread constructor resolves to the threading module.""" - func = call.func - reference_line = getattr(call, "lineno", 0) - if isinstance(func, ast.Attribute) and func.attr == "Thread": +def _thread_class_reference_is_active( + reference, + operator_bindings, + reference_line, +): + """Return whether an expression resolves to threading.Thread.""" + if isinstance(reference, ast.Attribute) and reference.attr == "Thread": module_events = operator_bindings[38] if len(operator_bindings) > 38 else {} return _module_alias_active_at_line( - func.value, + reference.value, set(), module_events, reference_line, ) - if isinstance(func, ast.Name): + if isinstance(reference, ast.Name): alias_events = operator_bindings[39] if len(operator_bindings) > 39 else {} return _imported_alias_is_active( alias_events, - func.id, + reference.id, reference_line, ) return False +def _thread_constructor_is_active(call, operator_bindings): + """Return whether a Thread constructor resolves to the threading module.""" + return _thread_class_reference_is_active( + call.func, + operator_bindings, + getattr(call, "lineno", 0), + ) + + def _thread_target_mutates_workers(target, operator_bindings, mutator_names): """Return whether one Thread target can mutate workers.""" if isinstance(target, ast.Lambda): @@ -4167,6 +4178,9 @@ def _thread_pool_callback_mutates_workers(callback, operator_bindings): """Return whether one pool callback can mutate module workers.""" if isinstance(callback, ast.Lambda): return _lambda_mutates_workers(callback, operator_bindings) + namespace_aliases = operator_bindings[2] if len(operator_bindings) > 2 else set() + if _expression_is_namespace_update_reference(callback, namespace_aliases): + return True mutator_names = operator_bindings[46] if len(operator_bindings) > 46 else set() return isinstance(callback, ast.Name) and callback.id in mutator_names @@ -4224,7 +4238,9 @@ def _thread_pool_map_arguments(call): return callback, iterables -_THREAD_POOL_CALLBACK_METHODS = frozenset({"map", "imap", "starmap"}) +_THREAD_POOL_CALLBACK_METHODS = frozenset( + {"map", "imap", "imap_unordered", "starmap"} +) def _call_is_thread_pool_map_mutation(call, operator_bindings): @@ -4262,50 +4278,56 @@ def _call_is_thread_pool_map_mutation(call, operator_bindings): ) -def _call_is_thread_pool_apply_async_get_mutation(call, operator_bindings): - """Return True when apply_async(...).get() runs a workers mutation.""" - if not isinstance(call, ast.Call): - return False - func = call.func - if not (isinstance(func, ast.Attribute) and func.attr == "get"): - return False - apply_async = func.value +def _call_is_thread_pool_apply_async_mutation(call, operator_bindings): + """Return True when apply_async schedules a workers-mutating callback.""" if not ( - isinstance(apply_async, ast.Call) - and isinstance(apply_async.func, ast.Attribute) - and apply_async.func.attr == "apply_async" + isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and call.func.attr == "apply_async" ): return False constructor = _thread_pool_receiver_constructor( - apply_async.func.value, + call.func.value, operator_bindings, ) if constructor is None: return False - callback = apply_async.args[0] if apply_async.args else next( + task = call.args[0] if call.args else next( ( keyword.value - for keyword in apply_async.keywords - if keyword.arg in {"func", "callback"} + for keyword in call.keywords + if keyword.arg == "func" ), None, ) - if callback is None: + if task is None: return False if _thread_pool_constructor_has_mutating_initializer( constructor, operator_bindings, ): return True - return _thread_pool_callback_mutates_workers(callback, operator_bindings) + callbacks = [task] + callbacks.extend( + keyword.value + for keyword in call.keywords + if keyword.arg in {"callback", "error_callback"} + ) + return any( + _thread_pool_callback_mutates_workers(callback, operator_bindings) + for callback in callbacks + ) def _call_is_thread_pool_imap_iterator(expr, operator_bindings): - """Return True when a proven pool.imap yields mutating callbacks.""" + """Return True when a proven pool imap variant yields mutating callbacks.""" if not isinstance(expr, ast.Call): return False func = expr.func - if not (isinstance(func, ast.Attribute) and func.attr == "imap"): + if not ( + isinstance(func, ast.Attribute) + and func.attr in {"imap", "imap_unordered"} + ): return False constructor = _thread_pool_receiver_constructor( func.value, @@ -4340,16 +4362,39 @@ def _asyncio_module_active_at_line(node, operator_bindings, reference_line): return _imported_alias_is_active(asyncio_events, node.id, reference_line) +def _asyncio_helper_active_at_line( + node, + helper_name, + operator_bindings, + reference_line, +): + """Resolve module-qualified or directly imported asyncio helpers.""" + if isinstance(node, ast.Attribute) and node.attr == helper_name: + return _asyncio_module_active_at_line( + node.value, + operator_bindings, + reference_line, + ) + if not isinstance(node, ast.Name): + return False + event_index = {"run": 48, "to_thread": 49}.get(helper_name) + if event_index is None or len(operator_bindings) <= event_index: + return False + return _imported_alias_is_active( + operator_bindings[event_index], + node.id, + reference_line, + ) + + def _call_is_asyncio_run_to_thread_mutation(call, operator_bindings): - """Return True when asyncio.run(asyncio.to_thread(...)) mutates workers.""" + """Return True when asyncio run/to_thread execution mutates workers.""" if not isinstance(call, ast.Call): return False - func = call.func - if not (isinstance(func, ast.Attribute) and func.attr == "run"): - return False reference_line = getattr(call, "lineno", 0) - if not _asyncio_module_active_at_line( - func.value, + if not _asyncio_helper_active_at_line( + call.func, + "run", operator_bindings, reference_line, ): @@ -4359,14 +4404,12 @@ def _call_is_asyncio_run_to_thread_mutation(call, operator_bindings): to_thread = call.args[0] if not ( isinstance(to_thread, ast.Call) - and isinstance(to_thread.func, ast.Attribute) - and to_thread.func.attr == "to_thread" - ): - return False - if not _asyncio_module_active_at_line( - to_thread.func.value, - operator_bindings, - reference_line, + and _asyncio_helper_active_at_line( + to_thread.func, + "to_thread", + operator_bindings, + reference_line, + ) ): return False target = to_thread.args[0] if to_thread.args else next( @@ -4458,6 +4501,24 @@ def _expression_starts_mutating_thread( mutator_names, ): return True + if ( + expr.func.attr == "run" + and expr.args + and _thread_class_reference_is_active( + owner, + operator_bindings, + getattr(expr, "lineno", 0), + ) + ): + thread = expr.args[0] + if isinstance(thread, ast.Name) and thread.id in active_thread_names: + return True + if isinstance(thread, ast.Call) and _thread_constructor_has_mutating_target( + thread, + operator_bindings, + mutator_names, + ): + return True return any( _expression_starts_mutating_thread( child, @@ -4616,7 +4677,7 @@ def _call_is_special_lazy_iterator_consumer(call, operator_bindings): ) or _call_is_thread_pool_map_mutation(call, operator_bindings) or _call_is_thread_pool_submit_mutation(call, operator_bindings) - or _call_is_thread_pool_apply_async_get_mutation(call, operator_bindings) + or _call_is_thread_pool_apply_async_mutation(call, operator_bindings) or _call_is_asyncio_run_to_thread_mutation(call, operator_bindings) or _attribute_call_consumes_mutating_lazy_iterator( call, @@ -6574,9 +6635,21 @@ def _scan_gunicorn_config_worker_details(tree): tree, "asyncio", ) + asyncio_run_alias_events = _collect_imported_name_alias_events( + tree, + "asyncio", + {"run"}, + ) + asyncio_to_thread_alias_events = _collect_imported_name_alias_events( + tree, + "asyncio", + {"to_thread"}, + ) operator_bindings = ( *operator_bindings, asyncio_module_alias_events, + asyncio_run_alias_events, + asyncio_to_thread_alias_events, ) if _statements_start_mutating_thread( tree.body, diff --git a/tests/test_gunicorn_indirect_workers_bypass.py b/tests/test_gunicorn_indirect_workers_bypass.py index ff4795f..6ab7584 100644 --- a/tests/test_gunicorn_indirect_workers_bypass.py +++ b/tests/test_gunicorn_indirect_workers_bypass.py @@ -565,6 +565,24 @@ def test_security_review_sep20_thread_asyncio_pool_gaps_are_dynamic( assert config._workers_from_gunicorn_config_path(str(config_file)) == (1, True) +# Codex review follow-up 2026-09-20: remaining Thread/asyncio/ThreadPool gaps +@pytest.mark.parametrize( + "config_content", + [ + "workers = 1\nfrom multiprocessing.pool import ThreadPool\nlist(ThreadPool(1).imap_unordered(lambda _: globals().update({'workers': 4}), [1]))\n", + "workers = 1\nfrom multiprocessing.pool import ThreadPool\npool = ThreadPool(1)\nresult = pool.apply_async(lambda: globals().update({'workers': 4}))\nresult.get()\n", + "workers = 1\nfrom multiprocessing.pool import ThreadPool\nThreadPool(1).apply_async(lambda: 0, callback=lambda _: globals().update({'workers': 4})).get()\n", + "workers = 1\nfrom asyncio import run, to_thread\nrun(to_thread(lambda: globals().update({'workers': 4})))\n", + "workers = 1\nimport threading\nt = threading.Thread(target=lambda: globals().update({'workers': 4}))\nthreading.Thread.run(t)\n", + "workers = 1\nimport asyncio\nasyncio.run(asyncio.to_thread(globals().update, {'workers': 4}))\n", + ], +) +def test_codex_pr258_remaining_findings_are_dynamic(tmp_path, config_content): + config_file = tmp_path / "gunicorn.conf.py" + config_file.write_text(config_content, encoding="utf-8") + assert config._workers_from_gunicorn_config_path(str(config_file)) == (1, True) + + def test_codex_pr253_custom_submitter_stays_static(tmp_path): config_file = tmp_path / "gunicorn.conf.py" config_file.write_text( From 0abfe12f19f7049393078fe21839bc882895937c Mon Sep 17 00:00:00 2001 From: Alexander Wagner Date: Sun, 20 Sep 2026 14:54:47 +0200 Subject: [PATCH 3/3] refactor(config): reduce thread scan complexity --- config.py | 93 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 62 insertions(+), 31 deletions(-) diff --git a/config.py b/config.py index e5b2929..1578920 100644 --- a/config.py +++ b/config.py @@ -4478,6 +4478,62 @@ def _call_is_thread_pool_constructor_initializer_mutation( ) ) +def _thread_expression_is_active( + thread, + active_thread_names, + operator_bindings, + mutator_names, +): + """Return whether an expression resolves to a workers-mutating Thread.""" + if isinstance(thread, ast.Name): + return thread.id in active_thread_names + return isinstance(thread, ast.Call) and _thread_constructor_has_mutating_target( + thread, + operator_bindings, + mutator_names, + ) + + +def _thread_call_starts_mutating_target( + call, + active_thread_names, + operator_bindings, + mutator_names, +): + """Return whether one Thread start/run call executes a risky target.""" + if not ( + isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and call.func.attr in {"start", "run"} + ): + return False + + owner = call.func.value + if _thread_expression_is_active( + owner, + active_thread_names, + operator_bindings, + mutator_names, + ): + return True + + return ( + call.func.attr == "run" + and bool(call.args) + and _thread_class_reference_is_active( + owner, + operator_bindings, + getattr(call, "lineno", 0), + ) + and _thread_expression_is_active( + call.args[0], + active_thread_names, + operator_bindings, + mutator_names, + ) + ) + + def _expression_starts_mutating_thread( expr, active_thread_names, @@ -4487,38 +4543,13 @@ def _expression_starts_mutating_thread( """Return True when an evaluated expression starts a risky Thread.""" if isinstance(expr, ast.Lambda): return False - if ( - isinstance(expr, ast.Call) - and isinstance(expr.func, ast.Attribute) - and expr.func.attr in {"start", "run"} + if _thread_call_starts_mutating_target( + expr, + active_thread_names, + operator_bindings, + mutator_names, ): - owner = expr.func.value - if isinstance(owner, ast.Name) and owner.id in active_thread_names: - return True - if isinstance(owner, ast.Call) and _thread_constructor_has_mutating_target( - owner, - operator_bindings, - mutator_names, - ): - return True - if ( - expr.func.attr == "run" - and expr.args - and _thread_class_reference_is_active( - owner, - operator_bindings, - getattr(expr, "lineno", 0), - ) - ): - thread = expr.args[0] - if isinstance(thread, ast.Name) and thread.id in active_thread_names: - return True - if isinstance(thread, ast.Call) and _thread_constructor_has_mutating_target( - thread, - operator_bindings, - mutator_names, - ): - return True + return True return any( _expression_starts_mutating_thread( child,