Skip to content

fix(security): strip terminal control chars from untrusted session fields#191

Merged
stephenleo merged 2 commits into
stephenleo:mainfrom
jorgevazquez:fix/sanitize-terminal-escapes
Jun 28, 2026
Merged

fix(security): strip terminal control chars from untrusted session fields#191
stephenleo merged 2 commits into
stephenleo:mainfrom
jorgevazquez:fix/sanitize-terminal-escapes

Conversation

@jorgevazquez

Copy link
Copy Markdown
Contributor

Summary

Claude Code session JSON embeds attacker-influenced strings (directory names, model display names, vim mode, agent name, etc.). A malicious repo can name a directory with raw ESC sequences (delivered as JSON \u001b escapes, which serde decodes to real control bytes); cship rendered these verbatim to stdout, where the terminal interprets them — enabling window-title spoofing, cursor repositioning to overwrite on-screen text, or OSC 52 clipboard writes (CWE-150).

Fix

  • Add ansi::sanitize_control, which drops every Unicode Cc (control) character — ESC, BEL, BS, CR/LF/TAB, and the C1 range.
  • Apply it once at ingest in context.rs::from_reader, after deserialization, to every untrusted string field. Raw JSON values never legitimately contain control bytes, and styling escapes are added later from trusted config, so this loses nothing while neutralizing injected sequences. Sanitizing at ingest covers every downstream consumer (modules, passthrough, explain, cache) uniformly.

Testing

  • New unit tests for sanitize_control (strips C0/C1/DEL, preserves Unicode/spaces) and ingest (sanitize() over all fields, a from_reader JSON-escape decode→strip check, and a regression guard that clean values are untouched).
  • Full suite green: 484 lib + 74 integration tests, cargo fmt --check, cargo clippy -D warnings, release build.
  • Manual end-to-end: a payload with \u001b-escaped sequences in model.display_name and workspace.current_dir renders as inert text (]0;HACKEDX, [1A) with zero stray ESC bytes, while legitimate [s](bold green) styling still emits its ANSI.

🤖 Generated with Claude Code

…elds

Claude Code session JSON embeds attacker-influenced strings (directory
names, model display names, vim mode, agent name, etc.). A malicious repo
can name a directory with raw ESC sequences (JSON-escaped as �);
cship rendered these verbatim to stdout, where the terminal interprets
them — enabling window-title spoofing, cursor repositioning to overwrite
on-screen text, or OSC 52 clipboard writes (CWE-150).

Add ansi::sanitize_control (drops Unicode Cc control chars) and apply it
once at ingest in context.rs, after deserialization, to every untrusted
string field. Raw values never legitimately contain control bytes, and
styling escapes are added later from trusted config, so this loses nothing
while neutralizing injected sequences. Sanitizing at ingest covers every
downstream consumer (modules, passthrough, explain, cache) uniformly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stephenleo

Copy link
Copy Markdown
Owner

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. Confirmed all 12 untrusted Option<String> fields on Context (including the recently-added effort.level) are covered by sanitize(), the char::is_control() filter correctly spans C0/C1/DEL, and sanitizing once at from_reader ingest protects all downstream consumers.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

…itization

Document the ingest-time sanitization so users aren't surprised when
control bytes are stripped from paths or model names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@stephenleo

Copy link
Copy Markdown
Owner

Pushed two docs additions to this branch (aa87e0e) to go with the code change:

  • CHANGELOG.md — added a ### Security entry under [Unreleased] describing the control-char stripping and the CWE-150 injection vector it closes.
  • docs/faq.md — added a FAQ entry, "Why are some characters missing from my path or model name?", noting that control bytes are stripped from untrusted session fields at ingest by design, that legitimate values are unaffected, and that the behavior can't be disabled — so anyone who sees stripped characters knows why.

No code changes — just documenting existing behavior so the stripping isn't surprising.

@stephenleo stephenleo merged commit 70bdb10 into stephenleo:main Jun 28, 2026
3 checks passed
@stephenleo

Copy link
Copy Markdown
Owner

Thank you for your submission!

stephenleo added a commit that referenced this pull request Jun 29, 2026
- Bump crate version to 1.8.0
- Promote the Unreleased changelog section to [1.8.0], including the
  previously-omitted {reset_at} entry (#192)
- Drop {reset_at} from the cship.toml showcase usage_limits formats

Ships the security fix (#191), the breaking cost-duration formatting
change, and the new effort (#187) and account (#153) modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stephenleo added a commit that referenced this pull request Jun 29, 2026
- Bump crate version to 1.8.0
- Promote the Unreleased changelog section to [1.8.0], including the
  previously-omitted {reset_at} entry (#192)
- Drop {reset_at} from the cship.toml showcase usage_limits formats

Ships the security fix (#191), the breaking cost-duration formatting
change, and the new effort (#187) and account (#153) modules.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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