Skip to content

fix: sanitize ANSI/control sequences in markdown render path#116

Open
Mingye-Lu wants to merge 2 commits into
mainfrom
fix/ansi-injection-markdown
Open

fix: sanitize ANSI/control sequences in markdown render path#116
Mingye-Lu wants to merge 2 commits into
mainfrom
fix/ansi-injection-markdown

Conversation

@Mingye-Lu

Copy link
Copy Markdown
Owner

Summary

  • text_to_ansi wrote streamed markdown text directly into the terminal ANSI
    stream with no filtering, and the existing strip_ansi helper was never
    invoked from that path (MarkdownStreamState -> markdown_to_ansi ->
    render_lines), only from bash-output previews and the TUI. Untrusted
    scraped page text echoed back by the model could therefore inject raw
    terminal escape sequences.
  • Fixed by sanitizing the raw markdown string in render_lines() before
    it reaches the pulldown-cmark parser (not per-Event, since the
    tokenizer can split a single escape sequence's bytes across two adjacent
    events — e.g. a stray [ inside an OSC/CSI payload gets tokenized as
    markdown link syntax, defeating per-event filtering).
  • render_lines() is the single entry point for both the streaming state
    machine and one-shot rendering, so every caller (including the assistant's
    own streamed output path) is protected automatically without an opt-in
    strip_ansi() call.
  • Strips all C0 control characters except tab/newline, and now recognizes
    CSI, OSC, DCS, SOS, PM, and APC escape sequences (not just CSI) via a
    shared skip_escape_sequence() helper also reused by strip_ansi(), so
    its existing callers get the same broader coverage.

Test plan

  • cargo fmt -p render
  • cargo test -p render (81 passed, including new
    render_lines_strips_injected_escape_sequences_from_text and
    strip_ansi_removes_osc_sequences tests)
  • cargo clippy -p render --all-targets -- -D warnings (clean)

Untrusted text (e.g. scraped page content echoed back by the model)
could carry raw ESC bytes that reached the terminal unfiltered:
text_to_ansi() wrote span.content directly into the ANSI stream, and
the existing strip_ansi() helper was never called from the streaming
markdown render path (MarkdownStreamState -> markdown_to_ansi ->
render_lines), only from tool_format.rs previews and the TUI.

Fix by scrubbing the raw markdown string in render_lines() before it
reaches the parser, rather than sanitizing per pulldown-cmark Event:
the tokenizer can split an escape sequence's bytes across two
adjacent Text/Html events (e.g. a stray '[' inside an OSC/CSI payload
gets tokenized as markdown link syntax), so per-event sanitization
misses sequences split at token boundaries. Since render_lines() is
the single entry point used by both the streaming state machine and
one-shot rendering, every caller is protected automatically.

Strips all C0 control characters except tab/newline, and now
recognizes CSI, OSC, DCS, SOS, PM, and APC escape sequences (not just
CSI) via a shared skip_escape_sequence() helper also used by the
existing strip_ansi(), so its other callers (tool_format.rs bash
previews, the TUI) get the same broader coverage.
@Mingye-Lu

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1ddb5b41fc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +149 to +152
let sanitized = sanitize_untrusted_text(markdown);

let mut writer = MdWriter::default();
for event in Parser::new_ext(markdown, opts) {
for event in Parser::new_ext(&sanitized, opts) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Sanitize decoded markdown entities too

Because the filter only scrubs the raw markdown before pulldown_cmark runs, inputs such as [31mRED bypass it: the parser decodes the numeric character reference into an ESC Event::Text after this point, and text_to_ansi later writes span contents verbatim. In the streamed assistant/TUI render path, untrusted page text can therefore still inject terminal controls via entities unless decoded text events or rendered spans are sanitized as well.

Useful? React with 👍 / 👎.

Comment on lines +888 to +895
while let Some(next) = chars.next() {
if next == '\u{07}' {
break;
}
if next == '\u{1b}' && chars.peek() == Some(&'\\') {
chars.next();
break;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop string escapes at C1 ST terminators

When an OSC/DCS/SOS/PM/APC sequence uses the 8-bit ST terminator (U+009C) instead of ESC \\, this loop never breaks and consumes everything through EOF. In markdown rendering or strip_ansi previews, content like before ESC]0;title U+009C after renders only before, so valid terminal strings can hide all following output rather than just being removed.

Useful? React with 👍 / 👎.

…equences

- Push text through sanitize_untrusted_text() in push_text() to catch
  HTML-entity-decoded ANSI/C0 sequences that bypass the pre-parse filter
  (e.g.,  decoded by pulldown-cmark into ESC in Event::Text).

- Accept C1 ST terminator (U+009C) as a valid string-escape terminator
  in skip_escape_sequence(), preventing unbounded consumption when
  OSC/DCS/SOS/PM/APC sequences use 8-bit terminators instead of ESC \.

Closes #116
@Mingye-Lu

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants