Files
dokku/plugins/docker-options/dockeroptions_test.go
Jose Diaz-Gonzalez 1a376c3622 fix: prevent command injection via docker options eval
Values supplied through docker options, `--ttl-seconds`, and `-e` flowed into a Bash `eval` during build, deploy, and run, letting a low-privileged user execute arbitrary commands on the host as the dokku user. These arguments are now tokenized and passed through to the container verbatim, without shell expansion. A one-time migration repairs stored labels whose backticks were saved with a stray backslash so Traefik-style rules stay valid on the next deploy.
2026-07-19 01:42:12 -04:00

192 lines
4.8 KiB
Go

package dockeroptions
import (
"testing"
)
func TestSplitOptionString(t *testing.T) {
cases := []struct {
name string
input string
wantOptions []string
wantProcesses []string
wantErr bool
}{
{
name: "single flag with value",
input: "--link foo",
wantOptions: []string{"--link foo"},
},
{
name: "single flag with equals value",
input: "--build-arg FOO=bar",
wantOptions: []string{"--build-arg FOO=bar"},
},
{
name: "two link flags split",
input: "--link a --link b",
wantOptions: []string{"--link a", "--link b"},
},
{
name: "build-arg followed by two link flags",
input: "--build-arg X=Y --link a --link b",
wantOptions: []string{"--build-arg X=Y", "--link a", "--link b"},
},
{
name: "boolean flag then key+value flag",
input: "--rm --link foo",
wantOptions: []string{"--rm", "--link foo"},
},
{
name: "short flag with value",
input: "-v /tmp",
wantOptions: []string{"-v /tmp"},
},
{
name: "restart with embedded equals and colon",
input: "--restart=on-failure:5",
wantOptions: []string{"--restart=on-failure:5"},
},
{
name: "empty input",
input: "",
},
{
name: "whitespace only",
input: " \t ",
},
{
name: "value with whitespace gets shell-quoted",
input: `--label "foo bar"`,
wantOptions: []string{"--label 'foo bar'"},
},
{
name: "equals value with whitespace gets shell-quoted",
input: `--label key="hello world"`,
wantOptions: []string{"--label 'key=hello world'"},
},
{
name: "process flag in option content is lifted",
input: "--process web",
wantProcesses: []string{"web"},
},
{
name: "process equals form is lifted",
input: "--process=web",
wantProcesses: []string{"web"},
},
{
name: "process lifted from middle of multi-flag input",
input: "--link foo --process web --link bar",
wantOptions: []string{"--link foo", "--link bar"},
wantProcesses: []string{"web"},
},
{
name: "multiple processes lifted",
input: "--process web --process worker",
wantProcesses: []string{"web", "worker"},
},
{
name: "process without value errors",
input: "--process",
wantErr: true,
},
{
name: "unbalanced quote errors",
input: `--build-arg FOO="bar`,
wantErr: true,
},
{
name: "command substitution is stored verbatim",
input: "--label x=$(id)",
wantOptions: []string{"--label 'x=$(id)'"},
},
{
name: "backtick command substitution is stored verbatim",
input: "--label x=`id`",
wantOptions: []string{"--label 'x=`id`'"},
},
{
name: "parameter expansion is stored verbatim",
input: "--label x=$FOO",
wantOptions: []string{"--label 'x=$FOO'"},
},
{
name: "metacharacter value that parses is shell-quoted",
input: `--label 'a;b'`,
wantOptions: []string{"--label 'a;b'"},
},
{
name: "escaped backtick inside double quotes is unescaped",
input: "--label \"traefik.rule=Host(\\`app.example.com\\`)\"",
wantOptions: []string{"--label 'traefik.rule=Host(`app.example.com`)'"},
},
{
name: "escaped dollar inside double quotes is unescaped",
input: "--label \"k=a\\$b\"",
wantOptions: []string{"--label 'k=a$b'"},
},
{
name: "unquoted backslash escape is removed",
input: "--label k=a\\ b",
wantOptions: []string{"--label 'k=a b'"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotOptions, gotProcesses, err := SplitOptionString(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("SplitOptionString(%q) succeeded, want error", tc.input)
}
return
}
if err != nil {
t.Fatalf("SplitOptionString(%q): %v", tc.input, err)
}
if !equalStringSlice(gotOptions, tc.wantOptions) {
t.Errorf("options = %q, want %q", gotOptions, tc.wantOptions)
}
if !equalStringSlice(gotProcesses, tc.wantProcesses) {
t.Errorf("processes = %q, want %q", gotProcesses, tc.wantProcesses)
}
})
}
}
func TestQuoteShellArg(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", "''"},
{"plain", "plain"},
{"FOO=bar", "FOO=bar"},
{"hello world", "'hello world'"},
{"it's", `'it'\''s'`},
{"$VAR", "'$VAR'"},
{"a|b", "'a|b'"},
}
for _, tc := range cases {
if got := quoteShellArg(tc.in); got != tc.want {
t.Errorf("quoteShellArg(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func equalStringSlice(a, b []string) bool {
if len(a) == 0 && len(b) == 0 {
return true
}
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}