Insights / Технические материалы
Безопасный рендеринг Markdown-ссылок в ответах AI-чата
Техническая заметка о том, как безопасно превращать Markdown-ссылки из ответов AI-чата в HTML. Парсинг с допуском пробелов, trim для href, allowlist, DOM-рендеринг, fallback и тесты рассматриваются отдельно.
Содержание
Если AI-чат отвечает Подробнее см. [Services]( /services/ ), ссылка может не отрендериться, а исходный Markdown останется на экране.
Acecore столкнулся с этим в контактном AI-чате и поправил renderer в PR с исправлением Markdown-ссылок.
Эта статья использует небольшое исправление как вход в тему безопасного превращения AI-ответов в DOM.
Ответы AI не являются доверенным HTML
Вывод модели нужно считать текстом.
В чате полезны ссылки, жирный текст и списки. Но innerHTML заставляет браузер интерпретировать любую строку, которую произвела модель.
Не нужно реализовывать весь Markdown. Нужен небольшой renderer, который распознает только поддерживаемые элементы и создает безопасные DOM-узлы.
Проблема не только в пробелах
Конкретная ошибка была в ссылке:
[Services](/services/)
Строгая regex часто считает, что URL не содержит пробелов:
/\[([^\]]+)\]\(([^)\s]+)\)/;
[^)\s]+ отклоняет пробелы, поэтому ( /services/ ) не распознается. Исправление допускает пробелы внутри скобок, а затем нормализует значение.
/\[([^\]]+)\]\(\s*([^)]+?)\s*\)/;
Но ослабить parser недостаточно. Нормализованное значение обязательно нужно проверить.
Делайте trim перед проверкой href
Порядок должен быть таким:
- Извлечь label и raw href из Markdown
- Применить
trim()к raw href - Проверить href по allowlist
- Создать
<a>только если href разрешен
const href = String(rawHref || "").trim();
if (label && isSafeMarkdownHref(href)) {
const link = document.createElement("a");
link.href = href;
link.rel = "noopener noreferrer";
if (/^https?:\/\//i.test(href)) {
link.target = "_blank";
}
link.textContent = label;
parent.appendChild(link);
}
Проверяемое значение должно быть тем же, что попадает в DOM.
Allowlist зависит от продукта
Каждый сайт должен решить, какие URL может показывать AI.
| Тип | Пример | Решение |
|---|---|---|
| Внутренний путь | /services/ |
Разрешить |
| Тот же origin | https://acecore.net/... |
Разрешить |
| Официальный LINE | https://lin.ee/... |
Разрешить как официальный канал |
| mailto | mailto:info@acecore.net |
Только фиксированный адрес |
| tel | tel:05088902788 |
Только фиксированный номер |
| Другие внешние | Любой URL | По умолчанию не ссылать |
function isSafeMarkdownHref(href) {
if (href.startsWith("/")) return true;
try {
const url = new URL(href, window.location.origin);
if (url.origin === window.location.origin) return true;
if (url.hostname === "acecore.net") return true;
if (url.hostname === "lin.ee") return true;
} catch {
return false;
}
return href === "mailto:info@acecore.net" || href === "tel:05088902788";
}
Рекрутинговый сайт может разрешать job boards, SaaS может разрешать документацию и status page. Функция должна отражать политику продукта.
Fallback в текст
Если ссылка не проходит проверку, удаление не всегда лучший вариант.
В контактном AI-чате текстовый fallback сохраняет контекст для пользователя и помогает разработчикам увидеть, что модель пыталась вывести.
Renderer должен не только создавать безопасные ссылки, но и безопасно отказывать.
Тестируйте плохие случаи
Минимальный набор:
| Ввод | Ожидаемый результат |
|---|---|
[Services](/services/) |
Внутренняя ссылка |
[Services]( /services/ ) |
Внутренняя ссылка после trim |
[LINE]( https://lin.ee/example ) |
Разрешенная внешняя ссылка |
[Bad](javascript:alert(1)) |
Не превращается в ссылку |
[External](https://example.com/) |
Не ссылка, если домен не разрешен |
[Broken](/services/ |
Отображается как текст |
В PR #99 было проверено, что варианты с пробелами и без них ведут к ожидаемому URL.
Не реализуйте весь Markdown по умолчанию
Для чата обычно достаточно:
- Абзацы
- Списки
- Жирный текст
- Inline-code
- Ссылки
Таблицы, изображения, raw HTML и footnotes быстро расширяют ответственность renderer. Даже с библиотекой политика HTML и URL остается отдельным решением.
Итог
Рендеринг Markdown-ссылок в AI-ответах выглядит как небольшая UI-правка, но на деле задает границу доверия к выводу модели.
Практическое правило: сначала текст, маленькое подмножество, trim перед проверкой, строгий allowlist и безопасный fallback.
Поток рендеринга ссылок
Text
Сначала рассматривать ответ модели как обычный текст.
Parse
Находить только те Markdown-элементы, которые реально поддерживает чат.
Validate
Делать trim для href и разрешать только внутренние URL или одобренные домены.
Render
Создавать безопасные элементы через DOM API, не через innerHTML.
Какие решения нужно разделять
Слабый рендеринг
- Вставлять ответы AI прямо в innerHTML
- Пытаться сразу реализовать весь Markdown
- Не распознавать ссылки с пробелами вокруг URL
- Обрабатывать внешние URL и javascript: одинаково
Малый и безопасный рендеринг
- Принимать ответы как текст и превращать в DOM только нужное
- Поддерживать только подмножество Markdown для чата
- Проверять URL после trim
- Оставлять запрещенные URL обычным текстом
Чеклист внедрения
- Не доверять ответам AI как HTML
- Разрешать пробелы вокруг URL в Markdown-ссылках
- Всегда делать trim href перед проверкой
- Разрешать только внутренние пути, текущий origin и нужные внешние домены
- Явно задавать target и rel для внешних ссылок
- Сохранять запрещенные ссылки как текст
- Тестировать опасные URL и сломанный Markdown
Связанные страницы
Частые вопросы
Достаточно ли markdown-it или marked?
Даже с библиотекой нужно отдельно решить, как обрабатывать HTML, какие цели ссылок разрешены, как добавлять target и rel и как отклонять опасные URL. Для чата небольшого собственного renderer может быть достаточно.
Опасно ли разрешать пробелы вокруг URL?
Риск не в пробелах, а в том, что разрешается после trim. Проверка нормализованного href сохраняет allowlist строгим.
Нужно ли удалять запрещенные URL?
Обычно текстовый fallback проще для отладки и сохраняет контекст. Более строгая политика может удалить всю ссылку.