Skip to content

Security Overview

The HTML library processes untrusted input from the internet, so security is the first priority by design. The library has multiple independent layers of protection built in, following the Defense in Depth philosophy: each layer assumes other layers may fail, and a single layer being bypassed does not lead to total compromise.

This page is an overview of security features. If you want to jump straight to configuring the audit pipeline or reviewing the production checklist, go to Next Steps at the bottom.

Defense in Depth Architecture

The library's security protections are distributed across three independent layers, each with its own failure modes and recovery strategies:

text
Input Layer Protection (reject before processing)
├── MaxInputSize        Byte-level size limit (default 50MB)
├── MaxDepth            DOM nesting depth limit (default 500, prevents stack overflow)
├── ProcessingTimeout   Single-document processing timeout (default 30s)
├── Path traversal detection  .. components in file paths
└── AllowedBaseDir      File read sandbox (OS handle resolution, prevents symlink/junction)

Processing Layer Protection (sanitization and cancellation)
├── HTML sanitization        Multi-dimensional filtering of tags / attributes / URLs / CSS
├── Cooperative context cancellation  ExtractWithContext responds to ctx.Done()
├── Panic recovery           recoverPanic generic wrapper, returns ErrInternalPanic
└── Goroutine leak protection  maxTimeoutGoroutines (limit 1000)

Audit Layer Protection (observability + isolation)
├── AuditSink panic isolation SEC-003 (audit subsystem is best-effort)
├── 8 audit event types      blocked_tag/blocked_attr/blocked_url/...
└── Raw value HTML escaping   Prevents audit logs from triggering XSS in SIEM dashboards

Why "independent" matters

The value of defense in depth lies in the assumption of no coupling between layers. For example, even if the sanitization layer misses a malicious URL (processing layer failure), the input layer's MaxInputSize still blocks oversized payloads; even if an AuditSink itself panics (audit layer failure), recoverPanic still ensures the main flow does not crash.

Input Boundary Protection

Input Size Limit

Default maximum input is 50MB (DefaultMaxInputSize), preventing memory exhaustion attacks. The configuration upper bound is also constrained by maxConfigInputSize (also 50MB), meaning it cannot be raised to unsafe values through configuration:

go
cfg := html.DefaultConfig()
cfg.MaxInputSize = 10 * 1024 * 1024 // Tighten to 10MB

File paths have an additional pre-check: after Stat retrieves the size, oversized files are rejected before ReadAll loads the content into memory, closing the memory peak window of "finish reading before discovering it's oversized."

DOM Depth Limit

Default maximum depth is 500 (DefaultMaxDepth), preventing stack overflow bombs from recursively nested constructs:

go
cfg.MaxDepth = 200 // Stricter

Depth validation uses iterative traversal rather than recursion, so it does not overflow its own stack from deeply nested input.

Processing Timeout

Configurable processing timeout prevents malicious HTML from triggering exponential processing time:

go
cfg.ProcessingTimeout = 10 * time.Second

The timeout mechanism is implemented via an independent goroutine + context deadline, protected by the maxTimeoutGoroutines (1000) limit to prevent goroutine runaway under high concurrency. Combined with ExtractWithContext, the caller's cancellation signal can be layered on top.

Content Sanitization in Detail

Sanitization is controlled by EnableSanitization (default true). It modifies the parsed DOM tree in place to remove potentially malicious content. The entire flow is implemented in internal/sanitize.go, and every interception is written to the audit log.

When to turn off sanitization

Only consider EnableSanitization = false when the input is fully trusted internal data. It should always remain enabled when processing any HTML from user uploads, web scraping, or third-party APIs.

Removed Tags

The following tags are removed along with their entire subtrees:

TagReason for removal
<script>, <style>, <noscript>Script and style containers, can execute code or hide payloads
<iframe>, <embed>, <object>External content embedding, classic XSS and phishing vectors
<input>, <button>Form controls, can be used for CSRF or UI spoofing
<svg>Can embed JavaScript and event handlers
<math>MathML, can be abused to execute scripts in some browsers

Why <form> is not removed

The <form> tag is intentionally preserved. Server-side frameworks like ASP.NET WebForms, JSF, and JSP wrap the entire <body> inside a single <form>. Removing <form> would discard all visible page content. Text extraction scenarios neither render nor submit forms, so the CSRF/UI spoofing rationale that applies to <input>/<button> does not apply to the <form> container itself.

Removed Attributes

