fix: prevent host shell injection from app.json cron commands

The docker-local scheduler wrote each app.json cron command verbatim into the dokku user's crontab, where cron's `bash -c` interpreted any shell metacharacters in the command on the host as the dokku user rather than inside the container. The crontab line is now `dokku cron:run <app> <cron-id>`, and the command is resolved from app.json and exec'd inside the container at run time. Commands containing shell operators are also rejected when app.json is validated at deploy time.
This commit is contained in:
Jose Diaz-Gonzalez
2026-05-25 02:14:21 -04:00
parent 75c0659dcf
commit ac95225809
5 changed files with 255 additions and 31 deletions

View File

@@ -10,8 +10,20 @@ import (
"github.com/multiformats/go-base36"
cronparser "github.com/robfig/cron/v3"
"mvdan.cc/sh/v3/shell"
)
// ValidateCronCommand returns an error if the command cannot be tokenised
// as a sequence of shell words. cron:run dispatches with the same parser,
// so a command that fails here will also fail to execute - validating at
// deploy time surfaces the error before the schedule fires.
func ValidateCronCommand(command string) error {
_, err := shell.Fields(command, func(name string) string {
return ""
})
return err
}
var (
// DefaultProperties is a map of all valid cron properties with corresponding default property values
DefaultProperties = map[string]string{
@@ -75,7 +87,7 @@ func (t CronTask) DokkuRunCommand() string {
return t.AltCommand
}
return fmt.Sprintf("dokku run --concurrency-policy %s --cron-id %s %s %s", t.ConcurrencyPolicy, t.ID, t.App, t.Command)
return fmt.Sprintf("dokku cron:run %s %s", t.App, t.ID)
}
// FetchCronTasksInput is the input for the FetchCronTasks function
@@ -138,6 +150,10 @@ func FetchCronTasks(input FetchCronTasksInput) ([]CronTask, error) {
return tasks, fmt.Errorf("Invalid cron schedule for app %s (schedule %s): %s", appName, c.Schedule, err.Error())
}
if err := ValidateCronCommand(c.Command); err != nil {
return tasks, fmt.Errorf("Invalid cron command for app %s (command %q): %s", appName, c.Command, err.Error())
}
cronID := GenerateCommandID(appName, c)
maintenance := c.Maintenance
if value, ok := properties[MaintenancePropertyPrefix+cronID]; ok {

102
plugins/cron/cron_test.go Normal file
View File

@@ -0,0 +1,102 @@
package cron
import (
"strings"
"testing"
)
func TestDokkuRunCommandAppTaskDispatchesViaCronRun(t *testing.T) {
task := CronTask{
App: "myapp",
ID: "abc123",
Command: "echo CRON_OK; echo hi > /tmp/appjson-test.txt",
Schedule: "* * * * *",
ConcurrencyPolicy: "allow",
}
got := task.DokkuRunCommand()
want := "dokku cron:run myapp abc123"
if got != want {
t.Errorf("DokkuRunCommand() = %q, want %q", got, want)
}
if strings.Contains(got, task.Command) {
t.Errorf("DokkuRunCommand() leaked user command into crontab line: %q", got)
}
if strings.ContainsAny(got, ";>|&`$") {
t.Errorf("DokkuRunCommand() contains shell metacharacters: %q", got)
}
}
func TestDokkuRunCommandPlainCommandStillUsesCronRun(t *testing.T) {
task := CronTask{
App: "myapp",
ID: "abc123",
Command: "npm run send-email",
Schedule: "@daily",
ConcurrencyPolicy: "forbid",
}
got := task.DokkuRunCommand()
want := "dokku cron:run myapp abc123"
if got != want {
t.Errorf("DokkuRunCommand() = %q, want %q", got, want)
}
}
func TestDokkuRunCommandAltCommandUnchanged(t *testing.T) {
task := CronTask{
ID: "abc123",
AltCommand: "/usr/bin/some-internal-task --flag",
}
got := task.DokkuRunCommand()
want := "/usr/bin/some-internal-task --flag"
if got != want {
t.Errorf("DokkuRunCommand() = %q, want %q", got, want)
}
}
func TestValidateCronCommandAcceptsValidCommands(t *testing.T) {
cases := []string{
"python3 task.py schedule",
"npm run send-email",
"sh -c 'echo CRON_OK; echo hi > /tmp/x.txt'",
`node -e 'console.log(1)'`,
"true",
}
for _, cmd := range cases {
if err := ValidateCronCommand(cmd); err != nil {
t.Errorf("ValidateCronCommand(%q) returned error: %v", cmd, err)
}
}
}
func TestValidateCronCommandRejectsShellOperators(t *testing.T) {
cases := []string{
"echo CRON_OK; echo hi > /tmp/x.txt",
"cmd1 && cmd2",
"cmd | other",
"cmd > file",
"cmd $(other)",
}
for _, cmd := range cases {
if err := ValidateCronCommand(cmd); err == nil {
t.Errorf("ValidateCronCommand(%q) accepted a command containing a shell operator", cmd)
}
}
}
func TestDokkuRunCommandAltCommandWithLogFile(t *testing.T) {
task := CronTask{
ID: "abc123",
AltCommand: "/usr/bin/some-internal-task",
LogFile: "/var/log/dokku/internal-task.log",
}
got := task.DokkuRunCommand()
want := "/usr/bin/some-internal-task &>> /var/log/dokku/internal-task.log"
if got != want {
t.Errorf("DokkuRunCommand() = %q, want %q", got, want)
}
}

View File

@@ -52,7 +52,7 @@ func CommandList(appName string, format string) error {
maintenance = "true (app)"
}
}
output = append(output, fmt.Sprintf("%s | %s | %s | %t | %s", task.ID, task.Schedule, task.ConcurrencyPolicy, maintenance, task.Command))
output = append(output, fmt.Sprintf("%s | %s | %s | %s | %s", task.ID, task.Schedule, task.ConcurrencyPolicy, maintenance, task.Command))
}
result := columnize.SimpleFormat(output)
@@ -139,6 +139,7 @@ func CommandRun(appName string, cronID string, detached bool) error {
os.Setenv("DOKKU_CONCURRENCY_POLICY", concurrencyPolicy)
os.Setenv("DOKKU_CRON_ID", cronID)
os.Setenv("DOKKU_RM_CONTAINER", "1")
os.Setenv("DOKKU_RUN_TTL_SECONDS", "86400")
scheduler := common.GetAppScheduler(appName)
args := append([]string{scheduler, appName, "0", "--"}, fields...)
_, err = common.CallPlugnTrigger(common.PlugnTriggerInput{