Флаг с названием trust_remote_code обещает простую вещь: чужой код не запустится, пока разработчик сам не скажет "доверяю". Именно на это обещание опираются те, кто скачивает модели из чужих репозиториев и считает такие репозитории пассивными хранилищами весов. 27 октября 2026 года вышла версия Diffusers 0.42.1, и её единственная цель в том, чтобы закрыть случай, когда обещание не выполнялось: имя библиотеки компонента из конфигурационного файла попадало в импорт без проверки. Ниже разобрано, что именно исправлено и при каких условиях это работало, чем эта история похожа на майскую волну уязвимостей того же класса, какие неудобства несёт само обновление и как за вечер проверить свои установки скриптом и закрыть основные пути минимальными мерами. Скрипт аудита приложен и проверен на тестовых каталогах.

Версия 0.42.1 от 27 октября исправляет разбор имени библиотеки компонента, который обходил флаг trust_remote_code

Релиз 0.42.1 помечен как исправление безопасности и привязан к рекомендации GHSA-4prv-5hj7-c2xr. Затронуты версии 0.42.0 и все более ранние, исправленная версия одна, 0.42.1. Оценка опасности по шкале CVSS составляет 7,0, то есть высокий уровень, но заметно ниже, чем у майских уязвимостей, о которых речь пойдёт дальше. Вектор оценки говорит о локальном векторе, высокой сложности, отсутствии требуемых привилегий и необходимости действия пользователя. Идентификатор CVE на момент публикации не назначен. Классификация слабости - небезопасная рефлексия и включение функциональности из недоверенной области. Тестовый пример проблемы датирован 17 октября, публикация состоялась через десять дней.

Суть правки скромно описана в самой заметке к релизу: изменён способ, которым определяется библиотека, объявленная у компонента при загрузке через from_pretrained. Конфигурации пайплайнов и моделей описывают каждый компонент парой из имени библиотеки и имени класса. Правка касается трёх путей загрузки: DiffusionPipeline, AutoModel и ModularPipeline. Заодно добавлено понятное сообщение об ошибке, если объявленный класс не найден в найденном модуле. Установка исправления выглядит так:

pip install "diffusers>=0.42.1"

Версии ветки 0.42 требуют Python 3.11 или новее, и это важная деталь, к которой мы вернёмся: для части окружений обновление пакета означает обновление интерпретатора. Для остальных достаточно одной команды, а проверить результат можно строкой python -c "import diffusers; print(diffusers.version)".

Две лазейки в конфигурациях превращали имя модуля в строку для импорта, и сработать они могли только у локальной копии репозитория

Первая лазейка: имя библиотеки компонента из файла model_index.json или modular_model_index.json в ветке загрузки из локального каталога импортировалось напрямую. Список разрешённых библиотек, который в этом файле используется в других местах, на эту ветку не распространялся. Вторая лазейка: ключи словаря requirements из файла modular_config.json обрабатывались как имена модулей через поиск спецификации. У составных имён с точками импортируются родительские пакеты, а вместе с ними выполняются их файлы init.py.

Ключевое условие описано в рекомендации чётко. Каталог репозитория должен быть доступен для импорта, то есть лежать в одном из мест, где Python ищет модули. Пример из рекомендации: человек клонирует репозиторий модели или скачивает его локально и запускает свой скрипт из родительского каталога. Тогда файл Python, лежащий внутри репозитория, может выполниться при вызове from_pretrained с параметрами по умолчанию. Холодная загрузка по идентификатору репозитория с хаба, без локального каталога, в рекомендации названа не затронутой. Запуск с переменной PYTHONSAFEPATH=1 атаку блокирует.

Если переложить это на бытовой язык, то Python ищет модули в списке папок, как человек ищет ключи по карманам. Если папка с чужой моделью оказалась в этом списке, то слово из чужого файла настроек может указать прямо на чужой файл с кодом. Проверка доверия стоит у главной двери, а такой путь идёт через боковую.

