💡 Полный рабочий пример доступен на GitHub:
load-untrusted-documents-safely-python

Старый способ был болезненным

Вы написали три строки, чтобы отобразить миниатюру загруженного документа. Они выглядели так и выглядели нормально:

with signature.Signature(upload_path) as sign:
    save_page_preview(sign, thumbnail_path)

То, что делали эти строки до GroupDocs.Signature 26.9, — запрашивали каждый адрес, на который указывал документ. Файл Word может содержать картинку, которой в нём нет — файл хранит URL, и любое приложение, открывающее его, скачивает этот URL. На настольном компьютере это функция. На сервере, принимающем загрузки, это значит, что отправитель файла решает, какие адреса запрашивает ваша инфраструктура.

Атака имеет название — server‑side request forgery (SSRF), и имеет три формы, которые стоит назвать. Внутренний адрес, недоступный из интернета, доступен с вашего сервера, поэтому специально подготовленный документ может заставить ваш сервис запросить http://169.254.169.254/ или административный эндпоинт на localhost. UNC‑путь может заставить Windows‑хост аутентифицироваться наружу, передавая учётные данные серверу, контролируемому атакующим. А ссылка на хост, который просто не отвечает, удерживает поток загрузки до таймаута, что является дешёвым способом исчерпать пул воркеров документами, выглядящими безвредными.

Ничего из этого не является багом в библиотеке документов. Переход по ссылке — то, что требует формат. Дискомфортным был тот факт, что согласие было поведением по умолчанию, которое в коде никто не помечал бы при ревью.

Есть лучший способ

Безопасная загрузка документов — это поведение GroupDocs.Signature для Python, которое отказывается делать такие запросы. Начиная с версии 26.9, LoadOptions.skip_external_resources по умолчанию равно True, поэтому те же три строки теперь ничего не запрашивают и выводят заглушку вместо связанной картинки.

Изменение является изменением значения по умолчанию, а не новой функцией — свойство уже существовало. Что изменилось в 26.9, так это то, куда оно указывает, когда ваш код ничего не задаёт, а это единственная настройка, которую используют почти все сервисы.

Новый способ: три режима загрузки

Шаг 1 — Оставьте значение по умолчанию для всего ненадёжного

Никаких LoadOptions вовсе:

with signature.Signature(source_path) as sign:
    return save_page_preview(sign, preview_path)

Ничего не запрашивается. Превью будет меньше, чем иначе, и эта разница в размере — самое удобное доказательство того, что запросов с машины не вышло.

Шаг 2 — Добавьте в белый список хост, которым вы действительно владеете

Много документов ссылаются на легитимные ресурсы: CDN компании, внутренний сервер изображений, хранилище шаблонов. Разрешите только его и ничего больше:

load_options = LoadOptions()
load_options.whitelisted_resources = [trusted_address]

with signature.Signature(source_path, load_options) as sign:
    return save_page_preview(sign, preview_path)

Правило сопоставления заслуживает внимания. Это нечувствительный к регистру поиск подстроки в адресе ресурса, что делает короткий фрагмент опасным: github совпадает с github.attacker.example/payload.png так же легко, как и с нужным вам хостом. Используйте схему, хост и путь — в этом примере в белый список добавлен raw.githubusercontent.com/groupdocs-signature/.

Шаг 3 — Разрешить всё, сознательно

Поведение до версии 26.9, всё ещё доступное:

load_options = LoadOptions()
load_options.skip_external_resources = False

Уместно для документов, созданных вашим собственным приложением. Одна ловушка: устаревшее свойство load_external_resources имеет противоположную полярность, поэтому skip_external_resources = False заменяет load_external_resources = True. Скопировав значение из старого свойства, вы инвертируете свою политику безопасности без какого‑либо сообщения об ошибке.

Сравнение: до и после

Один и тот же документ, один и тот же путь кода, три политики загрузки. Ниже указаны размеры файлов, зафиксированных в папке Result/ примера, чтобы их можно было проверить, а не принимать на веру:

Режим загрузки Размер превью Исходящие запросы
по умолчанию (26.9 и новее) 16 435 байт нет
хост в белом списке 51 738 байт один, к разрешённому адресу
все ресурсы (значение по умолчанию до 26.9) 51 738 байт по одному для каждого связанного ресурса

Связанная картинка составляет 35 303 байта этой разницы. Я не доверял настройке, пока эти два числа не оказались рядом, и советую сделать то же: чтение свойства обратно показывает, что вы сконфигурировали, а не то, что процесс сделал.

Что считается внешним ресурсом?

Узкое определение, чем ожидают люди, поэтому обновление обычно проходит безболезненно. Связанные картинки вместо встроенных, поля INCLUDEPICTURE, связанные изображения в презентациях и таблицах, а также изображения и таблицы стилей, на которые ссылается SVG. Встроенный контент остаётся нетронутым, потому что он уже находится внутри файла и не требует запросов для отображения.

Это различие — вся граница безопасности. Документ может заставить ваш сервер выйти наружу только если он хранит адрес вместо байтов, поэтому вопрос для любого корпуса файлов прост: сколько из них ссылаются, а не встраивают? Если ни один не ссылается, новое значение по умолчанию ничего вам не стоит, и вы можете обновиться, не читая дальше.

Пример из реального мира: загрузка, которую подписывают

Случай, для которого и существует изменение значения по умолчанию. Документ приходит извне, и вам нужно поставить на него подпись:

with signature.Signature(source_path) as sign:
    options = QrCodeSignOptions("Approved by GroupDocs.Signature")
    options.encode_type = QrCodeTypes.QR
    options.left = 400
    options.top = 50
    options.width = 120
    options.height = 120

    result = sign.sign(output_path, options)

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

Что ещё меняется при обновлении?

Для большинства сервисов ничего заметного, что стоит явно отметить, потому что изменение значения по умолчанию, затрагивающего поведение везде, не прошло бы проверку при обновлении. Подпись, проверка и поиск остаются без изменений. Исключение — превью, которое раньше показывало связанную картинку, а теперь выводит заглушку — изменение делает свою работу. Добавьте хост в белый список, если это ваш, иначе отклоните.

Отдельно стоит упомянуть SVG. SVG может ссылаться на изображения и таблицы стилей по URL; такие ссылки считаются внешними ресурсами по тем же правилам, а SVG — распространённый формат загрузки и частый вектор SSRF. Сервис, принимающий SVG‑аватары и рендерящий их на сервере, именно тот, кого защищает данное изменение.

Один нюанс Python: как записывается превью

PreviewOptions принимает две фабрики потоков вместо пути, и обычные вызываемые объекты Python — всё, что требуется:

def create_page_stream(page_data):
    return open(preview_path, "wb")

def release_page_stream(page_data, page_stream):
    page_stream.close()

preview_options = PreviewOptions(create_page_stream, release_page_stream)
preview_options.preview_format = PreviewOptions.PreviewFormats.PNG
sign.generate_preview(preview_options)

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

Заключение

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

Если хотите более строгую проверку, чем размер файла, направьте тестовый документ на хост, которым вы управляете, и наблюдайте его журнал доступа, пока генерируется превью. Размер подскажет, пришли ли байты; журнал доступа покажет, был ли вообще запрос, и эти два показателя различаются именно в том случае, который имеет значение — белый список, который недоступен, выглядит так же, как заблокированный, если смотреть только на вывод.

Запуск примера на одном из ваших собственных документов занимает минуту и показывает в трёх размерах файлов точно, что ваш сервис запрашивал от имени отправителя.

Дополнительные ресурсы