mirror of
https://github.com/JanDeDobbeleer/oh-my-posh.git
synced 2026-08-24 10:14:12 -05:00
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:
committed by
Jan De Dobbeleer
parent
9edb5b7a3a
commit
16bf138780
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user