Documentation
¶
Overview ¶
Package runesafe classifies runes that are unsafe in untrusted text bound for logs, JSON, or rendered output, and provides shared sanitizers that neutralize them.
Untrusted upstream text — API response fields, upstream error messages, file names, titles — eventually reaches a sink that renders it: a slog line read in a terminal, a JSON report opened in a viewer, a Markdown table. Four classes of rune survive the trip into those sinks with their control semantics intact and let the upstream author forge or garble what the operator sees:
- C0 controls (U+0000-U+001F) and DEL (U+007F): terminal escape sequences (ESC introduces CSI/OSC sequences that can retitle the terminal, clear the screen, or write to the clipboard) and log-record forgery (a raw newline splits one record into two, letting upstream text fabricate a whole log line). JSON encoders escape C0, so CR and LF may be kept for sinks whose encoder provably escapes them (the keepCRLF policy switch).
- C1 controls (U+0080-U+009F): single-rune escape introducers (CSI U+009B, OSC U+009D, ...) with the same terminal powers as ESC sequences. encoding/json and slog's JSONHandler emit them raw, so escaping C0 alone does not close the terminal-injection hole.
- Unicode Bidi_Control format characters (U+061C, U+200E/U+200F, U+202A-U+202E, U+2066-U+2069): visually reorder rendered text (Trojan-Source-style spoofing — a link or verdict reads differently than it compares).
- The line and paragraph separators U+2028/U+2029: legal unescaped in JSON but line terminators to JavaScript and many viewers, so they split records like a raw newline.
The Untrusted string type makes the policy travel with the value: tag an upstream field at its decode struct and every standard sink applies Sanitize automatically — slog via LogValuer, fmt and error construction via Stringer, encoders via TextMarshaler — while Raw preserves the exact bytes for matching, dedupe keys, and composed escapers. Machine-read persistence must store Raw (encoding fires the sanitizer), and construction-time sanitization remains the boundary for text that must be safe unconditionally through every future sink.
IsUnsafe classifies one rune under an explicit CR/LF policy, IsUnsafeNonASCII exposes the above-ASCII subset (C1, bidi controls, the separators) for escapers whose sink already covers ASCII, and IsBidiControl exposes the Bidi_Control subset. Sanitize (CR/LF kept, for JSON-encoded sinks) and SanitizeSingleLine (CR/LF replaced too, for single-line sinks) apply the two policies to whole strings, replacing each unsafe rune with a space. CapBytes truncates on a rune boundary, so a byte cap applied after sanitizing cannot re-introduce a partial-rune C1 tail, and SanitizeSingleLineBounded packages the log-bound composition — SanitizeSingleLine, then CapBytes on the sanitized form, then a "..." marker outside the cap — for upstream-controlled values headed into capped log attributes. SanitizeCapped and SanitizeSingleLineCapped are the general form of that composition, one per CR/LF policy, for the callers the preset cannot serve: the marker is caller-supplied and charged against the cap, so the returned text is a hard total bound (what a persisted record's write limit or a vendor payload budget needs), and the truncation fact comes back as a second result instead of having to be inferred from the marker. Neither serves a caller that must cap BEFORE sanitizing to bound the sanitizer's work, nor one that keeps a value's tail behind a prefixed marker. Apply the sanitizer at the emit boundary (the slog call site, just before JSON encoding) so comparisons and dedupe keys keep operating on the raw value, and use one policy per application so two sinks cannot drift.
The package is one deliberately small policy. It is not an HTML/XSS sanitizer, does not normalize Unicode (NFC/NFKC), and does not touch zero-width or confusable runes; context-aware sinks (Markdown cells, URLs, HTML) need their own escaping on top of this rune policy.
Index ¶
- func CapBytes(s string, n int) string
- func IsBidiControl(r rune) bool
- func IsUnsafe(r rune, keepCRLF bool) bool
- func IsUnsafeNonASCII(r rune) bool
- func Sanitize(s string) string
- func SanitizeCapped(s string, n int, marker string) (text string, cut bool)
- func SanitizeSingleLine(s string) string
- func SanitizeSingleLineBounded(s string, n int) string
- func SanitizeSingleLineCapped(s string, n int, marker string) (text string, cut bool)
- type Untrusted
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func CapBytes ¶
CapBytes truncates s to at most n bytes without splitting a multi-byte rune at the cut: the cut backs off to the nearest rune start, so the result never ends in a partial rune. It exists because sanitizing can grow a string (each invalid UTF-8 byte becomes the three-byte U+FFFD), so a pre-sanitize byte cap does not survive sanitization, and a naive re-cap can split a rune — leaving a partial-rune tail whose raw 0x80-0x9F bytes a non-UTF-8 terminal reads as C1 escape introducers, re-minting the very class of byte the sanitizer removed. For valid UTF-8 input (any Sanitize or SanitizeSingleLine output) the backoff discards at most three bytes below n and the result is a valid-UTF-8 prefix of s. A non-positive n returns the empty string.
Example ¶
ExampleCapBytes bounds a sanitized string without splitting a multi-byte rune: the cut backs off to a rune boundary instead of leaving a partial rune's raw tail bytes.
package main
import (
"fmt"
"github.com/cplieger/runesafe"
)
func main() {
s := "葬送のフリーレン" // three bytes per rune
fmt.Printf("%q\n", runesafe.CapBytes(s, 7))
}
Output: "葬送"
func IsBidiControl ¶
IsBidiControl reports whether r is one of Unicode's Bidi_Control format characters: the singleton marks U+061C (ALM) and U+200E/U+200F (LRM/RLM), the override/embedding range U+202A-U+202E (LRE/RLE/PDF/LRO/RLO), and the isolate range U+2066-U+2069 (LRI/RLI/FSI/PDI). Any of them in untrusted text can visually reorder rendered output (Trojan-Source-style report and link spoofing), so every output sanitizer treats the full set as unsafe. The set matches unicode.Bidi_Control exactly, without the table lookup.
Example ¶
ExampleIsBidiControl classifies a right-to-left override against a plain letter.
package main
import (
"fmt"
"github.com/cplieger/runesafe"
)
func main() {
fmt.Println(runesafe.IsBidiControl('\u202e'), runesafe.IsBidiControl('a'))
}
Output: true false
func IsUnsafe ¶
IsUnsafe reports whether r is unsafe in untrusted text bound for a log, JSON, or rendered-output sink: a C0 control, DEL, a C1 control (U+0080-U+009F, single-rune terminal-escape introducers), a Unicode bidi control (IsBidiControl), or the U+2028/U+2029 line separators. keepCRLF selects the CR/LF policy: true treats CR and LF as safe, for sinks whose encoder escapes them (JSON); false treats them as unsafe like every other C0 control, for single-line sinks where a raw newline forges a new record. Sanitize and SanitizeSingleLine apply the two policies to whole strings.
Example ¶
ExampleIsUnsafe shows the CR/LF policy switch: a newline is safe for a JSON sink whose encoder escapes it, and unsafe for a single-line sink.
package main
import (
"fmt"
"github.com/cplieger/runesafe"
)
func main() {
fmt.Println(runesafe.IsUnsafe('\n', true), runesafe.IsUnsafe('\n', false))
fmt.Println(runesafe.IsUnsafe('\u009b', true), runesafe.IsUnsafe('\u009b', false))
}
Output: false true true true
func IsUnsafeNonASCII ¶ added in v1.1.0
IsUnsafeNonASCII reports whether r is an unsafe rune above the ASCII range: a C1 control (U+0080-U+009F), a Unicode bidi control (IsBidiControl), or the U+2028/U+2029 line separators. It is the IsUnsafe policy minus C0, DEL, and the CR/LF axis (all ASCII), for composed escapers whose sink already covers ASCII: a URL percent-encoder escapes C0, DEL, and whitespace itself, but url.Parse accepts these non-ASCII runes raw, and a terminal or Markdown viewer must never receive them. The CR/LF policy switch is moot above ASCII, so there is no keepCRLF parameter: IsUnsafeNonASCII(r) equals IsUnsafe(r, keepCRLF) && r > unicode.MaxASCII under either policy.
Example ¶
ExampleIsUnsafeNonASCII classifies the runes a URL percent-escaper must encode even though url.Parse accepts them raw: the C1 escape introducer and the bidi override are flagged, while an ASCII control (the sink's own escaping covers it) and a plain letter are not.
package main
import (
"fmt"
"github.com/cplieger/runesafe"
)
func main() {
fmt.Println(runesafe.IsUnsafeNonASCII('\u009b'), runesafe.IsUnsafeNonASCII('\u202e'))
fmt.Println(runesafe.IsUnsafeNonASCII('\x1b'), runesafe.IsUnsafeNonASCII('é'))
}
Output: true true false false
func Sanitize ¶
Sanitize makes an untrusted string safe for slog/JSON sinks by replacing each unsafe rune with a space: C0 controls (except CR/LF, which JSON encoders escape), DEL, C1 controls (U+0080-U+009F, single-rune terminal-escape introducers emitted raw by encoding/json and slog's JSONHandler), Unicode bidi controls, and the U+2028/U+2029 line separators. Invalid UTF-8 bytes become U+FFFD (a strings.Map property), so the result is always valid UTF-8. Apply it to every untrusted attribute at the emit boundary — one policy shared by all of an app's sinks, so they cannot drift. For a single-line sink where CR/LF must also go, use SanitizeSingleLine.
Example ¶
ExampleSanitize sanitizes an upstream-controlled title before it becomes a slog attribute: the terminal-escape introducer and the bidi override become spaces, ordinary text passes through.
package main
import (
"fmt"
"github.com/cplieger/runesafe"
)
func main() {
title := "Frieren\x1b[2J \u202egpj.exe"
fmt.Printf("%q\n", runesafe.Sanitize(title))
}
Output: "Frieren [2J gpj.exe"
func SanitizeCapped ¶ added in v1.3.0
SanitizeCapped is Sanitize followed by a byte cap that INCLUDES the marker. It sanitizes s under the keepCRLF=true policy (CR and LF survive, for a sink whose encoder escapes them — JSON, slog's handlers), and if the sanitized form exceeds n bytes it cuts that form on a rune boundary at n-len(marker) bytes and appends marker, so the returned text is at most max(n, 0) bytes whatever marker is. A within-cap value comes back untouched — byte-identical to Sanitize, no marker.
cut reports whether the sanitized form was SHORTENED, not whether sanitizing rewrote anything: it is true exactly when that form did not fit in n bytes, and false whenever the returned text is the whole sanitized form. It exists because a marker cannot prove a cut — a value may end in the marker on its own — so a caller that must report truncation as a fact (a log attribute beside the value, a decision to keep a fuller copy elsewhere) would otherwise re-implement this composition just to set its own flag.
n bounds the TOTAL, which is what a caller with a real budget needs: a record persisted under a write limit, a payload assembled under a vendor byte cap, a fixed-width column. SanitizeSingleLineBounded puts its marker OUTSIDE the cap (a truncated result runs to n+3 bytes), which leaves such a caller subtracting the marker's width by hand at every call site.
A marker longer than n cannot be shown intact, and a partial marker is indistinguishable from content, so for n < len(marker) the result is the rune-boundary prefix of the sanitized form alone — the empty string for a non-positive n — and the elision is reported through cut only. An empty marker is legal and yields a silent cap (CapBytes over the sanitized form) with the fact still returned. An empty s returns "" and false under any n, negative included. marker is emitted verbatim, never sanitized: it is the caller's own program text, and rewriting it would silently alter a chosen mark — a marker assembled from untrusted input must be sanitized by the caller first.
The CR/LF policy is a second function rather than a parameter on this one, deliberately: the package already expresses that choice as the named Sanitize / SanitizeSingleLine pair, so a call site names its sink instead of passing an opaque boolean next to two other tuning arguments. Only IsUnsafe takes the flag, because a composed policy needs it as data. SanitizeSingleLineCapped is the strict twin.
Two consumer shapes this pair deliberately does NOT serve, and must not be widened to serve:
- Capping BEFORE sanitizing, to bound the sanitizer's WORK rather than its output. A caller that must never walk a multi-megabyte upstream value in a memory-limited process caps the raw bytes first (CapBytes, then Sanitize on the chunk); this function sanitizes all of s by construction, and no marker placement changes that. Such a caller also tends to aggregate one truncation fact across several appends, which a single-call primitive cannot express.
- Keeping the TAIL behind a PREFIXED marker. When the identifying part of a value sits at its end (a path's file name), the caller wants marker + suffix; this function keeps the head. Compose the rune-boundary walk locally for that.
Example ¶
ExampleSanitizeCapped bounds a multi-line upstream body for a JSON sink: CR/LF survive the sanitizer, the caller's own marker is charged against the cap so the result never exceeds it, and cut carries the truncation fact for the caller to record.
package main
import (
"fmt"
"github.com/cplieger/runesafe"
)
func main() {
body, cut := runesafe.SanitizeCapped("line one\nline two\x1b[2J", 16, "...(truncated)")
fmt.Printf("%q %v %d\n", body, cut, len(body))
body, cut = runesafe.SanitizeCapped("short\nbody", 16, "...(truncated)")
fmt.Printf("%q %v\n", body, cut)
}
Output: "li...(truncated)" true 16 "short\nbody" false
func SanitizeSingleLine ¶
SanitizeSingleLine makes an untrusted string safe for a single-line sink — a plain-text log line, a one-line error message, a rendered table cell — by replacing each unsafe rune with a space under the strict keepCRLF=false policy: everything Sanitize replaces, plus CR and LF, whose raw appearance in a single-line sink forges a record boundary. Invalid UTF-8 bytes become U+FFFD, so the result is always valid UTF-8 and carries no line break.
Example ¶
ExampleSanitizeSingleLine flattens an upstream error message for a one-line sink: the newline that would forge a second log record becomes a space along with the escape introducer.
package main
import (
"fmt"
"github.com/cplieger/runesafe"
)
func main() {
msg := "bad request\nlevel=ERROR forged\x1b[2J"
fmt.Printf("%q\n", runesafe.SanitizeSingleLine(msg))
}
Output: "bad request level=ERROR forged [2J"
func SanitizeSingleLineBounded ¶ added in v1.2.0
SanitizeSingleLineBounded is SanitizeSingleLine followed by a byte cap: an over-cap result is truncated to at most n bytes on a rune boundary (CapBytes) with "..." appended to mark the cut, while a result within the cap is returned untouched — no marker, byte-identical to the unbounded form. It is the log-bound preset for VOLUME as well as rune safety: an upstream-controlled value headed for a log attribute (a query parameter, a JSON key name, an upstream error body) must not balloon a record past downstream log-pipeline limits, and every consumer hand-rolled exactly this sanitize-cap-mark composition. The cap applies to the SANITIZED form (sanitizing can grow invalid bytes into the three-byte U+FFFD, so a pre-sanitize cap does not survive), and the marker sits outside the cap: a truncated result is at most n+3 bytes and always ends in the marker. The converse does not hold — a within-cap input may itself end in "..." — so the marker marks the cut without proving one; a caller that must know whether truncation occurred, supply its own marker, or hold the total (marker included) under a hard budget uses SanitizeSingleLineCapped, which returns the fact and counts the marker inside the cap. A non-positive n yields "..." alone for a non-empty input ("" stays "", whatever n is). The marker's placement outside the cap is this preset's settled contract, not an accident of its implementation: it stays as it is so every existing call site keeps its byte-for-byte output.
Example ¶
ExampleSanitizeSingleLineBounded bounds an upstream error message for a capped log attribute: sanitization happens before the cap is measured, a within-cap result comes back untouched, and an over-cap result is cut on a rune boundary with the "..." marker appended outside the cap (at most n+3 bytes).
package main
import (
"fmt"
"github.com/cplieger/runesafe"
)
func main() {
fmt.Printf("%q\n", runesafe.SanitizeSingleLineBounded("bad\nrequest", 20))
fmt.Printf("%q\n", runesafe.SanitizeSingleLineBounded("葬送のフリーレン", 7))
}
Output: "bad request" "葬送..."
func SanitizeSingleLineCapped ¶ added in v1.3.0
SanitizeSingleLineCapped is SanitizeCapped under the strict keepCRLF=false policy: CR and LF become spaces along with every other unsafe rune, for a single-line sink where a raw newline forges a record boundary — a plain-text log line, a one-line error message, a rendered table cell. The cap, marker and cut contract are identical, marker counted INSIDE n and the n < len(marker) degenerate case included; see SanitizeCapped for the full contract, for why the CR/LF policy is a separate function, and for the two consumer shapes the pair does not serve.
Example ¶
ExampleSanitizeSingleLineCapped bounds an upstream error for a capped log attribute and logs the truncation as a fact rather than inferring it from the marker.
package main
import (
"fmt"
"github.com/cplieger/runesafe"
)
func main() {
reason, cut := runesafe.SanitizeSingleLineCapped("bad request\nlevel=ERROR forged", 14, "...")
fmt.Printf("%q %v %d\n", reason, cut, len(reason))
}
Output: "bad request..." true 14
Types ¶
type Untrusted ¶ added in v1.1.0
type Untrusted string
Untrusted marks a string as untrusted upstream text at the moment it enters the program — an API response field, an upstream error message, a file name, a title — so every human-facing sink it later reaches applies the package's rune policy automatically. The type makes provenance portable: the trust decision is recorded once, in the DTO struct that decodes the upstream payload, instead of re-derived at every emit site.
Ingestion is free. A string-kinded named type without an UnmarshalText method decodes natively (encoding/json assigns into it directly), so tagging a decode-struct field preserves the raw bytes exactly as received. Emission is where the policy fires, through the standard interfaces:
- slog: LogValue implements slog.LogValuer, so a value passed as a bare attr ("title", v) resolves to its Sanitize'd form in every handler, through groups, before encoding.
- fmt and errors: String implements fmt.Stringer, so %s, %v, %q — and fmt.Errorf("upstream said %s", v) — render sanitized text. An error built this way carries no escape introducers from construction on, the one boundary that covers error values (slog handlers stringify errors inside the encoder, after any attribute rewriting). String keeps CR/LF (the Sanitize preset), so the error text is safe where its eventual sink escapes or quotes them — slog's handlers, JSON — but not as-is for a hand-built sink that escapes nothing; there, build the message from SingleLine instead.
- encoders: MarshalText implements encoding.TextMarshaler, so encoding/json and any TextMarshaler-aware encoder emit the Sanitize'd form, however deeply the value nests in a document. Map KEYS are the one exception: encoding/json uses a string-kinded key's bytes directly, never calling MarshalText, so a map[Untrusted]V key emits raw — key marshaled documents by u.String().
Raw returns the exact bytes for the paths that must not be transformed: matching, dedupe keys, context-aware escapers, and byte caps on the raw value itself (a cap bounding the EMITTED form applies after sanitizing: CapBytes(v.String(), n), never Untrusted(CapBytes(v.Raw(), n)), because sanitizing can grow invalid bytes into the 3-byte U+FFFD). A plain string(v) conversion yields the same bytes but silently drops the tag; prefer Raw so intentional unwrapping stays greppable.
Two rules keep the type honest:
- Machine-read persistence stores Raw. MarshalText fires inside every json.Marshal, so a tagged field written to a state file and read back would round-trip sanitized-not-raw. Structs persisted for the program's own consumption keep plain string fields, populated via Raw at construction; the tagged form is for human-facing documents only.
- Untrusted does not replace construction-time sanitization for text that must be safe unconditionally through every future sink (a captured error body embedded in a returned value), and context-aware sinks (Markdown cells, URLs, HTML) still need their own escaping on top of the rune policy, composed over Raw.
LogValue, String, and MarshalText apply Sanitize (the keepCRLF=true preset): correct for JSON sinks, and safe for slog's TextHandler, whose quoting escapes a kept CR or LF. For a hand-built single-line sink whose encoder escapes nothing, use SingleLine explicitly.
Example ¶
ExampleUntrusted tags an upstream field at the decode boundary: the raw bytes survive ingestion for matching (Raw), while fmt, errors, and JSON emission all render the sanitized form automatically.
package main
import (
"encoding/json"
"fmt"
"github.com/cplieger/runesafe"
)
func main() {
var ep struct {
Title runesafe.Untrusted `json:"title"`
}
_ = json.Unmarshal([]byte(`{"title":"Frieren\u202egpj.exe"}`), &ep)
fmt.Println(len(ep.Title.Raw())) // raw bytes preserved for compute
fmt.Println(ep.Title) // fmt renders sanitized
fmt.Println(fmt.Errorf("bad: %s", ep.Title)) // errors carry sanitized text
out, _ := json.Marshal(ep)
fmt.Println(string(out)) // encoders emit sanitized
}
Output: 17 Frieren gpj.exe bad: Frieren gpj.exe {"title":"Frieren gpj.exe"}
func (Untrusted) LogValue ¶ added in v1.1.0
LogValue implements slog.LogValuer: a tagged attr value resolves to its Sanitize'd form in every handler before encoding.
func (Untrusted) MarshalText ¶ added in v1.1.0
MarshalText implements encoding.TextMarshaler: encoding/json and any TextMarshaler-aware encoder emit the Sanitize'd form at any nesting depth — except as a map key: encoding/json resolves a string-kinded map key directly from its bytes and never calls MarshalText, so a map[Untrusted]V key marshals RAW. Key a marshaled document by u.String() instead. Decoding is deliberately untouched (no UnmarshalText), so raw bytes survive ingestion; see the type comment for the machine-read persistence rule this asymmetry imposes.
func (Untrusted) Raw ¶ added in v1.1.0
Raw returns the exact bytes as received, for matching, dedupe keys, byte caps, and context-aware escapers. A byte cap meant to bound an EMITTED form belongs on the sanitized string — CapBytes(u.String(), n) — because sanitizing can grow the raw bytes (each invalid byte becomes the three-byte U+FFFD), so a cap applied to Raw does not survive emission. Prefer Raw over a string conversion so intentional unwrapping stays greppable.
func (Untrusted) SingleLine ¶ added in v1.1.0
SingleLine returns the SanitizeSingleLine'd form, for hand-built single-line sinks whose encoder does not escape CR/LF.
func (Untrusted) String ¶ added in v1.1.0
String implements fmt.Stringer: %s, %v, %q, and fmt.Errorf render the Sanitize'd form, so an error wrapping the value carries no escape introducers from construction on. The form keeps CR/LF (see the type comment): fine wherever the text is later quoted or encoded (slog, JSON), not for a hand-built single-line sink — use SingleLine there.