Insights / Технические материалы
Безопасный рендеринг Markdown-ссылок в ответах AI-чата
Техническая заметка о том, как безопасно превращать Markdown-ссылки из ответов AI-чата в HTML. Парсинг с допуском пробелов, trim для href, allowlist, DOM-рендеринг, fallback и тесты рассматриваются отдельно.

Содержание
Если ссылка ИИ остаётся текстом, пропустите один ввод через разбор, проверку URL и построение DOM, чтобы найти сбой. Для подписей используйте текстовые API, например MDN: textContent, отделяя разрешение ссылки от интерпретации HTML.
Если 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(rawHref) {
const href = String(rawHref ?? "").trim();
if (!href || /[\u0000-\u001f\u007f\\]/u.test(href)) return false;
if (href === "mailto:info@acecore.net" || href === "tel:05088902788") {
return true;
}
try {
if (href.startsWith("/")) {
if (href.startsWith("//")) return false;
const url = new URL(href, window.location.origin);
return (
url.origin === window.location.origin && !url.username && !url.password
);
}
const url = new URL(href);
return (
url.protocol === "https:" &&
!url.username &&
!url.password &&
!url.port &&
[
window.location.origin,
"https://acecore.net",
"https://lin.ee",
].includes(url.origin)
);
} catch {
return false;
}
}
Рекрутинговый сайт может разрешать 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 проще для отладки и сохраняет контекст. Более строгая политика может удалить всю ссылку.