Обзор безопасности
Библиотека HTML обрабатывает ненадёжный ввод из интернета, поэтому безопасность является её главным приоритетом в дизайне. Библиотека включает встроенные многоуровневые независимые механизмы защиты, следуя принципу эшелонированной защиты: каждый уровень предполагает, что другие могут отказать, и обход одного уровня не приводит к компрометации всей системы.
Эта страница — обзор функций безопасности. Если вы хотите сразу приступить к настройке конвейера аудита или проверке предзапускового чек-листа, перейдите к разделу Следующие шаги в конце.
Архитектура эшелонированной защиты
Защита библиотеки распределена по трём независимым уровням, каждый со своими режимами отказа и стратегиями восстановления:
Защита на уровне ввода (отклонение перед фильтрацией)
├── MaxInputSize Байтовый лимит размера (по умолчанию 50 МБ)
├── MaxDepth Лимит глубины вложенности DOM (по умолчанию 500, защита от переполнения стека)
├── ProcessingTimeout Тайм-аут обработки одного документа (по умолчанию 30 с)
├── Обнаружение обхода пути Компоненты .. в файловых путях
└── AllowedBaseDir Песочница чтения файлов (разрешение через дескриптор ОС, защита от symlink/junction)
Защита на уровне обработки (санирование и отмена)
├── HTML-санирование Многомерная фильтрация тегов / атрибутов / URL / CSS
├── Кооперативная отмена context ExtractWithContext реагирует на ctx.Done()
├── Восстановление Panic Обёртка recoverPanic, возвращает ErrInternalPanic
└── Защита от утечки Goroutine maxTimeoutGoroutines (лимит 1000)
Защита на уровне аудита (наблюдаемость + изоляция)
├── Изоляция panic AuditSink SEC-003 (подсистема аудита — best-effort)
├── 8 типов событий аудита blocked_tag/blocked_attr/blocked_url/...
└── HTML-экранирование исходных значений Предотвращает XSS в журналах аудита на дашбордах SIEMПодсказка
Почему акцент на «независимости»? Ценность эшелонированной защиты — в отсутствии предположений о связности между уровнями. Например, даже если уровень санирования пропустит вредоносный URL (отказ уровня обработки), MaxInputSize на уровне ввода всё равно заблокирует слишком длинный payload; даже если AuditSink сам вызовет panic (отказ уровня аудита), recoverPanic гарантирует, что основной процесс не завершится аварийно.
Защита границ ввода
Ограничение размера ввода
По умолчанию максимальный размер ввода — 50 МБ (DefaultMaxInputSize), защита от атак на исчерпание памяти. Верхний предел конфигурации также ограничен maxConfigInputSize (те же 50 МБ), то есть нельзя установить небезопасное значение через конфигурацию:
cfg := html.DefaultConfig()
cfg.MaxInputSize = 10 * 1024 * 1024 // Сужение до 10 МБДля файловых путей также существует предварительная проверка: после получения размера через Stat файл, превышающий лимит, отклоняется до загрузки содержимого в память через ReadAll, устраняя окно пикового использования памяти по сценарию «сначала полный reads, затем обнаружение превышения».
Ограничение глубины DOM
По умолчанию максимальная глубина — 500 (DefaultMaxDepth), защита от стековых бомб с рекурсивной вложенностью:
cfg.MaxDepth = 200 // СтрожеПроверка глубины использует итеративный обход, а не рекурсию, и сама по себе не вызывает переполнение стека при слишком глубоко вложенном вводе.
Тайм-аут обработки
Настраиваемый тайм-аут обработки предотвращает экспоненциальный рост времени обработки при злонамеренном HTML:
cfg.ProcessingTimeout = 10 * time.SecondМеханизм тайм-аута реализован через отдельный goroutine + context deadline и защищён верхним пределом maxTimeoutGoroutines (1000), предотвращающим выход goroutine из-под контроля при высокой нагрузке. В сочетании с ExtractWithContext можно добавить сигнал отмены от вызывающего.
Подробное описание механизма санирования контента
Санирование управляется параметром EnableSanitization (по умолчанию true), который модифицирует распарсенное DOM-дерево на месте, удаляя потенциально вредоносное содержимое. Весь процесс реализован в internal/sanitize.go, каждое перехваченное действие записывается в журнал аудита.
Предупреждение
Отключать санирование следует только если ввод — полностью доверенные внутренние данные. При обработке любого HTML из пользовательских загрузок, веб-скрапинга, сторонних API санирование должно быть включено.
Удаляемые теги
Следующие теги удаляются вместе с их поддеревом:
| Тег | Причина удаления |
|---|---|
<script>, <style>, <noscript> | Контейнеры скриптов и стилей, могут содержать исполняемый код или скрытые payload-ы |
<iframe>, <embed>, <object> | Встраивание внешнего содержимого, классические векторы XSS и фишинга |
<input>, <button> | Элементы управления формы, могут использоваться для CSRF или UI-подмены |
<svg> | Может содержать встроенный JavaScript и обработчики событий |
<math> | MathML, в некоторых браузерах может быть использован для выполнения скриптов |
Подсказка
Почему <form> не удаляется? Тег <form> намеренно сохраняется. Серверные фреймворки ASP.NET WebForms, JSF, JSP оборачивают весь <body> в один <form>, и удаление <form> привело бы к потере всего видимого содержимого страницы. Текстовое извлечение не рендерит и не отправляет формы, поэтому аргументы CSRF/UI-подмены, применимые к <input>/<button>, не применимы к самому контейнеру <form>.
Удаляемые атрибуты
| Категория атрибутов | Метод обнаружения | Пример |
|---|---|---|
| Обработчики событий | Префиксное сопоставление on* | onclick, onerror, onmouseover |
| Переопределение form action | Точное сопоставление | formaction (может перехватить целевую отправку формы) |
| Автофокус | Точное сопоставление | autofocus (может использоваться для фишинга и принуждения к клику) |
Обнаружение обработчиков событий использует префиксное сопоставление (имя атрибута начинается с on), что позволяет покрывать все существующие и будущие варианты on*, а не полагаться на фиксированный чёрный список.
Опасные CSS-паттерны
Значения атрибута style проверяются на следующие опасные подстроки; при совпадении весь style удаляется (сохранение безопасных CSS-свойств для извлечения метаданных требует отдельной оценки, текущая стратегия — полное удаление при обнаружении опасности):
expression(— динамические выражения старых версий IEbehavior:— привязка поведений IE-moz-binding:— привязка XBL старых версий Firefoxjavascript:— инъекция протоколаvbscript:— инъекция протокола
Проверяемые URI-атрибуты
Значения следующих атрибутов проходят через конвейер URL-защиты для проверки протокола и data URL:
href src cite action data poster background
longdesc usemap profile xlink:hrefПодсказка
Полностью заблокированные атрибуты не проверяются повторно. formaction уже заблокирован в разделе «Удаляемые атрибуты», поэтому он не попадает в конвейер URI-проверки — чтобы избежать избыточной проверки одного и того же атрибута.
Глубина URL-защиты
Значения URI-атрибутов проверяются через многоуровневый конвейер isSafeURIWithAudit. Каждый уровень соответствует известному поведению браузерного парсинга или технике обхода защиты — ни один нельзя пропустить.
Многоуровневый конвейер проверки
Исходный URI
│
├─ 1. NFC-нормализация Нормализация полноширинных/комбинированных символов
├─ 2. TrimSpace Удаление пробелов в начале и конце
├─ 3. Удаление C0-управляющих + пробелов Удаление начальных/конечных U+0000–U+001F и пробелов
├─ 4. Удаление tab/LF/CR Удаление \t \n \r внутри URL
├─ 5. ToLower Нормализация регистра
├─ 6. Лимит длины Не-data URL ограничены MaxURLLength(2000)
├─ 7. Проверка опасных протоколов javascript: / vbscript: / file: + полноширинные варианты
├─ 8. Проверка protocol-relative URL //javascript: и др.
└─ 9. Белый список data URL Только изображения/шрифты/PDF, блокировка svg и пустых MIMEUnicode-нормализация (NFC)
Первый шаг выполняет NFC-нормализацию URI (normalizeURIForSecurity), предотвращая маскировку опасных протоколов с помощью Unicode-преобразований:
- Полноширинные символы
javascript:отображаются обратно в ASCII - Комбинированные символы, кросс-скриптовые похожие символы нормализуются к единому представлению
Поверх этого isDangerousScheme также выполняет отдельное ASCII-свёртывание полноширинных латинских символов (U+FF01–U+FF5E) через normalizeFullwidthToASCII, обеспечивая двойную страховку — даже если некоторые браузеры/парсеры обрабатывают полноширинные формы как ASCII, библиотека их распознает.
Удаление пробелов и управляющих символов
Браузеры при парсинге URL удаляют определённые управляющие символы (следуя стандарту WHATWG URL), и библиотека должна симулировать то же удаление до проверки протокола, иначе злоумышленник может использовать эти символы для разбиения имени опасного протокола и обхода проверки:
- tab / LF / CR:
java\tscript:будет собран браузером обратно вjavascript:и выполнен. Библиотека используетstripURLWhitespaceдля удаления этих трёх байтов перед проверкой протокола. - C0-управляющие символы (U+0000–U+001F) + ASCII-пробелы: браузеры удаляют начальные и конечные байты перед парсингом схемы.
strings.TrimSpaceпокрывает только Unicode-пробелы, но не большинство C0-управляющих символов, поэтому библиотека использует отдельное множествоc0ControlOrSpaceдля явного удаления. Иначе\x01javascript:…сможет обойти все проверкиHasPrefix.
Обнаружение опасных протоколов
| Протокол | Метод блокировки |
|---|---|
javascript: | Прямое сопоставление + нормализация полноширинных символов |
vbscript: | То же |
file: | То же (доступ к локальной файловой системе) |
//javascript:, //vbscript:, //data:, //file: | Протокол-относительные формы проверяются отдельно |
Проверка протокол-относительных форм (начинающихся с //) сначала удаляет начальные пробелы после //, затем применяет то же определение опасного протокола, что гарантирует невозможность обхода для вариантов типа // javascript:.
Белый список data URL
Data URL разрешают только следующие явно объявленные MIME-типы:
| Категория | Разрешённые MIME-типы |
|---|---|
| Изображения | image/gif, image/jpeg, image/jpg, image/png, image/webp, image/bmp, image/x-icon, image/vnd.microsoft.icon, image/avif, image/apng |
| Шрифты | font/woff, font/woff2, font/ttf, font/otf, application/font-woff, application/font-woff2 |
| Документы | application/pdf |
Явно блокируются:
image/svg+xml: SVG может содержать встроенный JavaScript — работает как патч эшелонированной защиты после удаления тега.- Пустые медиа-типы: например
data:;base64,<payload>илиdata:;,.... Эти формы ранее могли обходить белый список, теперь отклоняются напрямую. - Сверхдлинные data URL: ограничены
MaxDataURILength(100 КБ), предотвращая исчерпание памяти большими блоками base64. - Недопустимые символы base64: base64-часть посимвольно проверяется на корректность набора символов.
Подсказка
Журнал аудита обрезает data URL. Data URL может содержать длинный base64, и полная запись в журнал аудита — пустая трата места и возможная утечка встроенного конфиденциального содержимого. Записи аудита обрезаются через truncateAuditURL до 256 символов.
Пути обхода санирования
Несколько путей кода (например ExtractAllLinks, сканирование video/audio в исходном HTML) читают несанированный HTML. Эти пути защищены через containsDangerousScheme — он использует тот же конвейер нормализации, что и санизатор (NFC, trim, удаление C0, удаление tab/LF/CR, свёртывание полноширинных), гарантируя, что оба пути выполняют одну и ту же политику протоколов, без несоответствий вида «санизатор блокирует, а здесь пропускается».
Например, payload типа javascript:alert(1).mp4, замаскированный под медиа-URL, ранее мог пройти простую проверку (первый символ j — буква), теперь перехватывается containsDangerousScheme.
Механизм песочницы AllowedBaseDir
При обработке файловых путей из ненадёжных источников через ExtractFromFile, AllowedBaseDir ограничивает область чтения указанным каталогом. Механизм реализован функциями readContained / realPath / pathWithin в processor.go.
Почему нужно разрешение через дескриптор ОС
Обычный filepath.EvalSymlinks не может разрешить Windows directory junction и reparse points — а они создаются без каких-либо привилегий и являются основным средством обхода ограничений пути в Windows. Подход библиотеки:
os.Open()открывает целевой файл, получая дескриптор ОСrealPath(f)разрешает реальный путь на диске из уже открытого дескриптораpathWithin(realBase, realTarget)определяет, попадает ли реальный путь в разрешённый каталог- Из того же проверенного дескриптора
io.ReadAllчитает содержимое
Чтение и проверка из одного дескриптора закрывает окно TOCTOU (time-of-check-to-time-of-use) — подмена пути на символическую ссылку между проверкой и чтением не может повлиять на результат, поскольку читается тот самый inode, который был проверен.
Кроссплатформенное разрешение реального пути
| Платформа | Метод разрешения | Покрываемые типы перенаправления |
|---|---|---|
| Linux | Чтение link из /proc/self/fd/<fd> | Символические ссылки (race-free) |
| macOS / BSD | Чтение link из /dev/fd/<fd> | Символические ссылки (race-free) |
| Прочие Unix | Откат к filepath.EvalSymlinks | Символические ссылки (остаточный лёгкий TOCTOU) |
| Windows | GetFinalPathNameByHandleW | Символические ссылки + junction + все reparse points |
Возвращённый Windows путь очищается от префикса расширенной длины \\?\ и обрабатывается через Clean, приводя к формату, согласованному с выводом filepath.Abs, что обеспечивает точность последующего сравнения включения.
Уровни защиты
Чтение файлов в режиме AllowedBaseDir накладывает четыре независимые проверки:
- Обнаружение обхода пути: после
filepath.Cleanпроверяется наличие компонентов.. - Песочница через дескриптор ОС:
realPathразрешает реальный путь,pathWithinопределяет включение - Предварительная проверка размера:
Statна проверенном дескрипторе, файлы сверхMaxInputSizeотклоняются доReadAll - Байтовый лимит: после чтения
validateInputповторно проверяет количество байтов
Даже если файл находится в разрешённом каталоге, AllowedBaseDir ограничивает «какой файл можно прочитать», а MaxInputSize ограничивает «какого размера файл может быть» — эти ограничения ортогональны и не заменяют друг друга.
Предупреждение
Пустой AllowedBaseDir = песочница не активна. AllowedBaseDir по умолчанию — пустая строка, означающая отсутствие ограничения по каталогу (сохраняется только проверка обхода пути через ..). Если ваши файловые пути поступают от пользователя, обязательно явно установите этот параметр.
Пример конфигурации
cfg := html.DefaultConfig()
// Разрешить чтение файлов только из /var/app/uploads и его подкаталогов
cfg.AllowedBaseDir = "/var/app/uploads"
p, err := html.New(cfg)
if err != nil {
log.Fatal(err)
}
defer p.Close()
// Нормально: файл в разрешённом каталоге
result, err := p.ExtractFromFile("/var/app/uploads/page.html")
// Отклонено: указывает наружу через symlink/junction
_, err = p.ExtractFromFile("/var/app/uploads/escape.txt") // Если это junction → /etcОтклонённый выход за границы записывается как событие аудита AuditEventPathTraversal и возвращает *FileError, обёрнутый сообщением "path outside allowed directory"; ошибка не содержит разрешённый реальный путь (чтобы избежать утечки структуры файловой системы).
Восстановление после паник и изоляция
Все публичные методы извлечения обёрнуты в обобщённую функцию recoverPanic[T], panic перехватывается и преобразуется в ошибку ErrInternalPanic, гарантируя, что злонамеренный ввод не приведёт к аварийному завершению процесса вызывающего.
func recoverPanic[T any](fn func() (T, error)) (result T, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%w: %v", ErrInternalPanic, r)
}
}()
return fn()
}Многоуровневые границы изоляции
| Граница изоляции | Поведение | Расположение |
|---|---|---|
| Однократное извлечение | panic → ErrInternalPanic | extract.go recoverPanic |
| Отдельный элемент пакета | Независимый recover для каждого элемента, panic одного не влияет на другие | batch.go |
| Goroutine тайм-аута | Независимый recover в worker-goroutine withTimeout | extract.go |
| Запись в AuditSink | panic самого sink подавляется (SEC-003) | audit.go Record |
| Закрытие AuditSink | panic sink.Close оборачивается в ErrInternalPanic и возвращается через error | audit.go Close |
| Создание пула Processor | panic в sync.Pool.New → ErrInternalPanic | processor_pool.go |
SEC-003: подсистема аудита — best-effort
Подсистема аудита никогда не должна распространять panic до вызывающего публичного API. AuditSink, переданный пользователем (возможно, самописный, возможно, с фильтрами/мультиплексорами), может вызвать panic в Write() на любом пути. Record использует defer recover() для подавления таких panic (восстановленное значение отбрасывается — у пути аудита нет безопасного канала отчётности); Close, имея возвращаемое значение error, оборачивает восстановленное значение в ErrInternalPanic.
Это означает, что даже если в вашем пользовательском Sink есть ошибка, идиома defer processor.Close() не приведёт к аварийному завершению процесса.
Защита от утечки Goroutine
withTimeout при каждом вызове создаёт worker-goroutine для ожидания deadline. Для предотвращения выхода goroutine из-под контроля при высокой нагрузке глобальный счётчик activeTimeoutGoroutines ограничивает параллелизм до maxTimeoutGoroutines (1000). При превышении новые запросы немедленно возвращают ErrProcessingTimeout вместо бесконечного накопления goroutine (при оценке ~1 МБ стека на goroutine, 1000 ≈ 1 ГБ верхний предел).
Защита от инъекций в журнал через исходные значения аудита
Когда AuditConfig.IncludeRawValues = true, записи аудита включают исходные значения перехваченных атрибутов/URL. Эти значения проходят через sanitizeRawValue, выполняющий HTML-экранирование (& < > " '), что предотвращает хранимый XSS при отображении журнала аудита в браузере или на дашборде SIEM.
Система аудита
События безопасности записываются через систему аудита, поддерживающую 8 типов событий, несколько встроенных Sink-ов и фильтрацию по уровням. Полная конфигурация описана в Система аудита на практике.
| Событие | Описание |
|---|---|
AuditEventBlockedTag | Заблокированные HTML-теги |
AuditEventBlockedAttr | Заблокированные атрибуты |
AuditEventBlockedURL | Заблокированные URL |
AuditEventInputViolation | Нарушения размера ввода |
AuditEventDepthViolation | Нарушения глубины DOM |
AuditEventPathTraversal | Попытки обхода пути (включая выход за AllowedBaseDir) |
AuditEventTimeout | Тайм-ауты обработки |
AuditEventEncodingIssue | Аномалии кодировки |
Таблица решений по конфигурации безопасности
| Сценарий | Рекомендуемая конфигурация | Описание |
|---|---|---|
| Полностью доверенные внутренние данные | DefaultConfig() + опционально EnableSanitization = false | Приоритет производительности; отключайте санирование только при подтверждённом отсутствии внешнего ввода |
| HTML от пользовательских загрузок | HighSecurityConfig() | Полная защита: ужесточённые ограничения + полный аудит |
| Обработка внешних веб-страниц | DefaultConfig() | Санирование по умолчанию покрывает распространённые угрозы |
| Обработка пользовательских файловых путей | Установить AllowedBaseDir | Включить песочницу через дескриптор ОС для защиты от symlink/junction-эскалации |
| Высоконагруженный краулер | Уменьшить MaxInputSize + сократить ProcessingTimeout | Защита от злонамеренных страниц, замедляющих Worker-ы |
Конфигурация высокой безопасности
HighSecurityConfig() — это предустановка, которая ужесточает все ограничения и включает полный аудит одним вызовом:
cfg := html.HighSecurityConfig()
// Автоматически устанавливает:
// MaxInputSize = 10 МБ (по умолчанию 50 МБ)
// MaxDepth = 100 (по умолчанию 500)
// ProcessingTimeout = 10 с (по умолчанию 30 с)
// WorkerPoolSize = 2 (по умолчанию 4)
// Audit = HighSecurityAuditConfig() (включён + с исходными значениями)Обработка ошибок
Все нарушения безопасности возвращают чёткие сигнальные ошибки, поддерживающие определение категории через errors.Is:
package main
import (
"errors"
"fmt"
"github.com/cybergodev/html"
)
func main() {
data := []byte(`<html><body>Злонамеренная сверхглубокая вложенность</body></html>`)
_, err := html.Extract(data)
if err != nil {
switch {
case errors.Is(err, html.ErrInputTooLarge):
// Ввод превышает лимит, записать и отклонить
fmt.Println("Ввод слишком большой")
case errors.Is(err, html.ErrMaxDepthExceeded):
// Возможно рекурсивная бомба
fmt.Println("Нарушение глубины")
case errors.Is(err, html.ErrInternalPanic):
// Panic восстановлен, следует исследовать ввод и сообщить
fmt.Println("Внутренняя паника восстановлена")
}
}
// Вывод: Нарушение глубины (пример, фактический результат зависит от ввода)
}Подсказка
Для файловых ошибок используйте SafePath. Для *FileError используйте SafePath() для получения маскированной строки пути, вместо прямой печати исходной error — чтобы избежать утечки разрешённого реального пути в журналы.
Следующие шаги
- Система аудита на практике — 8 типов событий, сравнение встроенных Sink-ов, конвейер маршрутизации по уровням
- Контрольный список для production — предзапусковый чек-лист безопасности
- Справочник API: система аудита — полные сигнатуры API