Контрольный список для продакшена
Перед выводом библиотеки html в продакшен пройдитесь по этому чек-листу пункт за пунктом. Каждый пункт помечен приоритетом, чтобы вы могли определить, что обязательно, а что можно отложить:
- 🔴 Обязательно (P0): невыполнение ведёт к уязвимостям безопасности или утечкам ресурсов
- 🟡 Рекомендуется (P1): настоятельно рекомендуется, влияет на надёжность и наблюдаемость
- 🟢 Опционально (P2): приятное дополнение
Начните с пресета, затем корректируйте под бизнес
HighSecurityConfig() уже ужесточает все лимиты до безопасных значений и включает полный аудит. Для большинства сценариев с недоверенным вводом достаточно взять его и при необходимости ослабить, не нужно собирать конфигурацию с нуля.
Базовая конфигурация
- [ ] 🔴 Использовать
HighSecurityConfig()или пользовательскую безопасную конфигурацию как отправную точку - [ ] 🔴 Установить разумный
MaxInputSize(по бизнес-требованиям, жёсткий верхний предел 50 МБ) - [ ] 🔴 Установить
ProcessingTimeoutдля предотвращения длительных блокировок (рекомендуется 10–30 с) - [ ] 🟡 Настроить
MaxDepthдля ограничения глубины DOM (по умолчанию 500, для высокой безопасности рекомендуется 100) - [ ] 🟡 Сохранять
EnableSanitization = true(по умолчанию включено, не отключайте) - [ ] 🟢 Скорректировать
WorkerPoolSizeв соответствии с уровнем параллелизма (по умолчанию 4, верхний предел 256)
Почему:
MaxInputSize— первая линия защиты памяти. Он также подчинён жёсткому ограничениюmaxConfigInputSize(50 МБ) — даже при ошибочной настройке значение не вырастет до опасного. Пиковое потребление памяти грубо оценивается какMaxInputSize × WorkerPoolSize; для общедоступных веб-API рекомендуется уменьшить до 10 МБ.WorkerPoolSizeопределяет уровень параллелизма пакетного извлечения. Обычно достаточно установить его равным числу ядер CPU; слишком большое значение (близкое к верхнему пределу 256) создаёт большое количество goroutine и нагрузку на память при высокой параллельности.CacheTTL(по умолчанию 1 ч) иCacheCleanup(по умолчанию 5 мин) управляют жизненным циклом кэша результатов и ритмом фоновой очистки.MaxCacheEntries = 0полностью отключает кэш; попадание в кэш экономит только CPU, но не память — сами кэшированные записи также занимают память (верхний предел 100 000 записей).
Жизненный цикл Processor
- [ ] 🔴 Использовать
defer p.Close()для корректного освобождения Processor - [ ] 🔴 Не вызывать методы извлечения после
Close()(вернётErrProcessorClosed) - [ ] 🟡 Использовать Processor-синглтон для повторного использования между запросами, а не создавать новый на каждый запрос
p, err := html.New(html.HighSecurityConfig())
if err != nil {
log.Fatal(err)
}
defer p.Close()Почему:
Processorпотокобезопасен — после создания он может разделяться между несколькими goroutine; внутренний кэш и счётчики статистики синхронизированы. Сделав его приложенческим синглтоном (например, HTTP handler хранит глобальный*html.Processor), вы сможете повторно использовать кэш и избежать накладных расходов на повторную инициализацию.- Не вызывайте
html.New()на каждый запрос: каждый Processor запускает фоновый goroutine очистки кэша, аллоцирует структуры кэша и аудита — частое создание и расходует ресурсы, и увеличивает давление на GC. - Функции уровня пакета (
html.Extract,html.ExtractWithContextи т. д.) внутренне используютsync.Poolдля повторного использования временных Processor, но не кэшируют результаты извлечения — межу вызовами попаданий в кэш не будет. Для сценариев с повторным использованием кэша явно удерживайте экземпляр Processor.
Аудит и мониторинг
- [ ] 🔴 Включить систему аудита (
Audit.Enabled = true) - [ ] 🟡 Настроить
WriterAuditSinkдля сохранения журнала аудита в файл - [ ] 🟡 Отслеживать
ErrorCountиCacheHitsвGetStatistics() - [ ] 🟡 Настроить оповещения в реальном времени для событий уровня critical (нарушения ввода, обход пути)
- [ ] 🟢 Использовать
ChannelAuditSinkдля направления потока аудита во внешнюю SIEM
auditFile, err := os.OpenFile("audit.jsonl", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Fatal(err)
}
defer auditFile.Close()
cfg := html.HighSecurityConfig()
cfg.Audit.Sink = html.NewWriterAuditSink(auditFile)Многоуровневый конвейер аудита — события уровня critical вызывают оповещения в реальном времени, остальные записываются в файл:
// события уровня critical отправляются в channel, отдельный goroutine отправляет оповещения
alertSink := html.NewChannelAuditSink(50)
go func() {
for entry := range alertSink.Channel() {
sendAlert(entry) // интеграция с PagerDuty / Slack / SMS
}
}()
// запись в файл + оповещения для critical — два параллельных пути без взаимной блокировки
cfg.Audit.Sink = html.NewMultiSink(
html.NewWriterAuditSink(auditFile),
html.NewFilteredSink(alertSink, func(e html.AuditEntry) bool {
return e.Level == html.AuditLevelCritical
}),
)Мониторинг отбрасывания ChannelAuditSink: при переполнении буфера channel события отбрасываются — проверяйте через DroppedCount():
if n := alertSink.DroppedCount(); n > 0 {
log.Printf("Channel оповещений отбросил %d событий аудита — увеличьте буфер или ускорьте потребление", n)
}Дополнительные паттерны построения конвейера (маршрутизация по уровням, пользовательские Sink, криминалистика высокой безопасности) см. в Система аудита на практике.
Контекст и тайм-ауты
- [ ] 🔴 Использовать версии
ExtractWithContextдля всех операций извлечения, а не «голый»Extract - [ ] 🔴 Устанавливать разумные тайм-ауты контекста
- [ ] 🟡 Для пакетных операций использовать контекст с отменой, чтобы при ошибке вовремя прервать оставшиеся задачи
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
result, err := p.ExtractWithContext(ctx, data)Почему:
- В библиотеке два уровня тайм-аутов:
Config.ProcessingTimeout(по умолчанию 30 с, действует на один документ) и передаваемый вызывающей сторонойcontext.Context. Оба действуют совместно — раньше истекает тот, что сработает первым. Даже без передачи контекста с тайм-аутомProcessingTimeoutслужит подстраховкой; если передан более короткий context, приоритет у context. - Пакетное извлечение (
ExtractBatch) должно иметь общий тайм-аут context на весь пакет, чтобы избежать неконтролируемого расхода времени на отдельную партию. Сбой отдельного элемента не должен блокировать всю партию — внутри пакетных методов для каждого элемента выполняется независимый recover. - Механизм тайм-аута реализован через отдельный goroutine + context deadline и глобально защищён
maxTimeoutGoroutines(1000). При экстремально высокой параллельности в случае превышения лимита новые запросы сразу возвращаютErrProcessingTimeout, а не накапливают goroutine бесконечно.
Обработка ошибок
- [ ] 🔴 Различать бизнес-ошибки и ошибки безопасности через
errors.Is - [ ] 🔴 Для
*FileErrorвыводить черезSafePath(), а не исходную строку ошибки - [ ] 🟡 Записывать все
ErrInputTooLargeиErrMaxDepthExceeded(могут быть признаком пробной атаки) - [ ] 🟡 Отслеживать частоту
ErrInternalPanic(при появлении следует расследовать и сообщить в issue) - [ ] 🟢 Для
ErrProcessorClosedвыполнять корректную деградацию, а не аварийное завершение
_, err := p.Extract(data)
switch {
case errors.Is(err, html.ErrInputTooLarge):
log.Printf("Превышен лимит ввода — возможна пробная атака")
case errors.Is(err, html.ErrMaxDepthExceeded):
log.Printf("Нарушение глубины — возможна рекурсивная бомба")
case errors.Is(err, html.ErrInternalPanic):
// panic уже восстановлен, но это баг библиотеки — следует сообщить
log.Printf("Внутренний panic восстановлен: %v", err)
case errors.Is(err, html.ErrFileNotFound),
errors.Is(err, html.ErrInvalidFilePath):
var fe *html.FileError
if errors.As(err, &fe) {
log.Printf("Ошибка файла: %s", fe.SafePath()) // выводится только имя файла, полный путь не раскрывается
}
}Почему:
FileError.Error()уже имеет встроенную санитизацию (показывает только имя файла, а не полный путь), но полеFileError.Pathсохраняет исходный путь. В журналах обязательно используйтеSafePath(), чтобы не допустить утечки серверной структуры каталогов в системы агрегации журналов.ErrInternalPanicв теории не должен появляться — он означает, что злоумышленный ввод спровоцировал непредвиденный panic в библиотеке. При обнаружении сохраните триггерный ввод и сообщите об этом. Восстановление panic в библиотеке гарантирует, что процесс не упадёт, но полагаться на это долгосрочно не следует.
Управление ресурсами
- [ ] 🔴 Пакетные операции — не более 10 000 элементов на партию
- [ ] 🟡 Разумно настроить
WorkerPoolSize(рекомендуется равным числу ядер CPU) - [ ] 🟡 Отслеживать использование памяти и частоту попаданий в кэш
- [ ] 🟢 Для долго работающих экземпляров периодически вызывать
ClearCache()для освобождения кэша
Почему:
- Оценка пикового потребления памяти:
MaxInputSize × WorkerPoolSize— это приблизительный пик памяти при пакетной обработке (каждый worker одновременно обрабатывает один ввод). Например,10 МБ × 8 = 80 МБ. Исходя из этого резервируйте память контейнера. - Кэш и память:
MaxCacheEntries(по умолчанию 2000) — каждая кэш-запись занимает память в соответствии с размером результата извлечения. В долго работающем сервисе при нехватке памяти можно уменьшить entries или сократитьCacheTTL; чем меньшеCacheCleanup, тем оперативнее освобождаются просроченные записи. - Стратегия разбиения на партии: слишком большая партия увеличивает и потребление памяти, и цену ошибки. Рекомендуется разбивать большие задачи на небольшие партии по 1000–5000 записей, используя для каждой независимый context с тайм-аутом — сбой одной партии не повлияет на последующие.
Обработка файлов
- [ ] 🔴 Проверять источник пути к файлу (не позволять пользователю напрямую управлять полным путём)
- [ ] 🔴 Установить
AllowedBaseDirдля ограничения каталога чтения файлов - [ ] 🟡 Перед обработкой предварительно проверять размер файла через
os.Stat(библиотека уже делает это внутри, но внешний дополнительный уровень повышает надёжность) - [ ] 🟢 Выполнять проверку загруженных файлов по белому списку типов/расширений
Почему:
AllowedBaseDir— это песочница для чтения файлов. Она разрешает реальные пути через дескрипторы файлов ОС и способна перехватить Windows junction/reparse points и кроссплатформенный уход через symlink, с которыми не справляетсяfilepath.EvalSymlinks. Пустое значение = сохраняется только проверка обхода.., песочница не включается — если путь поступает от пользователя, обязательно задавайте его явно.- Внутри библиотеки уже выполняется предварительная проверка размера файла через
Statперед загрузкой в память черезReadAllс отклонением превышающих лимит файлов — это закрывает окно пикового потребления памяти по сценарию «сначала прочитали, потом обнаружили превышение». Дополнительная внешняя проверка размера — это эшелонированная защита.
Скрипт самопроверки перед развёртыванием
Перед запуском выполните эту программу самопроверки, чтобы убедиться, что конфигурация легитимна, а Processor создаётся и извлекает данные:
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/cybergodev/html"
)
func main() {
cfg := html.HighSecurityConfig()
// 1. Проверка легитимности конфигурации (New внутри вызовет Validate)
p, err := html.New(cfg)
if err != nil {
log.Fatalf("Недопустимая конфигурация: %v", err)
}
defer p.Close()
// 2. Проверка ключевых параметров конфигурации
fmt.Printf("MaxInputSize = %d байт\n", cfg.MaxInputSize)
fmt.Printf("MaxDepth = %d\n", cfg.MaxDepth)
fmt.Printf("ProcessingTimeout = %v\n", cfg.ProcessingTimeout)
fmt.Printf("WorkerPoolSize = %d\n", cfg.WorkerPoolSize)
fmt.Printf("Audit.Enabled = %v\n", cfg.Audit.Enabled)
// 3. Практический тест извлечения
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
sample := []byte("<html><body><h1>Самопроверка</h1><p>Готов к продакшену</p></body></html>")
result, err := p.ExtractWithContext(ctx, sample)
if err != nil {
log.Fatalf("Ошибка извлечения: %v", err)
}
fmt.Printf("Извлечение успешно, длина текста: %d\n", len(result.Text))
fmt.Println("✓ Самопроверка конфигурации пройдена")
}Самопроверка подтверждает только «работоспособность»
Скрипт самопроверки удостоверяется, что конфигурация легитимна и цепочка работает, но не заменяет мониторинг при реальном трафике. После запуска необходимо продолжать наблюдение за метриками времени выполнения.
Ключевые точки мониторинга во время выполнения
После развёртывания постоянно следите за следующими метриками и своевременно оповещайте при аномалиях:
| Метрика | Способ получения | Порог оповещения | Возможная причина |
|---|---|---|---|
| Уровень ошибок | Statistics.ErrorCount / TotalProcessed | > 5% | Плохое качество ввода, слишком строгая конфигурация, аномалия на стороне источника |
| Частота попаданий в кэш | Statistics.CacheHits / TotalProcessed | < 30% | Недостаточная дедупликация ввода, слишком короткий TTL кэша |
| Среднее время обработки | Statistics.AverageProcessTime | Превышение бизнес-базовой линии | Злоумышленный ввод, неправильная настройка тайм-аутов |
| critical события аудита | GetAuditLog() фильтр AuditLevelCritical | Любое событие | Нарушение ввода, атака обхода пути |
| Количество отбрасываний ChannelAuditSink | sink.DroppedCount() | > 0 | Недостаточный буфер, слишком медленный потребитель |
// Периодический сбор метрик (например, каждую минуту через Prometheus)
stats := p.GetStatistics()
var errorRate float64
if stats.TotalProcessed > 0 {
errorRate = float64(stats.ErrorCount) / float64(stats.TotalProcessed)
}
hitRate := 0.0
if stats.TotalProcessed > 0 {
hitRate = float64(stats.CacheHits) / float64(stats.TotalProcessed)
}
log.Printf("Обработано=%d уровень_ошибок=%.2f%% попаданий_в_кэш=%.1f%% среднее_время=%v",
stats.TotalProcessed, errorRate*100, hitRate*100, stats.AverageProcessTime)
// Проверка событий уровня critical
for _, e := range p.GetAuditLog() {
if e.Level == html.AuditLevelCritical {
log.Printf("[ОПОВЕЩЕНИЕ] critical событие аудита: %s - %s", e.EventType, e.Message)
}
}Statistics — накопленное значение
Счётчики, возвращаемые GetStatistics(), накапливаются с момента создания Processor и не сбрасываются при ClearCache(). Для статистики по временным окнам периодически вызывайте ResetStatistics() или ведите дельту самостоятельно.
Матрица быстрого выбора конфигурации
Быстрый выбор значений конфигурации по среде развёртывания:
| Среда | MaxInputSize | ProcessingTimeout | MaxDepth | WorkerPoolSize | Аудит | Рекомендуемый пресет |
|---|---|---|---|---|---|---|
| Внутренние инструменты | 50 МБ (по умолчанию) | 30 с (по умолчанию) | 500 (по умолчанию) | 4 (по умолчанию) | Опционально | DefaultConfig() |
| Веб-API | 10 МБ | 10 с | 200 | Число ядер CPU | Рекомендуется | DefaultConfig() с корректировкой |
| Высокая безопасность | 10 МБ | 10 с | 100 | 2 | Обязательно | HighSecurityConfig() |
| Пакетный краулер | 50 МБ | 30 с | 500 | 8–16 | Рекомендуется | DefaultConfig() с корректировкой |
Особые соображения для сценария краулера
Пакетный краулер работает с недоверенными веб-страницами, но отдаёт приоритет пропускной способности. Рекомендуется сохранять EnableSanitization = true, увеличить WorkerPoolSize до 8–16 для повышения пропускной способности и одновременно установить для каждой задачи пакета независимый общий тайм-аут context, чтобы злонамеренная страница не «положила» всю партию.
См. также
- Обзор безопасности — архитектура эшелонированной защиты и подробное описание уровней защиты
- Система аудита на практике — 8 типов событий, сравнение встроенных Sink, конвейер маршрутизации по уровням
- Справочник API: Защита безопасности — сигнатуры API, связанные с безопасностью
- Справочник API: Константы и ошибки — константы значений по умолчанию и сигнатурные ошибки