Terminal-injection confinement: neutralize ESC/ANSI/control bytes in untrusted text before it reaches the terminal renderer.
One of the three centralized boundary confinements (PR #569 thread 2); the
others are Raxol.Core.Boundary.Path (path-traversal) and
Raxol.Core.Boundary.Evaluation (code-evaluation exposure). This one has
byte-stream semantics — a total function over binaries, no filesystem, never
errors, never passes a dangerous byte through. It enforces the repo rule
"never embed raw ANSI codes in strings passed to text()" at the untrusted
boundary instead of trusting upstream.
What sanitize/2 strips
In a single left-to-right pass:
- ESC (
0x1B) and every sequence it introduces — CSI (ESC [… final0x40..0x7E), OSC (ESC ]…BEL/ST; kills title-set and OSC-8 hyperlink smuggling), DCS/APC/PM/SOS (ESC P/ESC _/ESC ^/ESC X…ST), two-byteESC <c>forms, and a truncated trailingESC. - C0 controls (
0x00..0x1F) except the:allowlist (default[?\n]). - DEL (
0x7F) and raw C1 controls (0x80..0x9F). - Format characters that misrepresent a line — the bidi
embedding/override block and isolates (
U+202A..U+202E,U+2066..U+2069), which reorder rendered text (Trojan Source, CWE-451) so"disabled in a hosted session"can be made to read as its opposite with nothing an ESC filter would catch;U+200B,U+200E,U+200F,U+00AD,U+2028,U+2029andU+FEFF, which hide or re-break the content a reader is asked to trust; and the tag charactersU+E0000..U+E007F, which carry an invisible ASCII payload inside ordinary-looking text. ZWJ (U+200D) and ZWNJ (U+200C) are deliberately KEPT: they join emoji sequences, carry meaning in Indic and Perso-Arabic scripts, and reorder nothing. - Invalid UTF-8 bytes → replaced with
U+FFFD(never emit a broken sequence downstream). Raw C1 bytes are stripped (not replaced); other invalid bytes becomeU+FFFD.
Valid printable text (including CJK, emoji, and other multi-byte UTF-8) passes
through unchanged. The function never raises and its output contains no ESC
and no disallowed control byte for ANY input binary.
Where confinement happens (authoritative)
Untrusted content is confined at the OUTPUT SINKS, not by walking and rebuilding a component tree on every frame. Component modules keep their view data ordinary and do no terminal confinement of their own; they point here instead of restating this list. The sinks are:
- Cell writes —
Raxol.Core.Runtime.Rendering.Backends.sanitize_char/1delegates tosanitize_cell/1as a cell enters the screen buffer. - Terminal emitters —
Raxol.Terminal.RendererandRaxol.Core.Rendererpass every cell throughsanitize_cell/1and every OSC 8 URL throughsanitize_url/1, immediately before the bytes are assembled. - Layout link propagation —
Raxol.UI.Layout.Engineconfines a:linkattribute withsanitize_url/1as it propagates. - Append/paint authority —
Raxol.Harness.Surface.ViewTextsanitizes per line while flattening the view tree, over this module'sstrip_codepoint?/2deny set.
A cell sink and a text sink differ in exactly ONE respect: a cell owns a
column, so a disallowed cell is BLANKED (sanitize_cell/1), while
disallowed text is DELETED (sanitize/2). Every sink shares one deny set,
strip_codepoint?/2.
Summary
Types
A C0/allowed control byte value, e.g. ?\t or ?\n.
Functions
Sanitize binary for safe delivery to a terminal renderer.
Confine one screen CELL's text.
Confine a URL for OSC 8 emission: sanitize/2 with allow: [], then a
scheme allowlist (http, https, mailto).
The deny set, as a predicate over one codepoint.
Types
Functions
Sanitize binary for safe delivery to a terminal renderer.
Total: always returns a String.t(), never raises.
Options
:allow— a list of C0 control byte values (0..31) to pass through. Defaults to[?\n].ESC(0x1B) is never allowed regardless of this list.
Confine one screen CELL's text.
A cell is POSITIONAL: it owns a column. Deleting it (what sanitize/2 does
to text) pulls every cell to its right one column left, so box borders,
table columns and the approval line come apart around a single hostile
byte. A disallowed cell is therefore BLANKED to " ", never deleted, and
an invalid UTF-8 byte becomes the one-column U+FFFD the text path already
substitutes.
A non-binary cell fails closed as "", and an empty cell stays empty:
neither occupies a column to preserve.
Confine a URL for OSC 8 emission: sanitize/2 with allow: [], then a
scheme allowlist (http, https, mailto).
Returns "" for a non-binary, for a URL whose bytes all sanitize away, and
for any other scheme — including a URL with no scheme at all, which a
terminal resolves against a base this process does not control. Callers
read "" as "no link": the label text still renders, it is simply not
clickable.
@spec strip_codepoint?(integer(), [control_byte()]) :: boolean()
The deny set, as a predicate over one codepoint.
true when codepoint must never reach a terminal: a C0 control outside
allow, DEL, a C1 control, or one of the format characters listed in the
moduledoc. Public so that every sink — text, cell, and the
Raxol.Harness.Surface.ViewText line scanner — decides with the SAME set
rather than keeping a second list that drifts from this one.