Skip to content

Обзор безопасности

Библиотека HTML обрабатывает ненадёжный ввод из интернета, поэтому безопасность является её главным приоритетом в дизайне. Библиотека включает встроенные многоуровневые независимые механизмы защиты, следуя принципу эшелонированной защиты: каждый уровень предполагает, что другие могут отказать, и обход одного уровня не приводит к компрометации всей системы.

Эта страница — обзор функций безопасности. Если вы хотите сразу приступить к настройке конвейера аудита или проверке предзапускового чек-листа, перейдите к разделу Следующие шаги в конце.

Архитектура эшелонированной защиты

Защита библиотеки распределена по трём независимым уровням, каждый со своими режимами отказа и стратегиями восстановления:

text
Защита на уровне ввода (отклонение перед фильтрацией)
├── 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 МБ), то есть нельзя установить небезопасное значение через конфигурацию:

go
cfg := html.DefaultConfig()
cfg.MaxInputSize = 10 * 1024 * 1024 // Сужение до 10 МБ

Для файловых путей также существует предварительная проверка: после получения размера через Stat файл, превышающий лимит, отклоняется до загрузки содержимого в память через ReadAll, устраняя окно пикового использования памяти по сценарию «сначала полный reads, затем обнаружение превышения».

Ограничение глубины DOM

По умолчанию максимальная глубина — 500 (DefaultMaxDepth), защита от стековых бомб с рекурсивной вложенностью:

go
cfg.MaxDepth = 200 // Строже

Проверка глубины использует итеративный обход, а не рекурсию, и сама по себе не вызывает переполнение стека при слишком глубоко вложенном вводе.

Тайм-аут обработки

Настраиваемый тайм-аут обработки предотвращает экспоненциальный рост времени обработки при злонамеренном HTML:

go
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( — динамические выражения старых версий IE
  • behavior: — привязка поведений IE
  • -moz-binding: — привязка XBL старых версий Firefox
  • javascript: — инъекция протокола
  • vbscript: — инъекция протокола

Проверяемые URI-атрибуты

Значения следующих атрибутов проходят через конвейер URL-защиты для проверки протокола и data URL:

text
href  src  cite  action  data  poster  background
longdesc  usemap  profile  xlink:href

Подсказка

Полностью заблокированные атрибуты не проверяются повторно. formaction уже заблокирован в разделе «Удаляемые атрибуты», поэтому он не попадает в конвейер URI-проверки — чтобы избежать избыточной проверки одного и того же атрибута.

Глубина URL-защиты

Значения URI-атрибутов проверяются через многоуровневый конвейер isSafeURIWithAudit. Каждый уровень соответствует известному поведению браузерного парсинга или технике обхода защиты — ни один нельзя пропустить.

Многоуровневый конвейер проверки

text
Исходный 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 и пустых MIME

Unicode-нормализация (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. Подход библиотеки:

  1. os.Open() открывает целевой файл, получая дескриптор ОС
  2. realPath(f) разрешает реальный путь на диске из уже открытого дескриптора
  3. pathWithin(realBase, realTarget) определяет, попадает ли реальный путь в разрешённый каталог
  4. Из того же проверенного дескриптора 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)
WindowsGetFinalPathNameByHandleWСимволические ссылки + junction + все reparse points

Возвращённый Windows путь очищается от префикса расширенной длины \\?\ и обрабатывается через Clean, приводя к формату, согласованному с выводом filepath.Abs, что обеспечивает точность последующего сравнения включения.

Уровни защиты

Чтение файлов в режиме AllowedBaseDir накладывает четыре независимые проверки:

  1. Обнаружение обхода пути: после filepath.Clean проверяется наличие компонентов ..
  2. Песочница через дескриптор ОС: realPath разрешает реальный путь, pathWithin определяет включение
  3. Предварительная проверка размера: Stat на проверенном дескрипторе, файлы сверх MaxInputSize отклоняются до ReadAll
  4. Байтовый лимит: после чтения validateInput повторно проверяет количество байтов

Даже если файл находится в разрешённом каталоге, AllowedBaseDir ограничивает «какой файл можно прочитать», а MaxInputSize ограничивает «какого размера файл может быть» — эти ограничения ортогональны и не заменяют друг друга.

Предупреждение

Пустой AllowedBaseDir = песочница не активна. AllowedBaseDir по умолчанию — пустая строка, означающая отсутствие ограничения по каталогу (сохраняется только проверка обхода пути через ..). Если ваши файловые пути поступают от пользователя, обязательно явно установите этот параметр.

Пример конфигурации

go
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, гарантируя, что злонамеренный ввод не приведёт к аварийному завершению процесса вызывающего.

go
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 → ErrInternalPanicextract.go recoverPanic
Отдельный элемент пакетаНезависимый recover для каждого элемента, panic одного не влияет на другиеbatch.go
Goroutine тайм-аутаНезависимый recover в worker-goroutine withTimeoutextract.go
Запись в AuditSinkpanic самого sink подавляется (SEC-003)audit.go Record
Закрытие AuditSinkpanic sink.Close оборачивается в ErrInternalPanic и возвращается через erroraudit.go Close
Создание пула Processorpanic в sync.Pool.NewErrInternalPanicprocessor_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() — это предустановка, которая ужесточает все ограничения и включает полный аудит одним вызовом:

go
cfg := html.HighSecurityConfig()
// Автоматически устанавливает:
//   MaxInputSize      = 10 МБ (по умолчанию 50 МБ)
//   MaxDepth          = 100 (по умолчанию 500)
//   ProcessingTimeout = 10 с (по умолчанию 30 с)
//   WorkerPoolSize    = 2 (по умолчанию 4)
//   Audit             = HighSecurityAuditConfig() (включён + с исходными значениями)

Обработка ошибок

Все нарушения безопасности возвращают чёткие сигнальные ошибки, поддерживающие определение категории через errors.Is:

go
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 — чтобы избежать утечки разрешённого реального пути в журналы.

Следующие шаги