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.
This commit is contained in:
Jose Diaz-Gonzalez
2026-07-18 09:22:37 -04:00
parent 730fa85d03
commit 1a376c3622
25 changed files with 444 additions and 29 deletions

View File

@@ -226,6 +226,58 @@ func TestMigrateLegacyDockerOptionsFiles_SkipsEmptyContentFiles(t *testing.T) {
}
}
func TestRepairTraefikLabelBackticks(t *testing.T) {
cases := []struct {
name string
in string
want string
changed bool
}{
{"traefik rule space form", "--label 'traefik.http.routers.web.rule=Host(\\`app.example.com\\`)'", "--label 'traefik.http.routers.web.rule=Host(`app.example.com`)'", true},
{"traefik rule whole-quoted equals form", "'--label=traefik.http.routers.web.rule=Host(\\`x\\`)'", "'--label=traefik.http.routers.web.rule=Host(`x`)'", true},
{"non-traefik label left untouched", "--label 'some.key=Host(\\`x\\`)'", "--label 'some.key=Host(\\`x\\`)'", false},
{"already valid traefik label", "--label 'traefik.rule=Host(`x`)'", "--label 'traefik.rule=Host(`x`)'", false},
{"non-label option left untouched", "-v /tmp", "-v /tmp", false},
{"traefik label without backticks", "--label 'traefik.enable=true'", "--label 'traefik.enable=true'", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, changed := repairTraefikLabelBackticks(tc.in)
if got != tc.want || changed != tc.changed {
t.Errorf("repairTraefikLabelBackticks(%q) = (%q, %v), want (%q, %v)", tc.in, got, changed, tc.want, tc.changed)
}
})
}
}
func TestMigrateTraefikLabelBackticks(t *testing.T) {
dokkuRoot := setupMigrationEnv(t)
if err := os.MkdirAll(filepath.Join(dokkuRoot, "alpha"), 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
broken := "--label 'traefik.http.routers.web.rule=Host(\\`app.example.com\\`)'"
if err := common.PropertyListWrite("docker-options", "alpha", "_default_.deploy", []string{broken}); err != nil {
t.Fatalf("seed: %v", err)
}
if err := migrateTraefikLabelBackticks(); err != nil {
t.Fatalf("migrateTraefikLabelBackticks: %v", err)
}
got, err := GetDockerOptionsForProcessPhase("alpha", "_default_", "deploy")
if err != nil {
t.Fatalf("GetDockerOptionsForProcessPhase: %v", err)
}
want := "--label 'traefik.http.routers.web.rule=Host(`app.example.com`)'"
if !equalStrings(got, []string{want}) {
t.Errorf("got %q, want [%q]", got, []string{want})
}
if common.PropertyGet("docker-options", "--global", traefikLabelMigrationKey) != "true" {
t.Errorf("expected %s guard to be set", traefikLabelMigrationKey)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false