fix: respect quoting in procfile commands

Procfile commands for the `docker-local` scheduler were split differently on deploy and on `dokku run`. Deploys failed on escaped quotes, and `dokku run` ignored quotes entirely. Both now split commands using shell quoting rules, and commands containing bare shell operators fail with a clear error, matching the `k3s` scheduler.
This commit is contained in:
Jose Diaz-Gonzalez
2026-09-24 01:24:27 -04:00
parent 92afa386d1
commit 1f8a814047
13 changed files with 317 additions and 92 deletions

View File

@@ -52,6 +52,8 @@ console: bundle exec racksh
dokku run my-app console
```
The Procfile command is split into arguments using the same shell quoting rules used when deploying the app. See the [Procfile documentation](/docs/processes/process-management.md#procfile) for more details.
#### Specifying container labels
Containers may have specific labels attached. In order to avoid issues with dokku internals, do not use any labels beginning with either `com.dokku` or `org.label-schema`.

View File

@@ -179,6 +179,12 @@ importantworker: env QUEUE=important bundle exec rake resque:work
If the app build declares an `ENTRYPOINT`, the command defined in the `Procfile` is passed as an argument to that entrypoint. This is the case for all Dockerfile-based, Docker Image, and Cloud Native Buildpack deployments.
When deploying via the `docker-local` scheduler, for all builders other than Herokuish, the command is split into arguments using shell quoting rules, but is not otherwise interpreted by a shell. Environment variable references such as `$PORT` are substituted before the command is split. Single quotes, double quotes, and backslash escapes then group and escape arguments, and any other special characters are passed to the container verbatim. Shell operators such as `&&`, `|`, `;`, and redirections are not supported and will fail the deploy, and a trailing `#` comment is ignored. If shell features are required, invoke a shell explicitly:
```Procfile
web: sh -c "bin/migrate && exec bin/server --port $PORT"
```
The `web` process type holds some significance in that it is the only process type that is automatically scaled to `1` on the initial application deploy. See the [web process scaling documentation](/docs/processes/process-management.md#the-web-process) for more details around scaling individual processes.
See the [Procfile location documentation](/docs/processes/process-management.md#changing-the-procfile-location) for more information on where to place your `Procfile` file.

View File

@@ -251,6 +251,12 @@ fn-docker-args-split() {
printf "%s" "$DOCKER_ARGS" | xargs -r printf "%s\0"
}
fn-split-command() {
declare desc="tokenizes a command string into NUL-delimited fields using shell quoting rules without shell expansion"
declare COMMAND="$1"
"$PLUGIN_CORE_AVAILABLE_PATH/common/common" split-command -- "$COMMAND"
}
is_valid_app_name() {
declare desc="verify that the app name matches naming restrictions"
local APP="$1"

View File

@@ -13,6 +13,7 @@ require (
github.com/spf13/pflag v1.0.10
golang.org/x/crypto v0.57.0
golang.org/x/sync v0.23.0
mvdan.cc/sh/v3 v3.14.1
)
require (

View File

@@ -2,6 +2,8 @@ github.com/alexellis/go-execute/v2 v2.2.1 h1:4Ye3jiCKQarstODOEmqDSRCqxMHLkC92Bhs
github.com/alexellis/go-execute/v2 v2.2.1/go.mod h1:FMdRnUTiFAmYXcv23txrp3VYZfLo24nMpiIneWgKHTQ=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -11,6 +13,10 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
@@ -27,6 +33,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk=
github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
@@ -47,3 +55,5 @@ golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI=
mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=

View File

@@ -0,0 +1,91 @@
package shellwords
import (
"strings"
"mvdan.cc/sh/v3/syntax"
)
// Split splits input into shell words using the parser directly, so quotes
// delimit words and are stripped from the returned value, but nothing is
// expanded. Parameter expansions, command substitutions, and other
// metacharacters are preserved verbatim. Malformed input, such as an unbalanced
// quote, returns the parser error.
func Split(input string) ([]string, error) {
fields, _, err := SplitWithEnd(input)
return fields, err
}
// SplitWithEnd is Split plus the byte offset just past the last word the
// parser consumed. Callers that must not lose data compare the offset against
// the length of the input to detect a tail the parser skipped, such as a shell
// comment.
func SplitWithEnd(input string) ([]string, int, error) {
parser := syntax.NewParser()
var fields []string
end := 0
for word, err := range parser.WordsSeq(strings.NewReader(input)) {
if err != nil {
return nil, 0, err
}
fields = append(fields, literalWordValue(input, word.Parts))
end = int(word.End().Offset())
}
return fields, end, nil
}
// literalWordValue reconstructs the unquoted, unexpanded value of a shell word.
// Literal and single-quoted parts contribute their literal text; double-quoted
// parts have their surrounding quotes dropped while their contents stay
// literal; every other part - parameter expansions, command substitutions,
// arithmetic expansions - contributes its original source text unchanged.
// Backslash escapes are removed the same way the shell removes them during
// quote removal so a value such as `Host(\`app\`)` round-trips to `Host(`app`)`.
func literalWordValue(input string, parts []syntax.WordPart) string {
var sb strings.Builder
for _, part := range parts {
switch p := part.(type) {
case *syntax.Lit:
sb.WriteString(unquoteBackslashes(p.Value, false))
case *syntax.SglQuoted:
sb.WriteString(p.Value)
case *syntax.DblQuoted:
for _, inner := range p.Parts {
if lit, ok := inner.(*syntax.Lit); ok {
sb.WriteString(unquoteBackslashes(lit.Value, true))
continue
}
sb.WriteString(input[inner.Pos().Offset():inner.End().Offset()])
}
default:
sb.WriteString(input[part.Pos().Offset():part.End().Offset()])
}
}
return sb.String()
}
// unquoteBackslashes removes backslashes the way the shell does during quote
// removal. Inside double quotes only \$, \`, \", \\, and an escaped newline
// lose their backslash; unquoted, a backslash escapes any following character.
func unquoteBackslashes(s string, inDblQuotes bool) string {
if !strings.Contains(s, "\\") {
return s
}
var sb strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) {
next := s[i+1]
if next == '\n' {
i++
continue
}
if !inDblQuotes || next == '$' || next == '`' || next == '"' || next == '\\' {
sb.WriteByte(next)
i++
continue
}
}
sb.WriteByte(s[i])
}
return sb.String()
}

View File

@@ -0,0 +1,107 @@
package shellwords
import (
"slices"
"testing"
)
func TestSplit(t *testing.T) {
cases := []struct {
name string
input string
want []string
wantErr bool
}{
{
name: "empty input",
input: "",
},
{
name: "plain words",
input: "python -m http.server 5000",
want: []string{"python", "-m", "http.server", "5000"},
},
{
name: "double quoted word",
input: `sh -c "echo hi"`,
want: []string{"sh", "-c", "echo hi"},
},
{
name: "single quoted word",
input: `sh -c 'echo hi'`,
want: []string{"sh", "-c", "echo hi"},
},
{
name: "escaped quotes inside double quotes",
input: `sh -c "echo \"hi\""`,
want: []string{"sh", "-c", `echo "hi"`},
},
{
name: "escaped single quote outside quotes",
input: `echo it\'s`,
want: []string{"echo", "it's"},
},
{
name: "parameter expansion kept verbatim",
input: `sh -c "echo $PORT"`,
want: []string{"sh", "-c", "echo $PORT"},
},
{
name: "command substitution kept verbatim",
input: `echo $(date)`,
want: []string{"echo", "$(date)"},
},
{
name: "backticks kept verbatim",
input: "echo `date`",
want: []string{"echo", "`date`"},
},
{
name: "escaped backticks inside double quotes",
input: "echo \"\\`date\\`\"",
want: []string{"echo", "`date`"},
},
{
name: "unterminated quote",
input: `sh -c "echo hi`,
wantErr: true,
},
{
name: "bare operator",
input: `a && b`,
wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := Split(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("Split(%q) expected error, got %q", tc.input, got)
}
return
}
if err != nil {
t.Fatalf("Split(%q) unexpected error: %v", tc.input, err)
}
if !slices.Equal(got, tc.want) {
t.Fatalf("Split(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
func TestSplitWithEndReportsSkippedComment(t *testing.T) {
input := "a # comment"
fields, end, err := SplitWithEnd(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !slices.Equal(fields, []string{"a"}) {
t.Fatalf("fields = %q, want [a]", fields)
}
if end != 1 {
t.Fatalf("end = %d, want 1", end)
}
}

View File

@@ -5,6 +5,7 @@ import (
"os"
"github.com/dokku/dokku/plugins/common"
"github.com/dokku/dokku/plugins/common/shellwords"
flag "github.com/spf13/pflag"
)
@@ -92,6 +93,16 @@ func main() {
case "is-valid-app-name-old":
appName := flag.Arg(1)
err = common.IsValidAppNameOld(appName)
case "split-command":
var fields []string
fields, err = shellwords.Split(flag.Arg(1))
if err != nil {
err = fmt.Errorf("Unable to parse command %q: %w", flag.Arg(1), err)
break
}
for _, field := range fields {
fmt.Printf("%s\x00", field)
}
case "verify-app-name":
appName := flag.Arg(1)
err = common.VerifyAppName(appName)

View File

@@ -7,7 +7,7 @@ import (
"strings"
"github.com/dokku/dokku/plugins/common"
"mvdan.cc/sh/v3/syntax"
"github.com/dokku/dokku/plugins/common/shellwords"
)
// DefaultProcessType is the sentinel process-type key used for options that
@@ -32,7 +32,7 @@ func SplitOptionString(input string) (options []string, processes []string, err
return nil, nil, nil
}
fields, err := literalFields(input)
fields, err := shellwords.Split(input)
if err != nil {
return nil, nil, fmt.Errorf("Unable to parse docker option: %s", err.Error())
}
@@ -93,7 +93,7 @@ func groupOptionTokens(fields []string) [][]string {
// stops before consuming all of it - a line whose tail begins with an unquoted
// `#` reads as a shell comment, and dropping it would lose stored data.
func canonicalOptionsFromLine(line string) []string {
fields, end, err := literalFieldsWithEnd(line)
fields, end, err := shellwords.SplitWithEnd(line)
if err != nil || end < len(strings.TrimRight(line, " \t")) {
return []string{line}
}
@@ -118,12 +118,12 @@ func optionsEqual(stored string, option string) bool {
return true
}
storedFields, err := literalFields(stored)
storedFields, err := shellwords.Split(stored)
if err != nil {
return false
}
optionFields, err := literalFields(option)
optionFields, err := shellwords.Split(option)
if err != nil {
return false
}
@@ -131,90 +131,6 @@ func optionsEqual(stored string, option string) bool {
return slices.Equal(storedFields, optionFields)
}
// literalFields splits input into shell words using the parser directly, so
// quotes delimit words and are stripped from the stored value, but nothing is
// expanded. Parameter expansions, command substitutions, and other
// metacharacters are preserved verbatim. Malformed input, such as an unbalanced
// quote, returns the parser error.
func literalFields(input string) ([]string, error) {
fields, _, err := literalFieldsWithEnd(input)
return fields, err
}
// literalFieldsWithEnd is literalFields plus the byte offset just past the last
// word the parser consumed. Callers that must not lose data compare the offset
// against the length of the input to detect a tail the parser skipped, such as
// a shell comment.
func literalFieldsWithEnd(input string) ([]string, int, error) {
parser := syntax.NewParser()
var fields []string
end := 0
for word, err := range parser.WordsSeq(strings.NewReader(input)) {
if err != nil {
return nil, 0, err
}
fields = append(fields, literalWordValue(input, word.Parts))
end = int(word.End().Offset())
}
return fields, end, nil
}
// literalWordValue reconstructs the unquoted, unexpanded value of a shell word.
// Literal and single-quoted parts contribute their literal text; double-quoted
// parts have their surrounding quotes dropped while their contents stay
// literal; every other part - parameter expansions, command substitutions,
// arithmetic expansions - contributes its original source text unchanged.
// Backslash escapes are removed the same way the shell removes them during
// quote removal so a value such as `Host(\`app\`)` round-trips to `Host(`app`)`.
func literalWordValue(input string, parts []syntax.WordPart) string {
var sb strings.Builder
for _, part := range parts {
switch p := part.(type) {
case *syntax.Lit:
sb.WriteString(unquoteBackslashes(p.Value, false))
case *syntax.SglQuoted:
sb.WriteString(p.Value)
case *syntax.DblQuoted:
for _, inner := range p.Parts {
if lit, ok := inner.(*syntax.Lit); ok {
sb.WriteString(unquoteBackslashes(lit.Value, true))
continue
}
sb.WriteString(input[inner.Pos().Offset():inner.End().Offset()])
}
default:
sb.WriteString(input[part.Pos().Offset():part.End().Offset()])
}
}
return sb.String()
}
// unquoteBackslashes removes backslashes the way the shell does during quote
// removal. Inside double quotes only \$, \`, \", \\, and an escaped newline
// lose their backslash; unquoted, a backslash escapes any following character.
func unquoteBackslashes(s string, inDblQuotes bool) string {
if !strings.Contains(s, "\\") {
return s
}
var sb strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) {
next := s[i+1]
if next == '\n' {
i++
continue
}
if !inDblQuotes || next == '$' || next == '`' || next == '"' || next == '\\' {
sb.WriteByte(next)
i++
continue
}
}
sb.WriteByte(s[i])
}
return sb.String()
}
// isFlagToken reports whether tok looks like a CLI flag (long or short) rather
// than a value. Treating any token that begins with `-` and has more than one
// character as a flag matches docker's flag conventions and avoids the need

View File

@@ -5,7 +5,6 @@ go 1.26.2
require (
github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000
github.com/spf13/pflag v1.0.10
mvdan.cc/sh/v3 v3.14.1
)
require (
@@ -25,6 +24,7 @@ require (
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
mvdan.cc/sh/v3 v3.14.1 // indirect
)
replace github.com/dokku/dokku/plugins/common => ../common

View File

@@ -65,12 +65,15 @@ main() {
START_CMD=$(fn-scheduler-docker-local-extract-start-cmd "$APP" "$PROC_TYPE" "$START_CMD" "$DOKKU_HEROKUISH" "$DOKKU_PORT")
DOCKER_ARGS+=" $IMAGE"
DOCKER_ARGS+=" $START_CMD"
fn-split-command "$START_CMD" >/dev/null
declare -a ARG_ARRAY=()
while IFS= read -r -d '' arg; do
ARG_ARRAY+=("$arg")
done < <(fn-docker-args-split "$DOCKER_ARGS")
while IFS= read -r -d '' arg; do
ARG_ARRAY+=("$arg")
done < <(fn-split-command "$START_CMD")
cid=$(fn-scheduler-docker-local-start-app-container "$APP" "${ARG_ARRAY[@]}")
plugn trigger post-container-create "app" "$cid" "$APP" "deploy" "$PROC_TYPE"

View File

@@ -111,7 +111,11 @@ trigger-scheduler-docker-local-scheduler-run() {
if [[ -n "$PROC_CMD" ]]; then
dokku_log_info1_quiet "Found '$POTENTIAL_PROCFILE_KEY' in Procfile, running that command"
DOCKER_ARGS+=" --label=com.dokku.process-type=$POTENTIAL_PROCFILE_KEY"
read -ra proc_array <<<"$PROC_CMD"
fn-split-command "$PROC_CMD" >/dev/null
local -a proc_array=()
while IFS= read -r -d '' arg; do
proc_array+=("$arg")
done < <(fn-split-command "$PROC_CMD")
set -- "${proc_array[@]}" "${@:2}"
fi
fi

View File

@@ -483,3 +483,71 @@ teardown() {
echo "status: $status"
assert_output "running"
}
@test "(scheduler-docker-local) procfile quoted arguments on deploy" {
run create_app
echo "output: $output"
echo "status: $status"
assert_success
run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP quoted_procfile_callback
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "docker inspect --format '{{json .Config.Cmd}}' $TEST_APP.web.1"
echo "output: $output"
echo "status: $status"
assert_success
assert_output '["python3","-u","web.py","double quoted","single quoted","escaped \"quote\"","literal`tick`","5000"]'
}
@test "(scheduler-docker-local) procfile quoted arguments on run" {
run create_app
echo "output: $output"
echo "status: $status"
assert_success
run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP quoted_procfile_callback
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku run $TEST_APP args"
echo "output: $output"
echo "status: $status"
assert_success
assert_output_contains "['double quoted', 'single quoted', 'escaped \"quote\"', 'literal\`tick\`', '5000']"
}
@test "(scheduler-docker-local) procfile with shell operators fails deploy" {
run create_app
echo "output: $output"
echo "status: $status"
assert_success
run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP operator_procfile_callback
echo "output: $output"
echo "status: $status"
assert_failure
assert_output_contains "Unable to parse command"
}
quoted_procfile_callback() {
local APP="$1"
local APP_REPO_DIR="$2"
mv "$APP_REPO_DIR/dockerfile.Dockerfile" "$APP_REPO_DIR/Dockerfile"
cat >"$APP_REPO_DIR/Procfile" <<'PROCFILE'
web: python3 -u web.py "double quoted" 'single quoted' "escaped \"quote\"" literal\`tick\` "$PORT"
args: python3 -c "import sys; print(sys.argv[1:])" "double quoted" 'single quoted' "escaped \"quote\"" literal\`tick\` "$PORT"
PROCFILE
}
operator_procfile_callback() {
local APP="$1"
local APP_REPO_DIR="$2"
mv "$APP_REPO_DIR/dockerfile.Dockerfile" "$APP_REPO_DIR/Dockerfile"
cat >"$APP_REPO_DIR/Procfile" <<'PROCFILE'
web: python3 -u web.py && echo done
PROCFILE
}