Skip to content

Commit 605611b

Browse files
authored
Fix GHSA-x6pr-233j-x5cw: warn before executing an FL-provisioned bundle config (#9078)
## Summary Fixes GHSA-x6pr-233j-x5cw: https://github.com/Project-MONAI/MONAI/security/advisories/GHSA-x6pr-233j-x5cw Also closes GHSA-wvpx-5qmp-46g3: https://github.com/Project-MONAI/MONAI/security/advisories/GHSA-wvpx-5qmp-46g3 `MonaiAlgo`/`MonaiAlgoStats` run a bundle whose entire app directory is provisioned by the FL system. `initialize(extra)` resolves `bundle_root = os.path.join(extra[APP_ROOT], self.bundle_root)` — `APP_ROOT` is supplied by the aggregation server — then builds a `ConfigWorkflow` over `<app_root>/configs/train.json` and runs its `initialize` expressions. Because FL tasks are dispatched per round and executed with no human in the loop, a malicious or compromised server gets silent code execution on every participating client. The `UserWarning` added in #9057 for GHSA-873f-pvrv-4x83 lives in `create_workflow()`. This path never calls it — `MonaiAlgo` constructs `ConfigWorkflow` directly — so nothing warned here at all. ### Design Executing the config stays unblocked, for the same reason the `trust_remote_code` flag was dropped from #9057: MONAI has no mechanism to establish whether a bundle is trustworthy, so a flag mostly teaches operators to set it once and forget about it. Both `initialize()` methods now warn, naming the trust boundary (`extra[APP_ROOT]`) and the absence of per-round human interaction. The one behaviour change is narrowly scoped, and it targets the sink with no functional role in FL. `ConfigWorkflow` defaults `logging_file` to the bundle's own `configs/logging.conf` and passes it to `logging.config.fileConfig`, which `eval()`s the INI's `class=`/`args=` fields. That is code execution at construction time, before any config is parsed, and it hides in a plain INI rather than the MONAI `$`-DSL — easy to miss when reviewing a bundle. The FL client now treats `extra[ExtraItems.LOGGING_FILE]` as `False` both when the key is absent and when it is explicitly `None`, so a server-written `logging.conf` is never applied. `None` needs the same treatment as absent because it was the pre-PR default and `ConfigWorkflow` reads it as "fall back to the bundle's own `configs/logging.conf`" — exactly the file this change exists to keep away from `fileConfig`. An FL system that wants bundle logging passes an explicit path, through the key that already exists for it. The `fileConfig` warning sits inside the branch that actually calls it, not at the top of `__init__`. That keeps it truthful (nothing runs when the file is absent or logging is disabled, both common) and avoids double-warning callers who already got the `create_workflow()` warning, which is about `_target_`/`$` rather than the INI. ### Changes - `monai/fl/client/monai_algo.py`: warning in both `initialize()` methods; `ExtraItems.LOGGING_FILE` treated as `False` when absent or explicitly `None`; security notes on both class docstrings; both `initialize()` docstrings rewritten for the new default (this also fixes a `diable` typo). - `monai/bundle/workflows.py`: `_warn_logging_file_execution()` called immediately before each of the two `fileConfig` invocations; `logging_file` docstring entries updated on `BundleWorkflow`, `PythonicWorkflow` and `ConfigWorkflow`. - `tests/fl/monai_algo/test_fl_monai_algo.py`: `TestFLMonaiAlgoWarnsOnProvisionedConfig` — stages an app whose `train.json` and `logging.conf` each drop a distinct marker, for both `MonaiAlgo` and `MonaiAlgoStats`. Asserts the config still executes with the advisory warning; that the server's `logging.conf` no longer does, whether the key is absent or explicitly `None`; that an explicit path opts back in; and that no `fileConfig` warning fires when nothing is executed. - `tests/bundle/test_bundle_workflow.py`: `TestConfigWorkflowWarnsOnLoggingConf` — a bundle's default `configs/logging.conf` warns and still applies; `logging_file=False` neither warns nor applies it. Both new test classes snapshot and restore the root logger, closing any handler `fileConfig` installs. The suite runs in one process, so without that they would leak a root handler and formatter into every test that follows. ## Test plan - [x] `python -m unittest tests.fl.monai_algo.test_fl_monai_algo` — 17 passed - [x] `python -m unittest tests.fl.test_fl_monai_algo_stats` — 3 passed - [x] `python -m unittest tests.bundle.test_bundle_workflow.TestConfigWorkflowWarnsOnLoggingConf` — 2 passed - [x] `python -m unittest tests.bundle.test_bundle_download.TestLoadWarnsOnConfigExecution` — 6 passed, no double-warn regression on the #9057 fix - [x] Each new assertion checked against the unpatched code first — the advisory's payload writes its marker via the bundle config and via `logging.conf` before the change, and only via the bundle config after it - [x] Root logger verified identical before and after both new test classes run - [x] `black`, `isort`, `ruff` clean on the changed files ### Types of changes - [ ] Non-breaking change (fix or new feature that would not break existing functionality). - [x] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [x] In-line docstrings updated. The breaking-change box is for the `LOGGING_FILE` default only: an FL deployment relying on the bundle shipping its own `logging.conf` now has to pass the path explicitly. Everything else is additive. --------- Signed-off-by: R. Garcia-Dias <rafaelagd@gmail.com>
1 parent 7b9cb34 commit 605611b

4 files changed

Lines changed: 321 additions & 5 deletions

File tree

monai/bundle/workflows.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import os
1616
import sys
1717
import time
18+
import warnings
1819
from abc import ABC, abstractmethod
1920
from collections.abc import Sequence
2021
from copy import copy
@@ -34,6 +35,23 @@
3435
logger = get_logger(module_name=__name__)
3536

3637

38+
def _warn_logging_file_execution(logging_file: str) -> None:
39+
"""
40+
Warn that ``logging_file`` is about to be executed by `logging.config.fileConfig`.
41+
42+
Called immediately before every `fileConfig` invocation in this module, so the warning is only
43+
raised when the file is really executed -- not when it is missing or logging is disabled.
44+
"""
45+
warnings.warn(
46+
f"applying logging config {logging_file}: `logging.config.fileConfig` passes the `class=` and "
47+
"`args=` fields of the INI's handler and formatter sections to Python `eval()`, so this file "
48+
"runs as code. A bundle ships its own `configs/logging.conf` and it is applied by default, "
49+
"before any of the bundle's config is parsed. Only proceed if this file is from a source you "
50+
"trust (see https://github.com/Project-MONAI/MONAI/security/advisories/GHSA-wvpx-5qmp-46g3).",
51+
stacklevel=3,
52+
)
53+
54+
3755
class BundleWorkflow(ABC):
3856
"""
3957
Base class for the workflow specification in bundle, it can be a training, evaluation or inference workflow.
@@ -55,6 +73,10 @@ class BundleWorkflow(ABC):
5573
meta_file: filepath of the metadata file, if this is a list of file paths, their contents will be merged in order.
5674
logging_file: config file for `logging` module in the program. for more details:
5775
https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig.
76+
Security note: `fileConfig` passes the INI's `class=` and `args=` fields to Python
77+
`eval()`, so this file runs as code and applying it raises a warning -- once per call
78+
site, as Python's default warning filter suppresses repeats
79+
(see https://github.com/Project-MONAI/MONAI/security/advisories/GHSA-wvpx-5qmp-46g3).
5880
5981
"""
6082

@@ -72,6 +94,7 @@ def __init__(
7294
if not os.path.isfile(logging_file):
7395
raise FileNotFoundError(f"Cannot find the logging config file: {logging_file}.")
7496
logger.info(f"Setting logging properties based on config: {logging_file}.")
97+
_warn_logging_file_execution(logging_file)
7598
fileConfig(logging_file, disable_existing_loggers=False)
7699

77100
if meta_file is not None:
@@ -273,6 +296,10 @@ class PythonicWorkflow(BundleWorkflow):
273296
meta_file: filepath of the metadata file, if this is a list of file paths, their contents will be merged in order.
274297
logging_file: config file for `logging` module in the program. for more details:
275298
https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig.
299+
Security note: `fileConfig` passes the INI's `class=` and `args=` fields to Python
300+
`eval()`, so this file runs as code and applying it raises a warning -- once per call
301+
site, as Python's default warning filter suppresses repeats
302+
(see https://github.com/Project-MONAI/MONAI/security/advisories/GHSA-wvpx-5qmp-46g3).
276303
277304
"""
278305

@@ -375,6 +402,10 @@ class ConfigWorkflow(BundleWorkflow):
375402
https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig.
376403
If None, default to "configs/logging.conf", which is commonly used for bundles in MONAI model zoo.
377404
If False, the logging logic for the bundle will not be modified.
405+
Security note: `fileConfig` passes the INI's `class=` and `args=` fields to Python
406+
`eval()`, so this file runs as code and applying it raises a warning -- once per call
407+
site, as Python's default warning filter suppresses repeats
408+
(see https://github.com/Project-MONAI/MONAI/security/advisories/GHSA-wvpx-5qmp-46g3).
378409
init_id: ID name of the expected config expression to initialize before running, default to "initialize".
379410
allow a config to have no `initialize` logic and the ID.
380411
run_id: ID name of the expected config expression to run, default to "run".
@@ -444,6 +475,7 @@ def __init__(
444475
else:
445476
raise FileNotFoundError(f"Cannot find the logging config file: {logging_file}.")
446477
else:
478+
_warn_logging_file_execution(str(logging_file))
447479
fileConfig(str(logging_file), disable_existing_loggers=False)
448480
logger.info(f"Setting logging properties based on config: {logging_file}.")
449481

monai/fl/client/monai_algo.py

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import os
1515
import time
16+
import warnings
1617
from collections.abc import Mapping, MutableMapping
1718
from typing import Any, cast
1819

@@ -34,6 +35,26 @@
3435
logger = get_logger(__name__)
3536

3637

38+
def _warn_provisioned_config_execution(bundle_root: str) -> None:
39+
"""
40+
Warn that the bundle under ``bundle_root`` is about to be executed.
41+
42+
In federated learning the whole app directory -- configs included -- is provisioned by the FL
43+
system, and the aggregation server dispatches `initialize`/`train` tasks that the client runs
44+
on its own, so there is no per-round human interaction to catch a poisoned config.
45+
"""
46+
warnings.warn(
47+
f"executing the bundle config under {bundle_root}, which is provisioned by the FL system: "
48+
'any `"_target_"` value in it is resolved to an importable callable and invoked with no '
49+
'allow list, and any `"$"`-prefixed value is passed to Python `eval()`. A malicious or '
50+
"compromised aggregation server therefore gets code execution on this client, without any "
51+
"per-round human interaction. Only join a federation whose server and app-provisioning "
52+
"channel you trust (see "
53+
"https://github.com/Project-MONAI/MONAI/security/advisories/GHSA-x6pr-233j-x5cw).",
54+
stacklevel=3,
55+
)
56+
57+
3758
def convert_global_weights(global_weights: Mapping, local_var_dict: MutableMapping) -> tuple[MutableMapping, int]:
3859
"""Helper function to convert global weights to local weights format"""
3960
# Before loading weights, tensors might need to be reshaped to support HE for secure aggregation.
@@ -86,6 +107,15 @@ class MonaiAlgoStats(ClientAlgoStats):
86107
"""
87108
Implementation of ``ClientAlgoStats`` to allow federated learning with MONAI bundle configurations.
88109
110+
Security note: the bundle under `bundle_root` is provisioned by the FL system -- `initialize()`
111+
resolves it against `extra[ExtraItems.APP_ROOT]`, which the aggregation server supplies -- and
112+
executing it runs whatever its config contains: any `"_target_"` value is resolved to an
113+
importable callable and invoked with no allow list, and any `"$"`-prefixed value is passed to
114+
Python `eval()`. A malicious or compromised server therefore gets code execution on this client,
115+
with no per-round human interaction. Executing a config raises a warning -- once per call site,
116+
as Python's default warning filter suppresses repeats
117+
(see https://github.com/Project-MONAI/MONAI/security/advisories/GHSA-x6pr-233j-x5cw).
118+
89119
Args:
90120
bundle_root: directory path of the bundle.
91121
config_train_filename: bundle training config path relative to bundle_root. Can be a list of files;
@@ -135,18 +165,29 @@ def initialize(self, extra=None):
135165
Args:
136166
extra: Dict with additional information that should be provided by FL system,
137167
i.e., `ExtraItems.CLIENT_NAME`, `ExtraItems.APP_ROOT` and `ExtraItems.LOGGING_FILE`.
138-
You can diable the logging logic in the monai bundle by setting {ExtraItems.LOGGING_FILE} to False.
168+
`{ExtraItems.LOGGING_FILE}` defaults to False here, and an explicit `None` is
169+
treated the same way, so the bundle's own "configs/logging.conf" is not applied:
170+
it is provisioned by the FL system and `logging.config.fileConfig` runs the INI's
171+
`class=`/`args=` fields through `eval()`
172+
(see https://github.com/Project-MONAI/MONAI/security/advisories/GHSA-wvpx-5qmp-46g3).
173+
Set it to a logging config file path to opt back in to configuring logging.
139174
140175
"""
141176
if extra is None:
142177
extra = {}
143178
self.client_name = extra.get(ExtraItems.CLIENT_NAME, "noname")
144-
logging_file = extra.get(ExtraItems.LOGGING_FILE, None)
179+
logging_file = extra.get(ExtraItems.LOGGING_FILE, False)
180+
if logging_file is None:
181+
# `ConfigWorkflow` reads `None` as "fall back to the bundle's own configs/logging.conf",
182+
# the FL-provisioned file this default exists to keep away from `fileConfig`. Passing the
183+
# key explicitly as `None` has to mean the same as leaving it out.
184+
logging_file = False
145185
self.logger.info(f"Initializing {self.client_name} ...")
146186

147187
# FL platform needs to provide filepath to configuration files
148188
self.app_root = extra.get(ExtraItems.APP_ROOT, "")
149189
self.bundle_root = os.path.join(self.app_root, self.bundle_root)
190+
_warn_provisioned_config_execution(self.bundle_root)
150191

151192
if self.workflow is None:
152193
config_train_files = self._add_config_files(self.config_train_filename)
@@ -313,6 +354,15 @@ class MonaiAlgo(ClientAlgo, MonaiAlgoStats):
313354
"""
314355
Implementation of ``ClientAlgo`` to allow federated learning with MONAI bundle configurations.
315356
357+
Security note: the bundle under `bundle_root` is provisioned by the FL system -- `initialize()`
358+
resolves it against `extra[ExtraItems.APP_ROOT]`, which the aggregation server supplies -- and
359+
executing it runs whatever its config contains: any `"_target_"` value is resolved to an
360+
importable callable and invoked with no allow list, and any `"$"`-prefixed value is passed to
361+
Python `eval()`. A malicious or compromised server therefore gets code execution on this client,
362+
with no per-round human interaction. Executing a config raises a warning -- once per call site,
363+
as Python's default warning filter suppresses repeats
364+
(see https://github.com/Project-MONAI/MONAI/security/advisories/GHSA-x6pr-233j-x5cw).
365+
316366
Args:
317367
bundle_root: directory path of the bundle.
318368
local_epochs: number of local epochs to execute during each round of local training; defaults to 1.
@@ -416,19 +466,30 @@ def initialize(self, extra=None):
416466
Args:
417467
extra: Dict with additional information that should be provided by FL system,
418468
i.e., `ExtraItems.CLIENT_NAME`, `ExtraItems.APP_ROOT` and `ExtraItems.LOGGING_FILE`.
419-
You can diable the logging logic in the monai bundle by setting {ExtraItems.LOGGING_FILE} to False.
469+
`{ExtraItems.LOGGING_FILE}` defaults to False here, and an explicit `None` is
470+
treated the same way, so the bundle's own "configs/logging.conf" is not applied:
471+
it is provisioned by the FL system and `logging.config.fileConfig` runs the INI's
472+
`class=`/`args=` fields through `eval()`
473+
(see https://github.com/Project-MONAI/MONAI/security/advisories/GHSA-wvpx-5qmp-46g3).
474+
Set it to a logging config file path to opt back in to configuring logging.
420475
421476
"""
422477
self._set_cuda_device()
423478
if extra is None:
424479
extra = {}
425480
self.client_name = extra.get(ExtraItems.CLIENT_NAME, "noname")
426-
logging_file = extra.get(ExtraItems.LOGGING_FILE, None)
481+
logging_file = extra.get(ExtraItems.LOGGING_FILE, False)
482+
if logging_file is None:
483+
# `ConfigWorkflow` reads `None` as "fall back to the bundle's own configs/logging.conf",
484+
# the FL-provisioned file this default exists to keep away from `fileConfig`. Passing the
485+
# key explicitly as `None` has to mean the same as leaving it out.
486+
logging_file = False
427487
timestamp = time.strftime("%Y%m%d_%H%M%S")
428488
self.logger.info(f"Initializing {self.client_name} ...")
429489
# FL platform needs to provide filepath to configuration files
430490
self.app_root = extra.get(ExtraItems.APP_ROOT, "")
431491
self.bundle_root = os.path.join(self.app_root, self.bundle_root)
492+
_warn_provisioned_config_execution(self.bundle_root)
432493

433494
if self.train_workflow is None and self.config_train_filename is not None:
434495
config_train_files = self._add_config_files(self.config_train_filename)

tests/bundle/test_bundle_workflow.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@
1111

1212
from __future__ import annotations
1313

14+
import json
15+
import logging
1416
import os
1517
import shutil
1618
import sys
1719
import tempfile
1820
import unittest
21+
import warnings
1922
from copy import deepcopy
2023
from pathlib import Path
2124

@@ -268,5 +271,84 @@ def test_create_pythonic_workflow(self):
268271
workflow.finalize()
269272

270273

274+
class TestConfigWorkflowWarnsOnLoggingConf(unittest.TestCase):
275+
"""Regression test for GHSA-wvpx-5qmp-46g3: `ConfigWorkflow` defaults `logging_file` to the
276+
bundle's own "configs/logging.conf" and hands it to `logging.config.fileConfig`, which `eval()`s
277+
the INI's `class=`/`args=` fields. It fires in `__init__`, before `initialize()` or `run()`, and
278+
lives in a plain INI rather than the MONAI `$`-DSL, so it is easy to miss when reviewing a
279+
bundle. Applying it is still not blocked -- as for GHSA-873f-pvrv-4x83, MONAI has no way to
280+
establish whether a bundle is trustworthy -- but applying it now raises a `UserWarning`."""
281+
282+
def setUp(self):
283+
# `fileConfig` reconfigures logging process-wide. Snapshot the root logger and restore it
284+
# afterwards so these tests cannot leak a handler into the rest of the suite.
285+
root = logging.getLogger()
286+
level, handlers, filters = root.level, root.handlers[:], root.filters[:]
287+
disabled = logging.root.manager.disable
288+
289+
def _restore():
290+
# Detach whatever is on the root logger now, closing anything `fileConfig` installed so
291+
# it does not linger in logging's handler registry, then put the snapshot back. Under
292+
# `tests/runner.py` the root logger starts with no handlers, so there is nothing for
293+
# `fileConfig` to have closed on the way in.
294+
for handler in root.handlers[:]:
295+
root.removeHandler(handler)
296+
if handler not in handlers:
297+
handler.close()
298+
root.setLevel(level)
299+
root.filters[:] = filters
300+
for handler in handlers:
301+
root.addHandler(handler)
302+
logging.disable(disabled)
303+
304+
self.addCleanup(_restore)
305+
306+
def test_default_logging_conf_warns_and_executes(self):
307+
with tempfile.TemporaryDirectory() as tempdir:
308+
configs = os.path.join(tempdir, "configs")
309+
os.makedirs(configs)
310+
marker = os.path.join(tempdir, "PWNED")
311+
with open(os.path.join(configs, "train.json"), "w") as f:
312+
json.dump({"initialize": []}, f)
313+
# `fileConfig` eval()s the `class=` field, so the tuple subscript runs the payload and
314+
# still yields a usable handler class.
315+
with open(os.path.join(configs, "logging.conf"), "w") as f:
316+
f.write(
317+
"[loggers]\nkeys=root\n[handlers]\nkeys=h\n[formatters]\nkeys=f\n"
318+
"[logger_root]\nlevel=NOTSET\nhandlers=h\n"
319+
"[handler_h]\n"
320+
f"class=(__import__('pathlib').Path({marker!r}).write_text('pwned'), "
321+
"__import__('logging').StreamHandler)[1]\nargs=()\nformatter=f\n"
322+
"[formatter_f]\nformat=%(message)s\n"
323+
)
324+
with self.assertWarnsRegex(UserWarning, r"GHSA-wvpx-5qmp-46g3"):
325+
ConfigWorkflow(config_file=os.path.join(configs, "train.json"), workflow_type="train")
326+
self.assertTrue(os.path.exists(marker))
327+
328+
def test_no_warning_when_logging_disabled(self):
329+
"""No warning when `fileConfig` is never reached -- the file exists but is opted out of."""
330+
with tempfile.TemporaryDirectory() as tempdir:
331+
configs = os.path.join(tempdir, "configs")
332+
os.makedirs(configs)
333+
marker = os.path.join(tempdir, "PWNED")
334+
with open(os.path.join(configs, "train.json"), "w") as f:
335+
json.dump({"initialize": []}, f)
336+
with open(os.path.join(configs, "logging.conf"), "w") as f:
337+
f.write(
338+
"[loggers]\nkeys=root\n[handlers]\nkeys=h\n[formatters]\nkeys=f\n"
339+
"[logger_root]\nlevel=NOTSET\nhandlers=h\n"
340+
"[handler_h]\n"
341+
f"class=(__import__('pathlib').Path({marker!r}).write_text('pwned'), "
342+
"__import__('logging').StreamHandler)[1]\nargs=()\nformatter=f\n"
343+
"[formatter_f]\nformat=%(message)s\n"
344+
)
345+
with warnings.catch_warnings():
346+
warnings.simplefilter("error", UserWarning)
347+
ConfigWorkflow(
348+
config_file=os.path.join(configs, "train.json"), workflow_type="train", logging_file=False
349+
)
350+
self.assertFalse(os.path.exists(marker))
351+
352+
271353
if __name__ == "__main__":
272354
unittest.main()

0 commit comments

Comments
 (0)