Перейти к основному содержимому

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.

Рассмотренные варианты​

  1. Только усиление промпта (разделители + директива «данные, не инструкции»). Дёшево, ничего не ломает; но не даёт гарантии — обход инструкции моделью остаётся вопросом формулировки атаки.
  2. Только проверка исходящего текста перед публикацией. Даёт детерминированную гарантию на границе с WB, но оставляет модель управляемой извне и не защищает ручной путь от подсунутого продавцу текста.
  3. Модель-судья (второй вызов LLM) для оценки ответа. Сильнее по семантике, но удваивает стоимость и латентность каждой генерации и сам судья подвержен инъекции.
  4. Отключить авто-режим, всё через ручное подтверждение. Полностью снимает риск авто-публикации, но обнуляет ценность раздела 5.5.

Решение​

Принят вариант 1 + 2 как эшелонированная защита, с детерминированной проверкой в роли последнего рубежа.

  1. Санитизация входа. Текст покупателя и поля карточки проходят sanitizeUntrusted (reply-guard.util.ts): вычищаются управляющие символы, нейтрализуются угловые скобки (подделать закрывающий разделитель нельзя), текст обрезается до 2000 символов. Санитизированные значения идут и в промпт, и в переменные шаблона — иначе шаблонный рендер, который служит запасным текстом, унаследовал бы содержимое отзыва как есть.
  2. Разделители и директива. Недоверенные фрагменты обёрнуты в <feedback>/<example>; AUTOREPLY_SYSTEM_PROMPT и COMPLAINT_SYSTEM_PROMPT объявляют содержимое этих тегов данными и запрещают ссылки и контакты в ответе.
  3. Проверка исходящего текста (checkOutboundReply) в AnswerSendService.sendDraft — единственной точке, через которую текст попадает на карточку WB, что покрывает и ручной, и авто-путь одним контролем. Правила: длина 2..2000 (WB допускает 2..5000), отсутствие ссылок, доменов, e-mail и телефонов, отсутствие следов промпта и достаточная связь с шаблонным черновиком (доля общих основ слов ≥ 0.2 при шаблоне от 5 значимых слов). Провал → публикуется шаблонный вариант (ReplySource.Template), а если и он непригоден — ответ не публикуется, черновик уходит в error без проброса исключения (ретрай такого текста бессмыслен). Каждое срабатывание пишется в audit_logs (autoreply.answer.guard). Та же проверка применяется в генерации сразу после ответа модели — чтобы подозрительный текст не доходил даже до ручного подтверждения.
  4. Хранение шаблонного варианта. Добавлена колонка autoreply_drafts.template_text — база сравнения и запасной текст. Nullable: у черновиков до миграции базы сравнения нет, для них проверка связи пропускается, остальные правила действуют.
  5. Разрыв персистентности. В примеры для промпта попадают только прошлые ответы, которые сами проходят checkOutboundReply.
  6. Маркеры инъекции в авто-режиме. При срабатывании detectInjectionMarkers на тексте покупателя авто-публикация отменяется: черновик остаётся в draft на ручной разбор (аудит autoreply.generate.auto-send.blocked), а жалоба остаётся в draft вместо pending.
  7. Лимит на LLM-роуты — @ThrottleLlm() на POST /autoreply/generate и POST /autoreply/complaints/prepare (OWASP LLM10).

Последствия​

  • Положительные:
    • Произвольный текст из отзыва не может быть опубликован от имени продавца: даже при полностью «захваченной» модели работает детерминированный фильтр на границе с WB.
    • Инъекция не самовоспроизводится через примеры прошлых ответов.
    • Каждое срабатывание наблюдаемо: audit_logs + WARN-лог с причиной.
  • Отрицательные / компромиссы:
    • Возможны ложные срабатывания: легитимный ответ с номером телефона или ссылкой будет заменён шаблонным. Считаем приемлемым — контактам вне WB в ответе продавца взяться неоткуда, а цена ошибки асимметрична.
    • Проверка связи с шаблоном — эвристика по основам слов; на коротких шаблонах (< 5 значимых слов) она намеренно не применяется.
    • Маркеры инъекции — списки регулярных выражений: их придётся пополнять, и они не претендуют на полноту. Это фильтр авто-режима, а не граница безопасности; границей служит проверка исходящего текста.
  • Что дальше:
    • Вывести счётчик срабатываний фильтра в метрики Prometheus и завести алерт на всплеск (отдельный тикет).
    • Показать продавцу причину блокировки в UI списка черновиков.