0045. Защита автоответов от косвенной prompt injection
Копия ADR из репозитория:
backend/docs/adr/0045-autoreply-prompt-injection-guard.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted
- Дата: 2026-08-05
- Связанные задачи: аудит 2026-08-05 (находка 1), OP-73 / OP-74 / OP-75
Контекст и проблема
Модуль автоответов (5.5) генерирует ответ на отзыв покупателя через LLM и при
scenario.replyMode = auto публикует его на карточке Wildberries без участия
человека. До этого решения текст покупателя склеивался в промпт обычной
шаблонной строкой, а между draft.text и WB не было ни одной проверки
содержимого.
Это единственный в системе путь, где внешний актор без доступа к сервису
(покупатель, оставивший отзыв) влияет на публично видимое поведение продукта:
удавшаяся инъекция даёт произвольный текст под именем продавца на карточке
товара. Персистентность усугубляла риск — прошлые отправленные ответы
подмешивались в промпт как примеры по nmId, поэтому однажды сработавшая
инъекция воспроизводилась в последующих генерациях.
Ограничения:
- Инструкции в system-промпте — вероятностная мера: полагаться только на послушание модели нельзя.
- Ломать существующий контракт (эндпоинты, статусы черновика) нежелательно —
фронт уже работает с
draft/pending/sent/error. - Отказ от авто-режима как такового противоречит требованию ТЗ 5.5.
Рассмотренные варианты
- Только усиление промпта (разделители + директива «данные, не инструкции»). Дёшево, ничего не ломает; но не даёт гарантии — обход инструкции моделью остаётся вопросом формулировки атаки.
- Только проверка исходящего текста перед публикацией. Даёт детерминированную гарантию на границе с WB, но оставляет модель управляемой извне и не защищает ручной путь от подсунутого продавцу текста.
- Модель-судья (второй вызов LLM) для оценки ответа. Сильнее по семантике, но удваивает стоимость и латентность каждой генерации и сам судья подвержен инъекции.
- Отключить авто-режим, всё через ручное подтверждение. Полностью снимает риск авто-публикации, но обнуляет ценность раздела 5.5.
Решение
Принят вариант 1 + 2 как эшелонированная защита, с детерминированной проверкой в роли последнего рубежа.
- Санитизация входа. Текст покупателя и поля карточки проходят
sanitizeUntrusted(reply-guard.util.ts): вычищаются управляющие символы, нейтрализуются угловые скобки (подделать закрывающий разделитель нельзя), текст обрезается до 2000 символов. Санитизированные значения идут и в промпт, и в переменные шаблона — иначе шаблонный рендер, который служит запасным текстом, унаследовал бы содержимое отзыва как есть. - Разделители и директива. Недоверенные фрагменты обёрнуты в
<feedback>/<example>;AUTOREPLY_SYSTEM_PROMPTиCOMPLAINT_SYSTEM_PROMPTобъявляют содержимое этих тегов данными и запрещают ссылки и контакты в ответе. - Проверка исходящего текста (
checkOutboundReply) вAnswerSendService.sendDraft— единственной точке, через которую текст попадает на карточку WB, что покрывает и ручной, и авто-путь одним контролем. Правила: длина 2..2000 (WB допускает 2..5000), отсутствие ссылок, доменов, e-mail и телефонов, отсутствие следов промпта и достаточная связь с шаблонным черновиком (доля общих основ слов ≥ 0.2 при шаблоне от 5 значимых слов). Провал → публикуется шаблонный вариант (ReplySource.Template), а если и он непригоден — ответ не публикуется, черновик уходит вerrorбез проброса исключения (ретрай такого текста бессмыслен). Каждое срабатывание пишется вaudit_logs(autoreply.answer.guard). Та же проверка применяется в генерации сразу после ответа модели — чтобы подозрительный текст не доходил даже до ручного подтверждения. - Хранение шаблонного варианта. Добавлена колонка
autoreply_drafts.template_text— база сравнения и запасной текст. Nullable: у черновиков до миграции базы сравнения нет, для них проверка связи пропускается, остальные правила действуют. - Разрыв персистентности. В примеры для промпта попадают только прошлые
ответы, которые сами проходят
checkOutboundReply. - Маркеры инъекции в авто-режиме. При срабатывании
detectInjectionMarkersна тексте покупателя авто-публикация отменяется: черновик остаётся вdraftна ручной разбор (аудитautoreply.generate.auto-send.blocked), а жалоба остаётся вdraftвместоpending. - Лимит на LLM-роуты —
@ThrottleLlm()наPOST /autoreply/generateиPOST /autoreply/complaints/prepare(OWASP LLM10).
Последствия
- Положительные:
- Произвольный текст из отзыва не может быть опубликован от имени продавца: даже при полностью «захваченной» модели работает детерминированный фильтр на границе с WB.
- Инъекция не самовоспроизводится через примеры прошлых ответов.
- Каждое срабатывание наблюдаемо:
audit_logs+ WARN-лог с причиной.
- Отрицательные / компромиссы:
- Возможны ложные срабатывания: легитимный ответ с номером телефона или ссылкой будет заменён шаблонным. Считаем приемлемым — контактам вне WB в ответе продавца взяться неоткуда, а цена ошибки асимметрична.
- Проверка связи с шаблоном — эвристика по основам слов; на коротких шаблонах (< 5 значимых слов) она намеренно не применяется.
- Маркеры инъекции — списки регулярных выражений: их придётся пополнять, и они не претендуют на полноту. Это фильтр авто-режима, а не граница безопасности; границей служит проверка исходящего текста.
- Что дальше:
- Вывести счётчик срабатываний фильтра в метрики Prometheus и завести алерт на всплеск (отдельный тикет).
- Показать продавцу причину блокировки в UI списка черновиков.