Последствия описаны жёстко. Код выполняется с правами пользователя в момент вызова from_pretrained, ещё до чтения весов, и ему доступно всё, что доступно процессу: токен доступа к хабу, исходники проекта, кэш моделей, сеть. Обход срабатывает даже при trust_remote_code=False и при переменной DIFFUSERS_DISABLE_REMOTE_CODE=1, а закрепление ревизии и формат safetensors на него не влияют. Формальный обходной путь в рекомендации не назван, кроме упомянутой переменной PYTHONSAFEPATH.

Понятно, почему оценка 7,0, а не 8,8. Локальный вектор, высокая сложность и необходимость действия пользователя отсекают массовую эксплуатацию: чтобы сработать, нужен определённый порядок действий жертвы. Но именно такой порядок, клонировать репозиторий рядом с кодом и запустить скрипт, и встречается в домашних лабораториях, учебных проектах и ноутбуках исследователей постоянно.

Это уже вторая волна проблем одного класса, и в мае три обхода закрыли в версии 0.38.0 по той же схеме ошибок

Весной 2026 года исследователи описали группу из нескольких обходов под общим названием FaceHugger. Среди них CVE-2026-44513 с оценкой 8,8 и CVE-2026-45804 с оценкой 7,5, плюс родственная ошибка, при которой отсутствие пользовательского пайплайна превращалось в имя файла None.py. Исправление вышло в версии 0.38.0 первого мая через запрос на слияние номер 13448, публичный разбор появился 27 июля. Версии до 0.38.0 затронуты, и в закрытых рекомендациях подчёркивается, что при trust_remote_code=False защиты на них не было.

Общая причина там формулировалась так: проверка доверия стояла в функции download(), а динамическая загрузка модулей, исполняющая код, жила ниже по цепочке и проверку не повторяла. Один из вариантов - гонка проверки и использования: между двумя сетевыми запросами при загрузке оставалось короткое окно, за которое конфигурацию репозитория можно было подменить. Исправление перенесло проверку в функцию get_cached_module_file, через которую проходит любая загрузка динамического модуля.

Сопоставление двух историй даёт простой вывод. Исправление 0.42.1 говорит о той же болезни: проверка стоит у одной двери, а код способен войти через другие. Каждое такое исправление закрывает конкретный ход, но не меняет архитектуру. Поэтому рассчитывать на флаг как на единственный замок нельзя. Флаг - табличка, которая хорошо работает против случайной ошибки и плохо против целенаправленно подготовленного репозитория.

За обновление до 0.42.1 приходится платить переходом на Python 3.11, новой версией клиента хаба и правками в модульных пайплайнах

Версия 0.42.0, на которой основан патч, принесла много нового. В ней появились FLUX.3 Image Klein для генерации и редактирования картинок с референсами, Bria Fibo 2.0 с описаниями в формате структурированного JSON, Kandinsky 6 для видео с синхронным звуком и отдельный конвейер повышения разрешения видео, а также SANA-WM для видео с управляемой камерой. Для пользователей всё это приятная часть. Вторая часть - совместимость.

Python 3.10 больше не поддерживается: теперь требуется 3.11 или новее. Диапазон версии клиента хаба изменён на >=1.32.0,<3.0. В модульных пайплайнах для FLUX, Krea 2 и LTX-Video латенты теперь распаковываются внутри основных блоков шумоподавления, и самописные рабочие процессы должны передавать в блоки декодирования уже распакованные латенты, а запасной вариант с упакованными латентами объявлен устаревшим и будет убран в 0.47.0. Для квантизации через TorchAO нужна версия 0.15 или новее.

Практический вывод для администратора такой. Если окружение на Python 3.10, одной командой pip не обойтись: нужен новый интерпретатор, пересборка зависимостей и проверка всех мест, где используются модульные пайплайны. Поэтому порядок действий лучше такой: собрать отдельное окружение, поставить diffusers в диапазоне от 0.42.1 до 0.43, прогнать рабочие сценарии, и только потом менять основное. В это время защита вместо обновления тоже возможна, и о ней говорится в разделе про минимальные меры.

Скрипт аудита находит устаревшую версию, чужой код в репозиториях моделей и подозрительные имена в конфигурациях

