security

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 21, 2026 License: MIT Imports: 5 Imported by: 0

README

Fluent Security

Opt-in security toolkit for the Fluent HTML framework. Sanitise untrusted HTML via bluemonday, generate per-request nonces for Content-Security-Policy - and nothing else. Fluent core stays dependency-free; you pay for security only when you need it.

Why a separate package?

Real sanitisation needs a proper HTML parser and a vetted policy engine. Trying to do that with a regex inside the core framework would have been dishonest, and bundling a sanitisation library into every Fluent project would have cost the zero-dependency property Fluent cares about.

Pulling security out into its own package gets both right:

  • Fluent core stays small and dependency-free. Projects that want the same minimalism gomponents provides out of the box get exactly that.
  • Security becomes first-class when opted in. A real HTML sanitiser, a correct nonce generator, and a clear contract about what each does.
  • Follows the ecosystem pattern. Just like fluent-jit, fluent-htmx, tether, and friends - separate package, dropped in when wanted, absent when not.

Install

go get github.com/jpl-au/fluent-security

Requires Fluent (for the node.Node interface) and bluemonday (the sanitiser). Both are resolved automatically by go mod.

Sanitising HTML

Three tiers of control, each returning a node.Node that embeds directly in a Fluent tree:

  1. One-line helpers (HTML, PlainText) - sensible defaults, no configuration. Use these for the 90% case.
  2. Chainable Cleaner (New, RichText) - sensible defaults you can extend with Allow, AllowClasses, AllowAttr. Hoist one at package scope and share it.
  3. Escape hatch (FromPolicy) - wrap any bluemonday policy directly when the chainable API does not express what you need.

You should not need to reach for tier 3 often. The chainable API covers almost every real case. But when it doesn't, the escape hatch is there so you are never stuck.

HTML(input string) node.Node

Permissive policy for rich user-generated content. Keeps the common formatting tags (<p>, <strong>, <em>, <a>, <img> with http/https schemes, lists, headings, ...) and strips scripts, event handlers, iframes, and other attack surfaces.

Use for rendered markdown, rich-text editor output, and any content where the user is expected to provide some HTML structure.

comment := div.New(
    h3.Text(user.Name),
    div.New(security.HTML(user.CommentHTML)).Class("body"),
).Class("comment")
PlainText(input string) node.Node

