fix(terminal): strip control runes from rendered segment content

Segment text (directory names, git commit metadata, environment
variables, command output) can be attacker-controlled and reached the
terminal unfiltered through write(s rune), the only sink Oh My Posh's
own styling never uses. Raw ESC/BEL/CSI/OSC bytes let an attacker
inject terminal escape sequences (title spoofing, OSC 52 clipboard
writes) via a malicious directory name or git commit subject.

GHSA-fwjx-9p69-h25h

Entire-Checkpoint: 65f209a5fba9
This commit is contained in:
Jan De Dobbeleer
2026-07-23 12:44:26 +02:00
committed by Jan De Dobbeleer
parent 88ddbe0b0a
commit edcf3c88f3
3 changed files with 41 additions and 0 deletions
+24
View File
@@ -674,6 +674,15 @@ func write(s rune) {
return
}
// segment content (directory names, git metadata, environment variables,
// command output) is potentially attacker-controlled and never passes through
// this function when it's Oh My Posh's own styling; drop C0/C1 control runes
// (ESC, BEL, CSI, OSC, ...) so they can't be interpreted as escape sequences
// by the terminal, including inside a hyperlink target.
if isControlRune(s) {
return
}
if isHyperlink {
builder.WriteRune(s)
return
@@ -694,6 +703,21 @@ func write(s rune) {
builder.WriteRune(s)
}
// isControlRune reports whether s is a C0 (0x00-0x1F), DEL (0x7F), or C1
// (0x80-0x9F) control character. These are the bytes a terminal can interpret
// as the start of an escape sequence (ESC, BEL, CSI, OSC, ...); no legitimate
// rendered segment content needs them. '\n' is exempt: Oh My Posh itself
// prepends a literal newline ahead of the transient prompt (see
// Engine.getNewline), and unlike ESC/BEL/CSI/OSC a bare LF can't be
// interpreted as the start of an escape sequence.
func isControlRune(s rune) bool {
if s == '\n' {
return false
}
return s <= 0x1f || (s >= 0x7f && s <= 0x9f)
}
// writeVisibleRune stamps the active gradient color(s) for the current cell
// before writing s, then advances cellIndex by s's rune width. It is only
// called from writeBodyGradient, so isInvisible/isHyperlink runes are
+5
View File
@@ -93,6 +93,11 @@ func TestGenerateHyperlinkWithUrl(t *testing.T) {
ShellName: shell.BASH,
Expected: "\\[\x1b[47m\\]\\[\x1b[30m\\]\\[\x1b]8;;http://www.google.be\x1b\\\\\\]link\\[\x1b]8;;\x1b\\\\\\]\\[\x1b[0m\\]",
},
{
Text: "<LINK>http://evil\x1b]0;HACKED\x07.com<TEXT>google</TEXT></LINK>",
ShellName: shell.FISH,
Expected: "\x1b[47m\x1b[30m\x1b]8;;http://evil]0;HACKED.com\x1b\\google\x1b]8;;\x1b\\\x1b[0m",
},
}
for _, tc := range cases {
Init(tc.ShellName)
+12
View File
@@ -219,6 +219,18 @@ func TestWriteANSIColors(t *testing.T) {
Expected: "\x1b[33mhello \x1b[48;2;130;170;255m\x1b[38;2;1;22;39mnew\x1b[49m\x1b[33m world\x1b[0m",
Colors: &color.Set{Foreground: "yellow", Background: "transparent"},
},
{
Case: "OSC injection stripped from segment content",
Input: "before\x1b]0;HACKED\x07after",
Expected: "\x1b[47m\x1b[30mbefore]0;HACKEDafter\x1b[0m",
Colors: &color.Set{Foreground: "black", Background: "white"},
},
{
Case: "CSI and C1 control runes stripped from segment content",
Input: "before\x1b[31mred\u009bafter",
Expected: "\x1b[47m\x1b[30mbefore[31mredafter\x1b[0m",
Colors: &color.Set{Foreground: "black", Background: "white"},
},
}
for _, tc := range cases {