Скрипт не пытается доказывать наличие вредоносного кода, он ищет признаки, при которых стоит остановиться и посмотреть. Проверок пять. Первая сверяет установленную версию diffusers с порогом 0.42.1. Вторая читает model_index.json и modular_model_index.json в каждом найденном репозитории и проверяет имена библиотек компонентов: имя, не похожее на имя пакета, помечается как HIGH, а неизвестная библиотека как REVIEW. Третья разбирает словарь requirements в modular_config.json по той же логике. Четвёртая ищет файлы Python внутри каталогов моделей: обычному репозиторию весов код не нужен, поэтому любой такой файл заслуживает взгляда, а файл None.py сразу получает уровень HIGH. Пятая проверяет, лежит ли репозиторий модели рядом с текущим каталогом при незаданной PYTHONSAFEPATH.

"""Аудит локальных сетапов Diffusers: версия пакета, чужой код в репозиториях, пути импорта."""
import json
import os
import re
import sys
from importlib import metadata
from pathlib import Path

FIXED = (0, 42, 1)
ALLOWED_LIBS = {"diffusers", "transformers", "peft", "onnxruntime", "torch"}
INDEX_NAMES = ("model_index.json", "modular_model_index.json")
SAFE_CHARS = re.compile(r"^[A-Za-z0-9_.\-]+$")


def parse_version(text):
    nums = [int(n) for n in re.findall(r"\d+", text)[:3]]
    return tuple(nums) + (0,) * (3 - len(nums))


def check_version():
    try:
        version = metadata.version("diffusers")
    except metadata.PackageNotFoundError:
        return []
    if parse_version(version) < FIXED:
        return [("HIGH", f"diffusers {version}: нужно обновить до 0.42.1 или новее")]
    return []


def check_library_name(where, key, lib):
    if lib is None:
        return []
    if not isinstance(lib, str) or ".." in lib or not SAFE_CHARS.match(lib):
        return [("HIGH", f"{where}: компонент {key}, странное имя библиотеки {lib!r}")]
    if lib not in ALLOWED_LIBS:
        return [("REVIEW", f"{where}: компонент {key}, неизвестная библиотека {lib!r}")]
    return []


def check_index(path):
    try:
        doc = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return [("REVIEW", f"{path}: файл не читается как JSON")]
    found = []
    for key, value in doc.items():
        if key.startswith("_") or not isinstance(value, list) or not value:
            continue
        found += check_library_name(path, key, value[0])
    return found


def check_requirements(path):
    try:
        doc = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return [("REVIEW", f"{path}: файл не читается как JSON")]
    found = []
    for name in (doc.get("requirements") or {}):
        if not SAFE_CHARS.match(name) or ".." in name:
            found.append(("HIGH", f"{path}: странное имя в requirements {name!r}"))
        elif "." in name:
            found.append(("REVIEW", f"{path}: имя с точкой в requirements {name!r}"))
    return found


def check_python_files(folder):
    found = []
    for root, _dirs, files in os.walk(folder):
        for name in files:
            if not name.endswith(".py"):
                continue
            level = "HIGH" if name == "None.py" else "REVIEW"
            found.append((level, f"{Path(root) / name}: код внутри репозитория модели"))
    return found


def scan(roots):
    found = []
    for top in roots:
        for root, _dirs, files in os.walk(top):
            names = set(files)
            if not names & {*INDEX_NAMES, "modular_config.json"}:
                continue
            folder = Path(root)
            for name in INDEX_NAMES:
                if name in names:
                    found += check_index(folder / name)
            if "modular_config.json" in names:
                found += check_requirements(folder / "modular_config.json")
            found += check_python_files(folder)
    return found


def check_import_path(cwd):
    if os.environ.get("PYTHONSAFEPATH"):
        return []
    found = []
    for folder in [cwd, *sorted(p for p in cwd.iterdir() if p.is_dir())]:
        if any((folder / name).exists() for name in INDEX_NAMES):
            found.append(("REVIEW", f"{folder}: репозиторий модели рядом с кодом, "
                                    "а PYTHONSAFEPATH не задан"))
    return found


def main(roots):
    findings = check_version() + scan(roots) + check_import_path(Path.cwd())
    for level, text in sorted(set(findings)):
        print(f"[{level}] {text}")
    if not findings:
        print("Замечаний нет")
    return 1 if any(level == "HIGH" for level, _ in findings) else 0


if __name__ == "__main__":
    default = [Path.home() / ".cache" / "huggingface" / "hub"]
    sys.exit(main([Path(p) for p in sys.argv[1:]] or default))

Запуск без аргументов проверяет стандартный кэш моделей, запуск с путями проверяет перечисленные каталоги, например python audit_diffusers.py ~/models ~/comfy/models. Код возврата равен 1, если найдено хотя бы одно замечание уровня HIGH, так что скрипт можно поставить в проверку перед сборкой образа или в ночное задание.

Проверка шла на тестовом наборе из четырёх каталогов: чистый репозиторий, репозиторий с подозрительными именами и лишними файлами, конфигурация с необычными именами в requirements и каталог с битым JSON. Результат оказался таким:

[HIGH] models/bad/None.py: код внутри репозитория модели
[HIGH] models/bad/model_index.json: компонент num, странное имя библиотеки 5
[HIGH] models/bad/model_index.json: компонент x, странное имя библиотеки '../up'
[HIGH] models/mod/modular_config.json: странное имя в requirements 'a/b'
[REVIEW] models/bad/__init__.py: код внутри репозитория модели
[REVIEW] models/bad/model_index.json: компонент vae, неизвестная библиотека 'evil.pkg'
[REVIEW] models/empty/model_index.json: файл не читается как JSON
[REVIEW] models/mod/modular_config.json: имя с точкой в requirements 'ruamel.yaml'

Чистый репозиторий не дал ни одного замечания. Версия сверялась на значениях 0.41.0, 0.42.0, 0.42.1 и 0.43.0: первые две помечены, остальные нет, а при отсутствии пакета проверка тихо пропускается. С переменной PYTHONSAFEPATH=1 пятая проверка молчит. Нужны и оговорки. Список ALLOWED_LIBS стоит дополнить библиотеками, которые действительно встречаются в доверенных репозиториях, иначе уровень REVIEW будет шумным. Файлы Python в репозитории бывают и законными, например в репозиториях с пользовательскими пайплайнами, поэтому их наличие не доказательство, а повод для чтения. Скрипт тестировался только на искусственных каталогах и не заменяет разбор настоящих рекомендаций.

Минимальный набор мер из пяти шагов закрывает главные пути, даже если обновление придётся отложить

Защита строится так, чтобы каждая мера работала независимо от остальных и от самой библиотеки. Порядок такой:

  1. Обновить diffusers до 0.42.1 в окружениях, контейнерных образах и файлах блокировки зависимостей;
  2. Запускать скрипты с переменной PYTHONSAFEPATH=1 или ключом -P, а на Python 3.10 с изолированным режимом -I, и держать каталоги моделей вне путей импорта;
  3. Скачивать модели отдельным шагом по идентификатору и фиксированной ревизии, а загружать из локальной копии только после прогона скрипта аудита;
  4. Держать список проверенных репозиториев и обёртку, которая отказывает всем остальным;
  5. Первую загрузку незнакомой модели делать в одноразовом контейнере без сети и без токенов.

Второй шаг стоит пояснить. Переменная PYTHONSAFEPATH появилась в Python 3.11 и отключает добавление каталога скрипта и текущего каталога в пути импорта, ключ -P делает то же самое. На версии 3.10 такого нет, но есть изолированный режим -I: он тоже не добавляет каталог скрипта, зато заодно игнорирует переменные окружения и пользовательские пакеты, так что подходит для виртуальных окружений, где все зависимости лежат внутри. Эти меры работают как временный заслон для тех, кто пока не может поднять версию Python.

Третий и четвёртый шаги можно оформить небольшой обёрткой. Она не защищает от ошибок библиотеки, это только политика: в проект попадают лишь модели, которые кто-то уже просмотрел.