Strips all HTML tags, keeps only plain text. Special characters (<, >, &, ", ') are escaped into entities, so the result is safe to drop straight into a Fluent tree without a second escape pass.

Use when you want the text content of marked-up input without any markup surviving. Useful for titles, one-line summaries, and places where HTML structure would be a bug rather than a feature.

title := h1.New(security.PlainText(article.RawTitle))
New() *Cleaner

Create a new, empty cleaner. Nothing is permitted until you call Allow, AllowClasses, or AllowAttr. Use this for defining a narrow policy from scratch.

cleaner := security.New().
    Allow("b", "i", "em", "strong").
    AllowAttr("href", "a")

clean := cleaner.Clean(userInput)
RichText() *Cleaner

Create a cleaner pre-configured with the user-generated-content baseline. Chain additional Allow methods to extend it.

// Baseline plus syntax-highlight classes on <code>/<pre>.
var mdCleaner = security.RichText().
    AllowClasses("code", "pre")

// At call sites:
article := div.New(
    h1.Text(art.Title),
    div.New(mdCleaner.Clean(art.Body)).Class("body"),
).Class("article")

*Cleaner is safe for concurrent use once configured. Build it once (typically at package scope) and reuse it across handlers.

FromPolicy(policy *bluemonday.Policy) *Cleaner

Escape hatch. Wraps any bluemonday policy in a *Cleaner, so you keep the Fluent-native integration (node.Node return, concurrent-safe, can be passed as a dependency) while retaining full access to bluemonday's configuration surface.

import "github.com/microcosm-cc/bluemonday"

p := bluemonday.NewPolicy()
// ... any bluemonday configuration you need ...
cleaner := security.FromPolicy(p)

body := div.New(cleaner.Clean(input)).Class("body")

When to reach for this:

  • You need a bluemonday feature the chainable API does not expose (e.g. a URL scheme allowlist, a RequireParseableURLs toggle, custom rewrite handlers).
  • You already have a bluemonday policy you want to reuse.
  • You are migrating from a project that used bluemonday directly and want to adopt fluent-security incrementally.

When not to:

  • You just want the UGC baseline plus a few tweaks. Use RichText() and chain Allow* methods instead; it reads more clearly and keeps bluemonday an implementation detail.
  • You want to learn the API. Start with the chainable helpers - the documentation surface is smaller and the intent is obvious at each call site.

Content-Security-Policy nonces

Nonce() string

Returns a fresh 128-bit crypto-random value encoded as URL-safe base64 without padding (22 characters). Generate a new one per HTTP request and wire it into two matching places:

nonce := security.Nonce()

// 1. Put it on the inline script/style element via Fluent's method.
page := html.New(
    head.New(
        script.RawText("console.log('hello')").Nonce(nonce),
        style.RawText("body { background: #fff; }").Nonce(nonce),
    ),
    body.New( /* ... */ ),
)

// 2. Declare it in the Content-Security-Policy header.
w.Header().Set(
    "Content-Security-Policy",
    fmt.Sprintf("script-src 'self' 'nonce-%s'; style-src 'self' 'nonce-%s'",
        nonce, nonce),
)

page.Render(w)

The browser will execute only the inline blocks whose nonce attribute matches the value declared in the header. Do not reuse nonces across requests.

Nonce() uses crypto/rand. If the OS entropy source is unavailable it panics rather than returning a predictable value, because a guessable nonce silently defeats CSP. An HTTP handler cannot recover meaningfully from that condition anyway.

What this package does NOT do

  • Ad-hoc pattern scanning. An earlier iteration of Fluent shipped a regex "validator" that checked for obvious-bad substrings. It has been removed. A motivated attacker defeats any fixed pattern list via Unicode escapes, string concatenation, or HTML entities. If you want safety, use the tools above; if you want hints, a real linter will serve you better than a fake sanitiser.
  • Sanitise inline JavaScript or CSS source. Don't. Use a CSP nonce and treat the block as trusted within that scope.
  • Generate CSP headers for you. The right policy depends on your application. Nonce() produces the token; you write the header.

How it composes with Fluent

  • Text() and Textf() on Fluent elements already HTML-escape via html.EscapeString. For plain text content, you need nothing from this package.
  • RawText() and RawTextf() are unescaped by design. Anything you pass to them is rendered verbatim - sanitise first if the source is untrusted.
  • HTML / PlainText / Cleaner.Clean return node.Node values, so they plug into any position in a Fluent tree that accepts a node.

Disclaimer

This package reduces the attack surface of rendering untrusted HTML; it does not eliminate it. Sanitisation is only as complete as the underlying policy engine, and no sanitiser catches every novel technique.

You are still responsible for:

  • Applying sanitisation at the right boundary (untrusted source in, safe node out).
  • Keeping this package and its upstream dependencies patched.
  • Threat modelling and reviewing anything safety-critical yourself.

The MIT licence terms apply - the software is provided "as is", without warranty of any kind. See LICENSE.

Reporting a vulnerability

Please do not open a public issue for security reports. Use GitHub's private vulnerability reporting to disclose the issue privately.

Credits

HTML sanitisation is performed by bluemonday, the work of the bluemonday contributors. This package is a thin Fluent-native adapter over it; the parsing and policy engine are theirs.

Licence

MIT

Documentation

Overview

Package security provides the opt-in security toolkit for Fluent. It covers the two jobs a rendering framework cannot honestly tackle with a small built-in helper: sanitising untrusted HTML, and coordinating Content-Security-Policy nonces with the markup Fluent emits.

Why this lives outside Fluent

Fluent's core is pure rendering with zero external dependencies, and that is a property worth preserving. Real HTML sanitisation needs a proper parser and a vetted policy engine; attempting it with a regex inside the framework would have been dishonest. Pulling security out into its own package lets Fluent stay small while making security features first-class for the projects that want them.

What is in here

What is not in here

Ad-hoc pattern scanning. An earlier iteration of Fluent shipped a regex-based "validator"; it was removed because it promised safety it could not deliver. A motivated attacker defeats any fixed pattern list via Unicode escapes, string concatenation, or HTML entities. Sanitisation goes through a real parser; inline script and style safety goes through CSP. There is no third path.

Choosing a helper

  • HTML: one-off call for rich-text content (rendered markdown, rich-text editor output). Uses a permissive-but-safe baseline.
  • PlainText: one-off call when you want the text content without any surviving markup.
  • RichText: same baseline as HTML but returns a Cleaner you can chain more configuration onto. Use when one preset fits the whole app and you want to define it once.
  • New: empty-allowlist Cleaner to build a narrow custom policy from scratch.
  • FromPolicy: escape hatch when you already have a bluemonday policy and just want to wrap it as a Cleaner.

Under the hood

The current implementation is backed by bluemonday. That is an implementation detail: the package API talks in terms of "allow these elements" and "sanitise this HTML," not in terms of bluemonday's policy types. If a better sanitiser emerges, callers who used only the chainable API will need no changes; only users of FromPolicy would feel the swap.

Example: sanitised user content

// One-off: a comment body pasted in from a rich-text editor.
comment := div.New(
    h3.Text(user.Name),
    div.New(security.HTML(user.CommentHTML)).Class("body"),
).Class("comment")

// Reusable: hoist a cleaner with a tweaked policy at package
// scope, then share it.
var mdCleaner = security.RichText().AllowClasses("code", "pre")

body := div.New(mdCleaner.Clean(article.RenderedHTML)).Class("article-body")

Example: inline script with CSP nonce

nonce := security.Nonce()
w.Header().Set(
    "Content-Security-Policy",
    fmt.Sprintf("script-src 'self' 'nonce-%s'; style-src 'self' 'nonce-%s'",
        nonce, nonce),
)
page := html.New(
    head.New(
        script.RawText("console.log('hello')").Nonce(nonce),
    ),
    body.New( /* ... */ ),
)
page.Render(w)

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func HTML

func HTML(input string) node.Node

HTML sanitises untrusted HTML and returns a node.Node ready to embed in a Fluent tree. Uses the rich-text baseline (common formatting tags, links limited to http/https, scripts and event handlers removed). For sanitising the same class of content in more than one place, build a Cleaner with RichText and reuse it so the policy appears once in your code.

Use this for rendered markdown, rich-text editor output, comment bodies - any content where the user is expected to supply some HTML structure and you want formatting to survive.

The returned node renders the sanitised output verbatim. Fluent does not HTML-escape it again, because the sanitiser has already guaranteed the output is safe.

func Nonce

func Nonce() string

Nonce returns a fresh 128-bit random value encoded as URL-safe base64 without padding (22 characters). Generate a new nonce per HTTP request and use it in two matching places:

  1. As the `nonce` attribute on any inline <script> or <style> element you want the browser to execute - stamp it via script.Nonce(n) or style.Nonce(n) on the Fluent side.
  2. As 'nonce-<value>' under the matching directive (script-src or style-src) in the Content-Security-Policy header.

The browser will only execute inline blocks whose nonce attribute matches the value declared in the header. Nonces must never be reused across requests. Reuse does not make this nonce easier to predict (128 bits of crypto/rand remain unpredictable), but it removes the freshness property CSP relies on: once a nonce is observed on the wire or in the rendered DOM, any later injected content on a page that shares that nonce will be allowed to run.

Nonce matches the stdlib's approach to entropy failure. crypto/rand.Read is documented (Go 1.24+) to never return an error and to always fill its buffer; on entropy-source failure it panics internally rather than surfacing a recoverable error. Nonce mirrors that model: it returns a plain string, not (string, error), because no HTTP handler has a useful response to "we could not produce secure randomness" other than to stop. The explicit err check below is kept as a defensive assertion against a future loosening of that stdlib contract - if it ever fires, panicking remains the only safe response, since a predictable nonce silently defeats CSP.

func PlainText

func PlainText(input string) node.Node

PlainText strips all HTML tags from input and returns a node.Node that renders only the surviving text content. Use when markup survival would be a bug - for example, rendering a user-provided title, a one-line summary, or any display context where HTML structure is not wanted.

The surviving special characters (`<`, `>`, `&`, `"`, `'`) are already escaped to HTML entities by the sanitiser before return. The node therefore renders its content verbatim; passing it through Fluent's Text() (which would escape again) would double-encode those entities - for example `&lt;` would become `&amp;lt;`. HTML helpers in this package always use RawText internally so callers do not have to know this.

Types

type Cleaner

type Cleaner struct {
	// contains filtered or unexported fields
}

Cleaner is a configurable sanitiser. Construct one with New for an empty allowlist, RichText for the UGC-style baseline, or FromPolicy when you already have a bluemonday policy. Customise with the chainable Cleaner.Allow, Cleaner.AllowClasses, Cleaner.AllowAttr methods. Cleaner.Clean produces a sanitised node.Node for embedding in a Fluent tree.

Cleaner is safe for concurrent use once configuration is complete. Building (calling the Allow* methods) and calling Clean from multiple goroutines simultaneously is not supported - configure fully, then share.

func FromPolicy

func FromPolicy(policy *bluemonday.Policy) *Cleaner

FromPolicy wraps an existing bluemonday policy in a Cleaner. Use this when the built-in presets and chainable methods do not cover your case and you need bluemonday's full configuration surface. Most apps will not reach for this - prefer New or RichText followed by the Allow helpers, so the bluemonday dependency stays an implementation detail rather than a cross-package concern.

func New

func New() *Cleaner

New returns a Cleaner with an empty allowlist. Nothing is permitted until you call Cleaner.Allow, Cleaner.AllowClasses, or Cleaner.AllowAttr. Use this when you want to define a narrow policy from scratch.

For most apps, RichText is the better starting point - it comes pre-configured with the widely-accepted UGC tag set (paragraphs, headings, lists, links with http/https, inline code, emphasis, and similar) and you chain on what you need to add.

func RichText

func RichText() *Cleaner

RichText returns a Cleaner pre-configured with the user-generated-content baseline: the common formatting tags survive, scripts and event handlers are stripped, and links are limited to http/https schemes. Chain Cleaner.Allow / Cleaner.AllowClasses / Cleaner.AllowAttr to extend; there is currently no method to narrow the baseline - for that, start from New instead.

The underlying policy is bluemonday's UGCPolicy. Fluent-security treats that as an implementation detail: the guarantees to rely on are "common HTML formatting allowed, known attack surfaces removed," not any specific bluemonday version's exact tag list.

func (*Cleaner) Allow

func (c *Cleaner) Allow(elements ...string) *Cleaner

Allow permits the named elements. Tags not on the allowlist (whether added here or seeded by the constructor) are stripped at sanitise time. Chaining Allow repeatedly is fine - allowlist additions are idempotent.

security.New().Allow("b", "i", "em")

func (*Cleaner) AllowAttr

func (c *Cleaner) AllowAttr(attr string, elements ...string) *Cleaner

AllowAttr permits the named attribute on the named elements. Use this for attributes other than class; for class specifically prefer Cleaner.AllowClasses as it reads more naturally at the call site.

security.RichText().AllowAttr("id", "h1", "h2", "h3")

func (*Cleaner) AllowClasses

func (c *Cleaner) AllowClasses(elements ...string) *Cleaner

AllowClasses permits the class attribute on the named elements. This is the common case for preserving syntax-highlight hooks on <code>/<pre> output from markdown renderers or code-display components.

security.RichText().AllowClasses("code", "pre")

func (*Cleaner) Clean

func (c *Cleaner) Clean(input string) node.Node

Clean sanitises input and returns a node.Node that renders the safe HTML verbatim. The returned node is not HTML-escaped again on render because the sanitiser has already guaranteed its output is safe under the configured policy.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL