Files
dokku/plugins/docker-options/dockeroptions.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

432 lines
14 KiB
Go

package dockeroptions
import (
"fmt"
"sort"
"strings"
"github.com/dokku/dokku/plugins/common"
"mvdan.cc/sh/v3/syntax"
)
// DefaultProcessType is the sentinel process-type key used for options that
// apply to every container in an app (i.e. options not scoped to a specific
// Procfile process type).
const DefaultProcessType = "_default_"
// SplitOptionString shell-tokenizes input, groups tokens on flag boundaries,
// and returns one re-serialized option per group. Tokenization honors quotes
// for word boundaries but performs no expansion, so parameter expansions,
// command substitutions, and other shell metacharacters are stored verbatim
// and passed through to the container as written (the bash scheduler splits
// them the same way, without expansion). A docker-options subcommand flag
// (currently just --process) that lands inside the option content - because
// the user typed it after the app name, where pflag's SetInterspersed(false)
// hands it back as positional - is lifted into the returned processes slice
// rather than stored as a docker option. The caller merges those processes
// with whatever pflag already captured. Empty or whitespace-only input returns
// empty slices.
func SplitOptionString(input string) (options []string, processes []string, err error) {
if strings.TrimSpace(input) == "" {
return nil, nil, nil
}
fields, err := literalFields(input)
if err != nil {
return nil, nil, fmt.Errorf("Unable to parse docker option: %s", err.Error())
}
var current []string
flush := func() error {
if len(current) == 0 {
return nil
}
head := current[0]
if head == "--process" {
if len(current) < 2 {
return fmt.Errorf("--process requires a value")
}
if len(current) > 2 {
return fmt.Errorf("--process accepts a single value, got %d", len(current)-1)
}
processes = append(processes, current[1])
current = nil
return nil
}
if strings.HasPrefix(head, "--process=") {
if len(current) > 1 {
return fmt.Errorf("--process=value cannot be followed by additional tokens")
}
processes = append(processes, head[len("--process="):])
current = nil
return nil
}
options = append(options, joinShellTokens(current))
current = nil
return nil
}
for _, tok := range fields {
if isFlagToken(tok) && len(current) > 0 {
if err := flush(); err != nil {
return nil, nil, err
}
}
current = append(current, tok)
}
if err := flush(); err != nil {
return nil, nil, err
}
return options, processes, nil
}
// 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) {
parser := syntax.NewParser()
var fields []string
for word, err := range parser.WordsSeq(strings.NewReader(input)) {
if err != nil {
return nil, err
}
fields = append(fields, literalWordValue(input, word.Parts))
}
return fields, 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
// for a per-flag whitelist.
func isFlagToken(tok string) bool {
return len(tok) > 1 && tok[0] == '-'
}
// joinShellTokens joins tokens into a single string suitable for storage,
// shell-quoting any token that contains characters the bash scheduler's
// `eval` re-tokenization would interpret. The stored line round-trips through
// `eval set -- "$line"` back to the original token slice.
func joinShellTokens(tokens []string) string {
parts := make([]string, len(tokens))
for i, tok := range tokens {
parts[i] = quoteShellArg(tok)
}
return strings.Join(parts, " ")
}
// quoteShellArg returns s wrapped in single quotes when it contains characters
// the shell would otherwise interpret (whitespace, quotes, expansion sigils,
// globs, redirections, etc.). Embedded single quotes are escaped with the
// standard `'\''` close-escape-open sequence. Tokens free of such characters
// are returned verbatim so the stored representation stays human-readable for
// the common case.
func quoteShellArg(s string) string {
if s == "" {
return "''"
}
if !needsShellQuoting(s) {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func needsShellQuoting(s string) bool {
for _, r := range s {
switch r {
case ' ', '\t', '\n', '"', '\'', '$', '`', '\\',
'*', '?', '[', ']', '<', '>', '|', '&', ';',
'(', ')', '{', '}', '!', '#', '~':
return true
}
}
return false
}
func propertyKey(processType, phase string) string {
if processType == "" {
processType = DefaultProcessType
}
return fmt.Sprintf("%s.%s", processType, phase)
}
// SetDockerOptionForPhases sets a `--name=value` option in the default scope
// for the specified phases, replacing any existing entry with the same name.
func SetDockerOptionForPhases(appName string, phases []string, name string, value string) error {
return SetDockerOptionForProcessPhases(appName, []string{DefaultProcessType}, phases, name, value)
}
// SetDockerOptionForProcessPhases sets a `--name=value` option for the specified
// process types and phases, replacing any existing entry with the same name.
func SetDockerOptionForProcessPhases(appName string, processTypes []string, phases []string, name string, value string) error {
if len(processTypes) == 0 {
processTypes = []string{DefaultProcessType}
}
for _, processType := range processTypes {
for _, phase := range phases {
options, err := GetDockerOptionsForProcessPhase(appName, processType, phase)
if err != nil {
return err
}
newOptions := []string{}
for _, option := range options {
if strings.HasPrefix(option, fmt.Sprintf("--%s=", name)) {
continue
}
newOptions = append(newOptions, option)
}
newOptions = append(newOptions, fmt.Sprintf("--%s=%s", name, value))
sort.Strings(newOptions)
if err := writeDockerOptionsForProcessPhase(appName, processType, phase, newOptions); err != nil {
return err
}
}
}
return nil
}
// AddDockerOptionToPhases adds an option to the default scope for the specified phases.
func AddDockerOptionToPhases(appName string, phases []string, option string) error {
return AddDockerOptionToProcessPhases(appName, []string{DefaultProcessType}, phases, option)
}
// AddDockerOptionToProcessPhases adds an option to the specified process types and phases.
func AddDockerOptionToProcessPhases(appName string, processTypes []string, phases []string, option string) error {
if len(processTypes) == 0 {
processTypes = []string{DefaultProcessType}
}
for _, processType := range processTypes {
for _, phase := range phases {
options, err := GetDockerOptionsForProcessPhase(appName, processType, phase)
if err != nil {
return err
}
options = append(options, option)
sort.Strings(options)
if err := writeDockerOptionsForProcessPhase(appName, processType, phase, options); err != nil {
return err
}
}
}
return nil
}
// GetDockerOptionsForPhase returns the docker options stored under the default
// scope for the specified phase.
func GetDockerOptionsForPhase(appName string, phase string) ([]string, error) {
return GetDockerOptionsForProcessPhase(appName, DefaultProcessType, phase)
}
// GetDockerOptionsForProcessPhase returns the docker options stored under the
// given process-type scope for the specified phase. An empty processType is
// treated as the default scope.
func GetDockerOptionsForProcessPhase(appName, processType, phase string) ([]string, error) {
options, err := common.PropertyListGet("docker-options", appName, propertyKey(processType, phase))
if err != nil {
return nil, fmt.Errorf("Unable to read docker options for %s.%s.%s: %s", appName, processType, phase, err.Error())
}
trimmed := make([]string, 0, len(options))
for _, option := range options {
option = strings.TrimSpace(option)
if option == "" {
continue
}
trimmed = append(trimmed, option)
}
return trimmed, nil
}
// RemoveDockerOptionFromPhases removes an option from the default scope for the specified phases.
func RemoveDockerOptionFromPhases(appName string, phases []string, option string) error {
return RemoveDockerOptionFromProcessPhases(appName, []string{DefaultProcessType}, phases, option)
}
// RemoveDockerOptionFromProcessPhases removes an option from the specified process types and phases.
func RemoveDockerOptionFromProcessPhases(appName string, processTypes []string, phases []string, option string) error {
if len(processTypes) == 0 {
processTypes = []string{DefaultProcessType}
}
for _, processType := range processTypes {
for _, phase := range phases {
options, err := GetDockerOptionsForProcessPhase(appName, processType, phase)
if err != nil {
return err
}
newOptions := []string{}
for _, opt := range options {
if opt != option {
newOptions = append(newOptions, opt)
}
}
sort.Strings(newOptions)
if err := writeDockerOptionsForProcessPhase(appName, processType, phase, newOptions); err != nil {
return err
}
}
}
return nil
}
// GetSpecifiedDockerOptionsForPhase returns the docker options for the specified
// phase (default scope) that are in the desiredOptions list. It expects
// desiredOptions entries in the form "--option" and matches against options
// stored as "--option", "--option=value", or "--option value".
func GetSpecifiedDockerOptionsForPhase(appName string, phase string, desiredOptions []string) (map[string][]string, error) {
foundOptions := map[string][]string{}
options, err := GetDockerOptionsForPhase(appName, phase)
if err != nil {
return foundOptions, err
}
for _, option := range options {
for _, desiredOption := range desiredOptions {
if option == desiredOption {
foundOptions[desiredOption] = []string{}
break
}
if strings.HasPrefix(option, fmt.Sprintf("%s=", desiredOption)) {
if _, ok := foundOptions[desiredOption]; !ok {
foundOptions[desiredOption] = []string{}
}
parts := strings.SplitN(option, "=", 2)
if len(parts) != 2 {
common.LogWarn(fmt.Sprintf("Invalid docker option found for %s: %s", appName, option))
continue
}
foundOptions[desiredOption] = append(foundOptions[desiredOption], parts[1])
break
}
if strings.HasPrefix(option, fmt.Sprintf("%s ", desiredOption)) {
if _, ok := foundOptions[desiredOption]; !ok {
foundOptions[desiredOption] = []string{}
}
parts := strings.SplitN(option, " ", 2)
if len(parts) != 2 {
common.LogWarn(fmt.Sprintf("Invalid docker option found for %s: %s", appName, option))
continue
}
foundOptions[desiredOption] = append(foundOptions[desiredOption], parts[1])
break
}
}
}
return foundOptions, nil
}
// ListProcessTypesWithOptions returns the sorted list of process types that
// have at least one option configured, excluding DefaultProcessType.
func ListProcessTypesWithOptions(appName string) ([]string, error) {
properties, err := common.PropertyGetAll("docker-options", appName)
if err != nil {
return nil, err
}
seen := map[string]bool{}
for key := range properties {
processType, _, ok := splitPropertyKey(key)
if !ok {
continue
}
if processType == DefaultProcessType {
continue
}
seen[processType] = true
}
processTypes := make([]string, 0, len(seen))
for processType := range seen {
processTypes = append(processTypes, processType)
}
sort.Strings(processTypes)
return processTypes, nil
}
func splitPropertyKey(key string) (processType, phase string, ok bool) {
idx := strings.LastIndex(key, ".")
if idx <= 0 || idx == len(key)-1 {
return "", "", false
}
processType = key[:idx]
phase = key[idx+1:]
if !isValidPhase(phase) {
return "", "", false
}
return processType, phase, true
}
func writeDockerOptionsForProcessPhase(appName, processType, phase string, options []string) error {
return common.PropertyListWrite("docker-options", appName, propertyKey(processType, phase), options)
}