Skip to content

Контрольный список для продакшена

Перед выводом библиотеки 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-синглтон для повторного использования между запросами, а не создавать новый на каждый запрос
go
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
go
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 вызывают оповещения в реальном времени, остальные записываются в файл:

go
// события уровня 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():

go
if n := alertSink.DroppedCount(); n > 0 {
    log.Printf("Channel оповещений отбросил %d событий аудита — увеличьте буфер или ускорьте потребление", n)
}

Дополнительные паттерны построения конвейера (маршрутизация по уровням, пользовательские Sink, криминалистика высокой безопасности) см. в Система аудита на практике.

Контекст и тайм-ауты

  • [ ] 🔴 Использовать версии ExtractWithContext для всех операций извлечения, а не «голый» Extract
  • [ ] 🔴 Устанавливать разумные тайм-ауты контекста
  • [ ] 🟡 Для пакетных операций использовать контекст с отменой, чтобы при ошибке вовремя прервать оставшиеся задачи
go
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 выполнять корректную деградацию, а не аварийное завершение
go
_, 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 создаётся и извлекает данные:

go
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Любое событиеНарушение ввода, атака обхода пути
Количество отбрасываний ChannelAuditSinksink.DroppedCount()> 0Недостаточный буфер, слишком медленный потребитель
go
// Периодический сбор метрик (например, каждую минуту через 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() или ведите дельту самостоятельно.

Матрица быстрого выбора конфигурации

Быстрый выбор значений конфигурации по среде развёртывания:

СредаMaxInputSizeProcessingTimeoutMaxDepthWorkerPoolSizeАудитРекомендуемый пресет
Внутренние инструменты50 МБ (по умолчанию)30 с (по умолчанию)500 (по умолчанию)4 (по умолчанию)ОпциональноDefaultConfig()
Веб-API10 МБ10 с200Число ядер CPUРекомендуетсяDefaultConfig() с корректировкой
Высокая безопасность10 МБ10 с1002ОбязательноHighSecurityConfig()
Пакетный краулер50 МБ30 с5008–16РекомендуетсяDefaultConfig() с корректировкой

Особые соображения для сценария краулера

Пакетный краулер работает с недоверенными веб-страницами, но отдаёт приоритет пропускной способности. Рекомендуется сохранять EnableSanitization = true, увеличить WorkerPoolSize до 8–16 для повышения пропускной способности и одновременно установить для каждой задачи пакета независимый общий тайм-аут context, чтобы злонамеренная страница не «положила» всю партию.

См. также