Attribute categoryDetection methodExamples
Event handlersPrefix match on*onclick, onerror, onmouseover
Form action overrideExact matchformaction (can hijack form submission target)
AutofocusExact matchautofocus (can be used for phishing click induction)

Event handler detection uses prefix matching (attribute names starting with on), so it covers all current and future on* variants rather than relying on a fixed blocklist.

CSS Dangerous Patterns

The value of style attributes is checked for the following dangerous substrings. If any is found, the entire style is stripped (retaining safe CSS properties for metadata extraction requires separate evaluation; the current strategy is to discard entirely once any danger is detected):

  • expression( — legacy IE dynamic expressions
  • behavior: — IE behavior binding
  • -moz-binding: — legacy Firefox XBL binding
  • javascript: — protocol injection
  • vbscript: — protocol injection

Inspected URI Attributes

The values of the following attributes enter the URL Security Protection pipeline for protocol and data URL validation:

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

Already-blocked attributes are not re-validated

formaction is already blocked entirely in "Removed Attributes" and does not enter the URI validation pipeline — avoiding redundant checks on the same attribute.

URL Security Protection in Depth

URI attribute values are validated through a multi-layer pipeline in isSafeURIWithAudit. Each layer addresses a known browser parsing behavior or attack bypass technique — none can be omitted.

Multi-Layer Validation Pipeline

text
Raw URI

  ├─ 1. NFC normalization       Normalize fullwidth/composing characters
  ├─ 2. TrimSpace               Remove leading/trailing whitespace
  ├─ 3. Strip C0 controls+space  Remove leading/trailing U+0000–U+001F and spaces
  ├─ 4. Strip tab/LF/CR         Remove \t \n \r within the URL
  ├─ 5. ToLower                 Normalize case
  ├─ 6. Length limit            Non-data URLs constrained by MaxURLLength(2000)
  ├─ 7. Dangerous scheme detection  javascript: / vbscript: / file: + fullwidth variants
  ├─ 8. Protocol-relative URL detection  //javascript: etc.
  └─ 9. data URL whitelist      Images/fonts/PDF only, block svg and empty MIME

Unicode Normalization (NFC)

The first step applies NFC normalization to the URI (normalizeURIForSecurity), preventing the use of Unicode variants to disguise dangerous protocols:

  • Fullwidth characters javascript: are mapped back to ASCII
  • Composing characters and cross-script similar characters are normalized to a single representation

On top of this, isDangerousScheme also performs a dedicated ASCII folding for fullwidth Latin characters (U+FF01–U+FF5E) via normalizeFullwidthToASCII — double insurance. Even if certain browsers/parsers treat fullwidth forms as ASCII, the library can still identify them.

Whitespace and Control Character Stripping

Browsers strip certain control characters when parsing URLs (following the WHATWG URL standard). The library must simulate the same stripping before protocol detection; otherwise attackers can use these characters to break up dangerous protocol names and bypass detection:

  • tab / LF / CR: java\tscript: would be reassembled by browsers into javascript: and executed. The library uses stripURLWhitespace to remove these three bytes before protocol detection.
  • C0 control characters (U+0000–U+001F) + ASCII space: browsers strip these from the beginning and end of the string before scheme parsing. strings.TrimSpace only covers Unicode whitespace, not most C0 control characters, so the library uses a dedicated c0ControlOrSpace set to explicitly strip them. Otherwise \x01javascript:… could fool all HasPrefix checks.

Dangerous Protocol Detection

ProtocolBlocking method
javascript:Direct match + fullwidth character normalization
vbscript:Same as above
file:Same as above (local filesystem access)
//javascript:, //vbscript:, //data:, //file:Protocol-relative forms detected separately

Detection of protocol-relative forms (starting with //) first strips leading whitespace after //, then applies the same dangerous protocol determination, ensuring that variants like // javascript: cannot bypass detection.

data URL Whitelist

data URLs only allow the following explicitly declared MIME types:

CategoryAllowed MIME types
Imagesimage/gif, image/jpeg, image/jpg, image/png, image/webp, image/bmp, image/x-icon, image/vnd.microsoft.icon, image/avif, image/apng
Fontsfont/woff, font/woff2, font/ttf, font/otf, application/font-woff, application/font-woff2
Documentsapplication/pdf

Explicitly blocked:

  • image/svg+xml: SVG can embed JavaScript, serving as a defense-in-depth patch after the tag itself is removed.
  • Empty media types: e.g., data:;base64,<payload> or data:;,.... These forms could previously bypass the whitelist; they are now rejected outright.
  • Overly long data URLs: constrained by MaxDataURILength (100KB), preventing base64 content from exhausting memory.
  • Illegal base64 characters: the base64 portion is validated byte-by-byte for character set legality.

Audit log truncates data URLs

data URLs may contain large base64 segments. Writing them in full to audit logs wastes space and may leak embedded sensitive content. Audit records truncate data URLs to 256 characters via truncateAuditURL.

Paths That Bypass the Sanitizer

A few code paths (such as ExtractAllLinks, and video/audio scanning in raw HTML) read unsanitized HTML. These paths are guarded by containsDangerousScheme — it reuses the exact same normalization pipeline as the sanitizer (NFC, trim, C0 stripping, tab/LF/CR stripping, fullwidth folding), ensuring that both paths enforce the same protocol policy. There is no inconsistency where the sanitizer blocks something but this path allows it.

For example, a payload like javascript:alert(1).mp4 disguised as a media URL could previously pass simple validation because the first character j is a letter. It is now caught by containsDangerousScheme.

AllowedBaseDir Sandbox Mechanism

When processing file paths from untrusted sources via ExtractFromFile, AllowedBaseDir limits the readable range to a specified directory. This mechanism is implemented by readContained / realPath / pathWithin in processor.go.

Why OS File Handle Resolution Is Needed

Plain filepath.EvalSymlinks cannot resolve Windows directory junctions and reparse points — both of which can be created without any privileges and are the primary means of bypassing path restrictions on Windows. The library's approach:

  1. os.Open() opens the target file, obtaining an OS file handle
  2. realPath(f) resolves the real on-disk path from the already-open handle
  3. pathWithin(realBase, realTarget) determines whether the real path falls within the allowed directory
  4. Reads content via io.ReadAll from the same verified handle

Performing both validation and reading from the same handle closes the TOCTOU (check-time-to-use-time) race window — a path substitution via symlink after validation but before reading cannot affect the result, because the read uses the same inode that was validated.

Cross-Platform Real Path Resolution

PlatformResolution methodCovered redirect types
LinuxRead the link at /proc/self/fd/<fd>Symlinks (race-free)
macOS / BSDRead the link at /dev/fd/<fd>Symlinks (race-free)
Other UnixFallback to filepath.EvalSymlinksSymlinks (slight residual TOCTOU)
WindowsGetFinalPathNameByHandleWSymlinks + junctions + all reparse points

After resolution on Windows, the \\?\ extended-length prefix is stripped and the path is Cleaned to match the output format of filepath.Abs, ensuring accurate subsequent containment comparison.

Protection Layers

File reading in AllowedBaseDir mode stacks four independent checks:

  1. Path traversal detection: checks for .. components after filepath.Clean
  2. OS handle sandbox: realPath resolves the real path, pathWithin determines containment
  3. Size pre-check: Stat on the verified handle checks the size; rejects files exceeding MaxInputSize before ReadAll
  4. Byte-level limit: after reading, validateInput re-checks the byte count

Even if a file is within the allowed directory, AllowedBaseDir constrains "which file can be read," and MaxInputSize constrains "how large the file can be." The two are orthogonal and do not substitute for each other.

Empty AllowedBaseDir = sandbox not enabled

AllowedBaseDir defaults to an empty string, meaning no directory restriction (only the .. traversal check remains). As long as your file paths come from user input, you should explicitly set this.

Configuration Example

go
cfg := html.DefaultConfig()
// Only allow reading files under /var/app/uploads and its subdirectories
cfg.AllowedBaseDir = "/var/app/uploads"

p, err := html.New(cfg)
if err != nil {
    log.Fatal(err)
}
defer p.Close()

// OK: file is within the allowed directory
result, err := p.ExtractFromFile("/var/app/uploads/page.html")

// Rejected: points outside via symlink/junction
_, err = p.ExtractFromFile("/var/app/uploads/escape.txt") // If it's a junction → /etc

Rejected out-of-bounds access logs an AuditEventPathTraversal audit event and returns a *FileError wrapping "path outside allowed directory." The error does not include the resolved real path (to avoid leaking the filesystem layout).

Panic Recovery and Isolation

All public extraction methods are wrapped by the recoverPanic[T] generic function. Panics are caught and converted into ErrInternalPanic errors, ensuring that malicious input does not crash the caller's process.

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()
}

Multi-Layer Isolation Boundaries

Isolation boundaryBehaviorLocation
Single extractionpanic → ErrInternalPanicextract.go recoverPanic
Batch single itemEach item independently recovered; one item's panic does not affect othersbatch.go
Timeout goroutineThe worker goroutine in withTimeout is independently recoveredextract.go
AuditSink writeSink's own panic is swallowed (SEC-003)audit.go Record
AuditSink closesink.Close panic is converted to ErrInternalPanic and returned via erroraudit.go Close
Pooled Processor creationsync.Pool.New panic → ErrInternalPanicprocessor_pool.go

SEC-003: Audit Subsystem Is Best-Effort

The audit subsystem must never propagate panics to public API callers. User-provided AuditSink implementations (which may be hand-written, may wrap filters/multiplexers) may panic on any path in their Write(). Record uses defer recover() to swallow such panics (the recovered value is discarded — the audit path itself has no safe reporting channel); Close, because it has an error return value, wraps the recovered value in ErrInternalPanic and returns it.

This means that even if your custom Sink has a bug, the idiomatic defer processor.Close() will not crash the process.

Goroutine Leak Protection

Each withTimeout call spawns a worker goroutine waiting for the deadline. To prevent goroutine runaway under high concurrency, the global counter activeTimeoutGoroutines limits the concurrency to maxTimeoutGoroutines (1000). When the limit is exceeded, new requests return ErrProcessingTimeout immediately instead of accumulating goroutines indefinitely (estimating ~1MB stack per goroutine, 1000 ≈ 1GB upper bound).

Audit Raw Value Log Injection Protection

When AuditConfig.IncludeRawValues = true, audit entries include the raw values of intercepted attributes/URLs. These values are HTML-escaped (& < > " ') via sanitizeRawValue to prevent stored XSS when audit logs are rendered in browsers or SIEM dashboards.

Audit System

Security events are recorded through the audit system, supporting 8 event types, multiple built-in sinks, and level filtering. For full configuration, see Audit System in Practice.

EventDescription
AuditEventBlockedTagBlocked HTML tag
AuditEventBlockedAttrBlocked attribute
AuditEventBlockedURLBlocked URL
AuditEventInputViolationInput size violation
AuditEventDepthViolationDOM depth violation
AuditEventPathTraversalPath traversal attempt (including AllowedBaseDir out-of-bounds)
AuditEventTimeoutProcessing timeout
AuditEventEncodingIssueEncoding anomaly

Security Configuration Decision Table

ScenarioRecommended configNotes
Fully trusted internal dataDefaultConfig() + optional EnableSanitization = falsePerformance priority; only disable sanitization when confirmed no external input
User-uploaded HTMLHighSecurityConfig()Full protection: tightened limits + complete auditing
Processing external web pagesDefaultConfig()Default sanitization already covers common threats
Processing user-supplied file pathsSet AllowedBaseDirEnable OS handle sandbox to prevent symlink/junction escape
High-throughput crawlerReduce MaxInputSize + tighten ProcessingTimeoutPrevent malicious pages from stalling workers

High-Security Configuration

HighSecurityConfig() is a preset that tightens all limits and enables full auditing in one step:

go
cfg := html.HighSecurityConfig()
// Automatically sets:
//   MaxInputSize      = 10MB (default 50MB)
//   MaxDepth          = 100 (default 500)
//   ProcessingTimeout = 10s (default 30s)
//   WorkerPoolSize    = 2 (default 4)
//   Audit             = HighSecurityAuditConfig() (enabled + includes raw values)

Error Handling

All security violations return clear sentinel errors supporting errors.Is for category determination:

go
package main

import (
	"errors"
	"fmt"

	"github.com/cybergodev/html"
)

func main() {
	data := []byte(`<html><body>Maliciously crafted ultra-deep nesting</body></html>`)
	_, err := html.Extract(data)
	if err != nil {
		switch {
		case errors.Is(err, html.ErrInputTooLarge):
			// Input exceeds limit, log and reject
			fmt.Println("Input too large")
		case errors.Is(err, html.ErrMaxDepthExceeded):
			// Possibly a recursive bomb
			fmt.Println("Depth violation")
		case errors.Is(err, html.ErrInternalPanic):
			// Panic recovered, investigate input and report
			fmt.Println("Internal panic recovered")
		}
	}
	// Output: Depth violation (example, actual result depends on input)
}

Use SafePath for file errors

For *FileError, use SafePath() to get a sanitized path string instead of printing the raw error — to avoid leaking the resolved real path into logs.

Next Steps