rust · security · tui

Your terminal executes strings

May 18, 2026 · 13 min read

Here is a shell session that should worry you more than it does:

curl -s https://example.com/build.log > build.log
cat build.log

If the author of that log was unkind, the cat just retitled your terminal window, moved your cursor up four rows, and overwrote your scrollback with output that never happened. No exploit was involved. No memory was corrupted, no sandbox escaped. You printed bytes to a byte-printing device, and the device did what the bytes said — because a terminal is not a text display. It's an interpreter. In-band control has been the deal since teletypes: the same stream carries both the text and the instructions for displaying the text, and nothing but convention separates them.

Which means printing a string is executing a program: a small one, in a 1970s-vintage instruction set, with no permission model whatsoever. Most of the time the program is "hello\n" and the only instruction is newline. But every TUI eventually renders text it didn't write: log lines, chat messages, filenames, subprocess output. At that moment, someone else is writing programs and your framework is running them.

The sanitize-ansi crate in inkrs is where that stops. It's under a hundred lines — small enough to take apart in full — and the interesting part isn't the code so much as the policy: what it keeps, what it strips, and why the boundary sits exactly where it does.

The instruction set

Terminal control sequences come in a few families, in escalating order of structure. One real example of each:

  • C0 controls — the single bytes below 0x20. No escape prefix, no parameters; the byte is the instruction. \x08 (backspace) moves the cursor left. \t jumps to the next tab stop. \r returns the cursor to column 0, which is all you need for the classic trick of printing "Verifying signature... FAILED\rVerifying signature... OK\n" and letting the second half pave over the first.

  • CSI sequencesESC [, then parameter bytes (0x30–0x3F), then intermediate bytes (0x20–0x2F), then one final byte (0x40–0x7E) that selects the command. The final byte is the verb: \x1b[31m sets the foreground red (final m — SGR, Select Graphic Rendition), \x1b[2A moves the cursor up two rows (final A), \x1b[2J erases the screen (final J). Same grammar, wildly different blast radius.

  • OSC control stringsESC ], then a free-form payload, terminated by BEL (\x07) or ST (ESC \). This is the "operating system command" channel: \x1b]0;not-your-shell\x07 retitles the window, \x1b]8;;https://example.com\x1b\\ starts a hyperlink, \x1b]52;c;aGVsbG8=\x07 writes "hello" to your clipboard on terminals that allow it.

  • DCS, and its cousins PM, APC, SOSESC P (and ^, _, X), payload, ST. Device control strings: sixel graphics, tmux's passthrough wrapper, terminal-to-terminal esoterica.

We've already met the machinery that parses this grammar: the ansi-tokenizer crate from the first essay in this series, which splits a string into AnsiToken::Text, AnsiToken::Csi (carrying parameter_string, intermediate_string, final_character), and AnsiToken::ControlString with a kind of Osc, Dcs, Pm, Apc, or Sos. Back then we used it to measure; wrapping used it to keep styles intact. Now it gets its third job: it's the parser in front of a security decision.

Two ways this bites a TUI framework

The first way is the one that makes the security lists: classic injection. A TUI is precisely the kind of program that renders untrusted text into a shared screen: a log viewer, a test runner, a chat pane. Consider a log line whose message ends with:

\x1b[1A\x1b[2K

Cursor up one row, erase that entire line. Rendered verbatim, this log entry deletes the entry above it — say, the one recording what actually happened. Add \x1b[2J for a clean slate, or an OSC 0 to retitle the window to something reassuring, and "attacker-controlled log message" quietly becomes "attacker-controlled screen." None of this requires the TUI to have a bug in any conventional sense. Faithfully repeating what it was told is the bug.

An escape sequence escaping the data layer in-band control: one byte stream carries both the text and the instructions CONTROL LAYER — terminal interpreter owns cursor · screen · window title · clipboard unintended commands run erase screen · move cursor · retitle the \x1b[ bytes escape the data layer → run as terminal commands, not shown DATA LAYER — untrusted bytes (a log line) l o g : O K ESC [ 2 J hidden \x1b[ control sequence should be data, not commands
A byte stream that reads as an innocent log line hides an ESC-bracket control sequence; because terminal control is in-band, those bytes escape upward out of the data layer into the control layer, where the terminal runs them as commands — erasing the screen, moving the cursor — instead of printing them.

The second way is quieter and specific to how a framework like inkrs works: layout integrity. The entire rendering pipeline — measure, wrap, paint — reckons in terminal cells, on the assumption that the string it measured is the string the terminal will interpret. Even a completely innocent control character breaks that assumption. The source states the problem exactly, in the doc comment on the predicate that decides which bytes are forbidden (comments translated from the Dutch here and throughout; the crate's internals are partly Dutch, identifiers included):

/// A C0 control character that must not end up as a cell character in
/// the render grid. `\n` is the only exception: that is a legitimate
/// line break for measure/wrap. The rest (`\t`, `\r`, `\x08`, ...) would
/// make the real terminal jump while the cell model advances only one
/// column — hence: replace with a single space, so that measure, wrap
/// and paint all see the same string.
fn is_verboden_c0(ch: char) -> bool {
    ch != '\n' && ch.is_ascii_control()
}

Take the tab. To measure-text it's a control character, width zero-ish, one position in the string. To the real terminal it's "jump to the next tab stop": up to eight columns of travel. Every cell after it now renders somewhere other than where the layout engine painted it, and the box borders from the first essay shear at the seam. A \r is worse: the terminal snaps back to column 0 and the rest of the row paints on top of what's already there. No malice required — one tab character in a log line is enough to disagree with your own frame.

That's why this crate exists as a stage in the pipeline rather than a footnote in the renderer: sanitization runs before measuring and wrapping, so that all three layers operate on the same string and the string contains nothing the terminal would reinterpret behind their backs.

The sanitizer, branch by branch

Here is sanitize_ansi in full — real code, translated comments:

/// Strip ANSI escape sequences that would conflict with Ink's layout.
///
/// Preserved: SGR sequences (colors, bold, etc. - end with 'm') and
/// OSC sequences (hyperlinks, etc. - ESC ] or C1 OSC).
/// Stripped: cursor movement, screen clearing, and other control sequences.
/// Raw C0 control characters other than `\n` become a single space.
pub fn sanitize_ansi(text: &str) -> String {
    // The tokenizer check only sees ESC/C1; a tab-only string would
    // otherwise slip through untouched via the early return.
    if !has_ansi_control_characters(text) && !text.chars().any(is_verboden_c0) {
        return text.into();
    }

    let mut output = String::with_capacity(text.len());

    for token in tokenize_ansi(text) {
        match token {
            AnsiToken::Text { value } => vervang_c0(&mut output, &value),
            AnsiToken::ControlString {
                kind: ansi_tokenizer::ControlStringType::Osc,
                value,
            } => {
                // An OSC payload with raw C0 (\n, \r, \t, ...) passed
                // through verbatim would be cut in half by wrap-text on
                // that '\n' — an unterminated OSC sent to the terminal.
                // Inside an OSC, '\n' is not a line break, so strip the
                // whole token. ESC (introducer/ST/escaped ESC ESC) and
                // BEL (terminator) are the only legitimate controls in
                // the token value.
                if value
                    .chars()
                    .all(|ch| !ch.is_ascii_control() || ch == '\u{001B}' || ch == '\u{0007}')
                {
                    output.push_str(&value);
                }
            }
            AnsiToken::Csi {
                value,
                parameter_string,
                intermediate_string,
                final_character,
            } if final_character == 'm'
                && intermediate_string.is_empty()
                && is_sgr_parameter_string(&parameter_string) =>
            {
                output.push_str(&value);
            }
            _ => {}
        }
    }

    output
}

Four branches. Take them in order.

Plain text goes through vervang_c0 — "replace C0" — which swaps every forbidden control character for a single space (we'll come back to its one exception). Not deleted: replaced. Deleting a character would change the string's width and silently shift everything after it; a space occupies exactly the one cell the grid already budgeted. Consistency over tab fidelity — the test suite says so in as many words.

OSC control strings are kept, but only if every control character in the token is one the OSC grammar itself requires: the ESC of the introducer and terminator, and BEL. The comment explains the threat, and it's not the terminal — it's the next stage of the pipeline. Wrap-text splits its input on \n. Inside an OSC payload, \n isn't a line break, it's just a byte the terminator hasn't shown up for yet — but the wrapper doesn't know that, cuts the token in half, and now one of the output rows ends with an unterminated OSC. Fed to a terminal, that's an open control string swallowing everything after it until an ST arrives from some other, innocent row. Rather than teach the wrapper about OSC internals, the sanitizer refuses to forward any OSC that a downstream \n-split could detonate. One test pins it: \x1b]0;regel1\nregel2\x07rest keeps only rest.

CSI sequences face a three-part guard, and this is the heart of the policy: final byte m, and an empty intermediate string, and a parameter string matching what the helper checks —

/// `^[\d:;]*$` over a string.
fn is_sgr_parameter_string(parameter_string: &str) -> bool {
    parameter_string
        .chars()
        .all(|ch| ch.is_ascii_digit() || ch == ':' || ch == ';')
}

digits, colons, semicolons, nothing else. That's the exact shape of SGR: 31, 1;31, 38:2::255:100:0. It is deliberately narrower than "final byte is m": the CSI grammar reserves <=>? as private-use parameter bytes, so \x1b[>4;2m — a real sequence, xterm's modifyOtherKeys — ends in m and is not SGR. The guard rejects it on the parameter string, and a test (strip_private_parameter_m_sequences_not_sgr) makes sure it stays rejected. Matching on the verb alone would have been the bug; the guard matches the whole grammatical shape.

Everything else hits _ => {} and vanishes. That one silent arm carries the whole default-deny policy: every non-m CSI (all cursor movement, all erasing), every DCS/PM/APC/SOS control string, stray C1 bytes, orphaned ST terminators — and the tokenizer's Invalid token, which covers malformed input. That last one gives the sanitizer a fail-closed property worth noticing: when the tokenizer hits an unterminated control string, it classifies the entire rest of the input as one Invalid token, and the sanitizer drops it wholesale. A\x1bPtmux;\x1blink comes out as A — not as A plus a best-effort guess about where the attacker's DCS was probably going. Undecodable input gets no benefit of the doubt.

Two details are easy to miss. The early return is a fast path — most strings contain no escapes and should cost nothing — but note it needs both checks: has_ansi_control_characters only looks for ESC and C1 bytes, so a string like "\t" contains work to do and no escapes. The regression test tab_only_string_wordt_spatie exists because at one point the early return looked only at the tokenizer's check and waved tabs straight through. And the match arm order matters less than it looks: the branches are disjoint by construction, since a token is text or OSC or CSI, never two of them.

Allowlist, not blocklist

Notice what the code never does: it has no list of dangerous sequences. There's no "strip \x1b[2J", no registry of known attacks. It couldn't work any other way, and it's worth being precise about why.

A blocklist enumerates what's harmful, and for terminal escape sequences that set isn't enumerable. The instruction set is a fifty-year accretion — ECMA-48, then DEC extensions, then xterm extensions, then whatever iTerm2 and kitty shipped last year. New sequences appear; old terminals interpret ambiguous bytes in ways newer ones don't. A blocklist is a claim about every sequence every terminal will ever honor, and you will maintain that claim forever, from behind.

An allowlist enumerates what's needed, and that set is small and knowable because the renderer defines it. inkrs needs SGR, because styling text is the framework's job — colors, bold, the runs that wrap-text goes to such lengths to preserve. It needs OSC, because hyperlinks are OSC 8 and worth supporting. It needs \n. Nothing else in the instruction set helps a framework that positions text by painting cells into a grid — the framework itself owns the cursor, so no string has any business moving it. Keep the two families the renderer requires, in exactly the grammatical shape it requires them, and let the default handle the unknown unknowns.

Honesty requires drawing the boundary precisely, though: this is a layout-integrity allowlist, not a content firewall. A syntactically clean OSC sails through the OSC branch regardless of what it asks for — \x1b]0;definitely-your-shell\x07 retitles the window, and \x1b]52;c;aGVsbG8=\x07 is a clipboard write whose base64 payload contains no raw C0 at all, so it passes. Neither can shear a border or desynchronize the grid, which is the property this crate defends. Whether a chat pane should let strangers retitle your window or write your clipboard is a real question — but it's an application policy question, answerable only by whoever knows where the text came from and what the product should permit. A rendering library that silently made that call for every application would be guessing; this one keeps the render grid coherent and leaves policy above, where the context lives.

The CRLF nuance

One last branch of the policy, small enough to miss and concrete enough to teach. Here's vervang_c0 in full:

/// Replace C0 controls in a plain-text run with spaces.
/// Exception: a `\r` immediately followed by `\n` (CRLF) is removed —
/// otherwise every Windows line gets a phantom space at the end of the
/// line. Only a lone `\r` becomes a space.
fn vervang_c0(output: &mut String, value: &str) {
    let mut chars = value.chars().peekable();
    while let Some(ch) = chars.next() {
        if ch == '\r' && chars.peek() == Some(&'\n') {
            continue;
        }
        output.push(if is_verboden_c0(ch) { ' ' } else { ch });
    }
}

By the general rule, \r is a forbidden C0 and becomes a space. But apply that mechanically to text from a Windows source — where every line ends \r\n — and each line gains an invisible trailing space. The tests spell out why that's not cosmetic: a line sitting exactly at the column budget now measures one cell wider, which means a spurious wrap; a line with a background color paints one visibly colored cell past its text. So the function peeks: a \r immediately followed by \n is dropped entirely ("hello\r\nworld""hello\nworld", no phantom space), while a lone \r — the overwrite trick from the top of this essay — still becomes a space ("b\rc""b c"). The same byte, two treatments, because in one position it's a line-ending convention and in the other it's a cursor instruction. Both cases are pinned by tests, crlf_wordt_kale_newline_zonder_fantoomspatie and losse_cr_blijft_spatie.

That's what a sanitizer built from a real threat model looks like at the bottom: not just "strip the scary bytes," but knowing which occurrence of \r is Windows and which is an attack on column 0.

Decide what you'll say, not what you'll refuse to repeat

Go back to the opening: a terminal is an interpreter, and anything that prints untrusted text to it is eval with extra steps. You don't get to opt out of that architecture — in-band control is the terminal contract, and it predates everyone reading this.

What you do get to choose is your posture. A blocklist is a promise to out-enumerate every attacker and every future terminal, made by the party with the least information. An allowlist is a statement about your own renderer — these two grammatical shapes are the entirety of what I will forward — and you're the best-placed party in the world to make that statement, because you wrote the renderer. sanitize-ansi is ninety-odd lines and four match arms, and the reason it can be that small is that it answers the tractable question. What's dangerous out there is unknowable. What your render grid needs is not.

You can't sanitize what you haven't parsed — and you can't allowlist what you haven't understood about your own renderer. This crate is what it looks like to have done both.


Thoughts? Find me on LinkedIn.