fix(terminal): strip control runes from OSC pwd/iterm sequences

write's per-rune isControlRune filter only guards segment-body text
streamed through it. Pwd() and RenderItermFeatures() format the raw
current working directory (and username/hostname) directly into OSC
7/51/99/1337 sequences via fmt.Sprintf, bypassing that filter entirely.
A directory name containing ESC/ST/BEL bytes closes Oh My Posh's own
OSC sequence early and lets an independent, attacker-controlled escape
sequence stand on its own once the victim cds into it and the pwd or
iterm_features integration renders it — the same class of injection an
earlier fix closed for segment content, reopened via a sink it didn't
cover.

Add a whole-string control-rune stripper for these one-shot OSC payload
fields and apply it to pwd/userName/hostName before every OSC format
call in both functions.

Entire-Checkpoint: 97966247c0a5
This commit is contained in:
Jan De Dobbeleer
2026-08-01 15:55:08 +02:00
committed by Jan De Dobbeleer
parent 9edb5b7a3a
commit 16bf138780
4 changed files with 218 additions and 2 deletions
+2 -2
View File
@@ -41,9 +41,9 @@ func RenderItermFeatures(features ITermFeatures, sh, pwd, user, host string) str
result.WriteString(formats.ITermPromptMark)
case CurrentDir:
result.WriteString(fmt.Sprintf(formats.ITermCurrentDir, pwd))
result.WriteString(fmt.Sprintf(formats.ITermCurrentDir, stripControlRunes(pwd)))
case RemoteHost:
result.WriteString(fmt.Sprintf(formats.ITermRemoteHost, user, host))
result.WriteString(fmt.Sprintf(formats.ITermRemoteHost, stripControlRunes(user), stripControlRunes(host)))
}
}
+63
View File
@@ -0,0 +1,63 @@
package terminal
import (
"testing"
"github.com/jandedobbeleer/oh-my-posh/src/shell"
"github.com/stretchr/testify/assert"
)
func TestRenderItermFeatures(t *testing.T) {
cases := []struct {
Case string
Features ITermFeatures
Shell string
Pwd string
User string
Host string
Expected string
}{
{
Case: "CurrentDir clean pwd",
Features: ITermFeatures{CurrentDir},
Shell: shell.GENERIC,
Pwd: "/home/user/project",
Expected: "\x1b]1337;CurrentDir=/home/user/project\x07",
},
{
Case: "CurrentDir malicious pwd is sanitized",
Features: ITermFeatures{CurrentDir},
Shell: shell.GENERIC,
Pwd: maliciousPwd,
Expected: "\x1b]1337;CurrentDir=evil\\]0;PWNEDrest\x07",
},
{
Case: "RemoteHost clean user and host",
Features: ITermFeatures{RemoteHost},
Shell: shell.GENERIC,
User: "jan",
Host: "box",
Expected: "\x1b]1337;RemoteHost=jan@box\x07",
},
{
Case: "RemoteHost malicious user and host are sanitized",
Features: ITermFeatures{RemoteHost},
Shell: shell.GENERIC,
User: maliciousPwd,
Host: maliciousPwd,
Expected: "\x1b]1337;RemoteHost=evil\\]0;PWNEDrest@evil\\]0;PWNEDrest\x07",
},
}
for _, tc := range cases {
t.Run(tc.Case, func(t *testing.T) {
Init(tc.Shell)
got := RenderItermFeatures(tc.Features, tc.Shell, tc.Pwd, tc.User, tc.Host)
assert.Equal(t, tc.Expected, got, tc.Case)
assert.NotContains(t, got, "\x1b]0;PWNED\x07", tc.Case)
})
}
}
+36
View File
@@ -352,6 +352,10 @@ func Pwd(pwdType, userName, hostName, pwd string) string {
return ""
}
userName = stripControlRunes(userName)
hostName = stripControlRunes(hostName)
pwd = stripControlRunes(pwd)
switch pwdType {
case OSC7:
return fmt.Sprintf(formats.Osc7, hostName, pwd)
@@ -940,6 +944,38 @@ func isControlRune(s rune) bool {
return s <= 0x1f || (s >= 0x7f && s <= 0x9f)
}
// isOSCPayloadControlRune reports whether s is a C0 (0x00-0x1F), DEL (0x7F),
// or C1 (0x80-0x9F) control character, with no exemption for '\n': unlike
// isControlRune's rendered segment text, the values this guards are
// single-line OSC payload fields (path, username, hostname), where a raw
// newline has no legitimate use and can only corrupt the sequence.
func isOSCPayloadControlRune(s rune) bool {
return s <= 0x1f || (s >= 0x7f && s <= 0x9f)
}
// stripControlRunes drops control runes from a whole string, for values
// that reach the terminal in one shot rather than through write's per-rune
// stream (an OSC payload field such as a path, username, or hostname).
// Kept separate from write/isControlRune, which guard the streaming render
// path instead.
func stripControlRunes(s string) string {
if !strings.ContainsFunc(s, isOSCPayloadControlRune) {
return s
}
sb := text.NewBuilder()
for _, r := range s {
if isOSCPayloadControlRune(r) {
continue
}
sb.WriteRune(r)
}
return sb.String()
}
// 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
+117
View File
@@ -2,6 +2,7 @@ package terminal
import (
"testing"
"unsafe"
"github.com/jandedobbeleer/oh-my-posh/src/color"
"github.com/jandedobbeleer/oh-my-posh/src/shell"
@@ -492,3 +493,119 @@ func TestAsAnsiColorsWithSourceSurvivesResolution(t *testing.T) {
})
}
}
// maliciousPwd embeds ESC-ST (which closes an OSC sequence early) followed by
// an independent OSC 0 title-set: if Pwd ever stops sanitizing, this reaches
// the terminal as two escape sequences instead of one opaque payload.
const maliciousPwd = "evil\x1b\\\x1b]0;PWNED\x07rest"
func TestPwd(t *testing.T) {
cases := []struct {
Case string
PwdType string
Pwd string
UserName string
HostName string
Expected string
}{
{
Case: "OSC7 clean pwd",
PwdType: OSC7,
Pwd: "/home/user/project",
HostName: "box",
Expected: "\x1b]7;file://box//home/user/project\x1b\\",
},
{
Case: "OSC7 malicious pwd is sanitized",
PwdType: OSC7,
Pwd: maliciousPwd,
HostName: "box",
Expected: "\x1b]7;file://box/evil\\]0;PWNEDrest\x1b\\",
},
{
Case: "OSC51 clean pwd",
PwdType: OSC51,
Pwd: "/home/user/project",
UserName: "jan",
HostName: "box",
Expected: "\x1b]51;Ajan@box:/home/user/project\x1b\\",
},
{
Case: "OSC51 malicious pwd, user and host are sanitized",
PwdType: OSC51,
Pwd: maliciousPwd,
UserName: maliciousPwd,
HostName: maliciousPwd,
Expected: "\x1b]51;Aevil\\]0;PWNEDrest@evil\\]0;PWNEDrest:evil\\]0;PWNEDrest\x1b\\",
},
{
Case: "OSC99 clean pwd",
PwdType: OSC99,
Pwd: "/home/user/project",
Expected: "\x1b]9;9;/home/user/project\x1b\\",
},
{
Case: "OSC99 malicious pwd is sanitized",
PwdType: OSC99,
Pwd: maliciousPwd,
Expected: "\x1b]9;9;evil\\]0;PWNEDrest\x1b\\",
},
}
for _, tc := range cases {
t.Run(tc.Case, func(t *testing.T) {
Init(shell.GENERIC)
got := Pwd(tc.PwdType, tc.UserName, tc.HostName, tc.Pwd)
assert.Equal(t, tc.Expected, got, tc.Case)
assert.NotContains(t, got, "\x1b]0;PWNED\x07", tc.Case)
})
}
}
func TestStripControlRunes(t *testing.T) {
cases := []struct {
Case string
Input string
Expected string
}{
{
Case: "empty string",
Input: "",
Expected: "",
},
{
Case: "printable unicode is untouched",
Input: "jan @ 世界 café",
Expected: "jan @ 世界 café",
},
{
Case: "C0 and C1 control runes removed",
Input: "evil\x1b\\\x1b]0;PWNED\x07rest",
Expected: "evil\\]0;PWNEDrest",
},
{
Case: "newline removed, unlike isControlRune",
Input: "line1\nline2",
Expected: "line1line2",
},
}
for _, tc := range cases {
t.Run(tc.Case, func(t *testing.T) {
got := stripControlRunes(tc.Input)
assert.Equal(t, tc.Expected, got, tc.Case)
})
}
}
func TestStripControlRunesFastPath(t *testing.T) {
input := "no control runes here, just plain text"
got := stripControlRunes(input)
assert.Equal(t, input, got)
assert.Same(t, unsafe.StringData(input), unsafe.StringData(got), "fast path must return the input unchanged, not a copy")
}