from diffusers import DiffusionPipeline

REVIEWED = {
    "org/model-a": "0123456789abcdef0123456789abcdef01234567",
}


def load_reviewed(repo_id, **kwargs):
    revision = REVIEWED.get(repo_id)
    if revision is None:
        raise PermissionError(f"репозиторий {repo_id} не проходил проверку")
    return DiffusionPipeline.from_pretrained(
        repo_id, revision=revision, trust_remote_code=False, **kwargs)

Закрепление ревизии даёт повторяемость и гарантирует, что загружается то, что просмотрели. Но, как отмечено в рекомендации, ошибка 0.42.0 от закрепления ревизии не зависит, поэтому обёртка работает в паре с обновлением, а не вместо него.

Пятый шаг строится на разделении двух операций, скачивания и загрузки. Скачивание файлов само по себе код не исполняет, и его можно делать в контейнере с сетью, но без токенов и без монтирования исходников. Загрузка идёт в другом контейнере, без сети, с каталогом моделей только для чтения:

hf download org/model-a --revision 0123456789abcdef0123456789abcdef01234567 --local-dir models/model-a

docker run --rm --network none --read-only --cap-drop ALL \
  --security-opt no-new-privileges --tmpfs /tmp \
  -v "$PWD/models:/models:ro" -v "$PWD/test_load.py:/app/test_load.py:ro" \
  my-diffusers:0.42.1 python -P /app/test_load.py

Здесь образ my-diffusers:0.42.1 нужно собрать самому, в нём стоит исправленная версия библиотеки. Контейнер без сети не отправит ничего наружу, даже если загрузка зацепит чужой код, каталог только для чтения не даст изменить файлы, а отсутствие токена лишает атакующего главной добычи. Параметр -P отдельно закрывает путь импорта, о котором шла речь выше.

Что проверить в сетапах с ComfyUI и самописными нодами, где репозитории моделей лежат рядом с кодом

Установка пользовательской ноды сама по себе означает запуск чужого кода с правами пользователя, и этот риск понимают все, кто ею занимается. Особенность описанной ошибки в том, что она превращала в источник кода репозиторий, который обычно считают просто хранилищем файлов: каталог с весами и конфигурацией. Человек, который тщательно выбирает ноды, мог не думать о папке с моделью, скачанной ради эксперимента, и клонировал её прямо в рабочий каталог проекта. Поэтому в домашних сетапах стоит пройтись по нескольким местам.

Первое место - расположение моделей. Каталоги с model_index.json не должны лежать в корне проекта, рядом со скриптами и в каталогах нод. Их место в отдельной папке, не входящей в пути импорта, и именно её передаёт скрипт аудита. Второе место - вызовы загрузки. Найти их в пользовательских нодах можно простой командой:

grep -rn "trust_remote_code\|from_pretrained" custom_nodes/ --include="*.py"

Результат стоит просмотреть на предмет локальных путей, значений trust_remote_code=True и мест, где идентификатор репозитория приходит от пользователя интерфейса: такой путь позволяет подставить любой репозиторий. Часть нод и обвязок использует diffusers под капотом, и узнать об этом можно только по коду. Третье место - окружение процесса: токены хаба и облачные ключи не должны быть доступны процессу, которому нужно только генерировать картинки. Если токен нужен для скачивания закрытых моделей, его держат только в контейнере скачивания.

Наконец, полезно просто спросить себя, что лежит рядом с интерпретатором: каталог кэша, исходники, ключи. Именно это достаётся тому, кто смог выполнить код при загрузке, и именно это по возможности убирают из доступа. Если Diffusers обновлён, пути импорта закрыты, модели проверены, а загрузка идёт в контейнере без сети, то одна пропущенная ошибка библиотеки перестаёт быть катастрофой.

Главный вывод этой истории не в том, что одна версия лучше другой. Граница доверия проходит не по флагу в вызове, а по каталогу, в котором лежит чужой репозиторий, и по правам процесса, который его открывает. Флаг trust_remote_code остаётся полезной табличкой, но замками служат контейнер, версия и порядок работы.