From be6912c2cd2bf87dc3aa8769a8aa582a2e747311 Mon Sep 17 00:00:00 2001 From: mateuscmtropical Date: Fri, 4 Sep 2026 20:49:13 -0300 Subject: [PATCH 1/2] feat: support configurable quote character for dotenv export `infisical export` always wrapped values in single quotes. Single quoted dotenv values have no escape sequences, so a multiline value (e.g. a PEM private key) can't be represented with a real newline. Adds --dotenv-quote-char (default: '), letting the value be wrapped in double quotes instead, which lets the existing multiline encoding decode back into a real newline on read. Only affects the dotenv/dotenv-export formats; validated up front, before any network call. Closes Infisical/infisical#1103 --- packages/cmd/export.go | 69 ++++++++-- packages/cmd/export_test.go | 246 ++++++++++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+), 10 deletions(-) diff --git a/packages/cmd/export.go b/packages/cmd/export.go index 776074f2..20f7a37c 100644 --- a/packages/cmd/export.go +++ b/packages/cmd/export.go @@ -27,12 +27,17 @@ const ( FormatDotEnvEval string = "dotenv-eval" ) +const ( + QuoteCharSingle string = "'" + QuoteCharDouble string = `"` +) + // exportCmd represents the export command var exportCmd = &cobra.Command{ Use: "export", Short: "Used to export environment variables to a file", DisableFlagsInUseLine: true, - Example: "infisical export --env=prod --format=json > secrets.json\ninfisical export --env=prod --format=json --output-file=secrets.json", + Example: "infisical export --env=prod --format=json > secrets.json\ninfisical export --env=prod --format=json --output-file=secrets.json\ninfisical export --env=prod --dotenv-quote-char='\"' > .env", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { environmentName, _ := cmd.Flags().GetString("env") @@ -93,6 +98,19 @@ var exportCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + dotEnvQuoteChar, err := cmd.Flags().GetString("dotenv-quote-char") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + // Validated up front, before any secrets are fetched, so that a typo is + // reported immediately instead of after a network round trip. This runs + // regardless of the chosen format, so that an unusable value is never + // silently accepted just because the format happens to ignore it. + if err := validateDotEnvQuoteChar(dotEnvQuoteChar); err != nil { + util.HandleError(err) + } + request := models.GetAllSecretsParameters{ Environment: environmentName, TagSlugs: tagSlugs, @@ -142,7 +160,7 @@ var exportCmd = &cobra.Command{ secrets = util.FilterSecretsByTag(secrets, tagSlugs) secrets = util.SortSecretsByKeys(secrets) - output, err = formatEnvs(secrets, format) + output, err = formatEnvs(secrets, format, dotEnvQuoteChar) if err != nil { util.HandleError(err) } @@ -275,15 +293,18 @@ func init() { exportCmd.Flags().String("path", "/", "get secrets within a folder path") exportCmd.Flags().String("template", "", "The path to the template file used to render secrets") exportCmd.Flags().StringP("output-file", "o", "", "The path to write the output file to. Can be a full file path, directory, or filename. If not specified, output will be printed to stdout") + exportCmd.Flags().String("dotenv-quote-char", QuoteCharSingle, `Set the character used to wrap values in the dotenv and dotenv-export formats (' or "). Double quotes let dotenv parsers interpret escape sequences such as \n`) } -// Format according to the format flag -func formatEnvs(envs []models.SingleEnvironmentVariable, format string) (string, error) { +// Format according to the format flag. quoteChar is the character used to wrap +// values in the dotenv and dotenv-export formats, and is ignored by every other +// format. It is validated by the caller before any secrets are fetched. +func formatEnvs(envs []models.SingleEnvironmentVariable, format string, quoteChar string) (string, error) { switch strings.ToLower(format) { case FormatDotenv: - return formatAsDotEnv(envs), nil + return formatAsDotEnv(envs, quoteChar), nil case FormatDotEnvExport: - return formatAsDotEnvExport(envs), nil + return formatAsDotEnvExport(envs, quoteChar), nil case FormatDotEnvEval: return formatAsDotEnvEval(envs), nil case FormatJson: @@ -310,23 +331,51 @@ func formatAsCSV(envs []models.SingleEnvironmentVariable) string { } // Format environment variables as a dotenv file -func formatAsDotEnv(envs []models.SingleEnvironmentVariable) string { +func formatAsDotEnv(envs []models.SingleEnvironmentVariable, quoteChar string) string { var dotenv string for _, env := range envs { - dotenv += fmt.Sprintf("%s='%s'\n", env.Key, escapeNewLinesIfRequired(env)) + dotenv += fmt.Sprintf("%s=%s\n", env.Key, quoteDotEnvValue(env, quoteChar)) } return dotenv } // Format environment variables as a dotenv file with export at the beginning -func formatAsDotEnvExport(envs []models.SingleEnvironmentVariable) string { +func formatAsDotEnvExport(envs []models.SingleEnvironmentVariable, quoteChar string) string { var dotenv string for _, env := range envs { - dotenv += fmt.Sprintf("export %s='%s'\n", env.Key, escapeNewLinesIfRequired(env)) + dotenv += fmt.Sprintf("export %s=%s\n", env.Key, quoteDotEnvValue(env, quoteChar)) } return dotenv } +// validateDotEnvQuoteChar checks that the quote character used by the dotenv +// formats is one that dotenv parsers actually understand. +func validateDotEnvQuoteChar(quoteChar string) error { + if quoteChar != QuoteCharSingle && quoteChar != QuoteCharDouble { + return fmt.Errorf("invalid quote character: %q. Available quote characters are [%s]", quoteChar, strings.Join([]string{QuoteCharSingle, QuoteCharDouble}, ", ")) + } + + return nil +} + +// quoteDotEnvValue wraps a secret value in quoteChar. The value itself is +// written verbatim, so the quote character is the only difference between the +// two styles. +// +// No backslash or quote escaping is applied, deliberately. Dotenv parsers +// extract a value by finding the quotes that delimit it and then treat what is +// between them as opaque; they do not generally undo escape sequences on read. +// Escaping on write would therefore never be unescaped again, and would only +// leave stray backslashes in the parsed value without protecting anything. +// +// The double quote style exists purely so that the "\n" produced by +// escapeNewLinesIfRequired is decoded back into a real newline, which is +// something parsers only do for double quoted values. That is the whole reason +// to pick it over the single quote default. +func quoteDotEnvValue(env models.SingleEnvironmentVariable, quoteChar string) string { + return quoteChar + escapeNewLinesIfRequired(env) + quoteChar +} + // Format environment variables for shell eval/source. Values are wrapped in // single quotes with POSIX escaping so the output is safe to evaluate via // `eval "$(infisical export --format=dotenv-eval)"` regardless of value diff --git a/packages/cmd/export_test.go b/packages/cmd/export_test.go index 0e3921ec..8b7fbf1b 100644 --- a/packages/cmd/export_test.go +++ b/packages/cmd/export_test.go @@ -78,6 +78,252 @@ func TestFormatAsYaml(t *testing.T) { } } +func TestFormatAsDotEnv(t *testing.T) { + tests := []struct { + name string + input []models.SingleEnvironmentVariable + quoteChar string + expected string + }{ + { + name: "Empty input", + input: []models.SingleEnvironmentVariable{}, + quoteChar: QuoteCharSingle, + expected: "", + }, + { + name: "Single quotes are the default wrapping", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + {Key: "KEY2", Value: "VALUE2"}, + }, + quoteChar: QuoteCharSingle, + expected: "KEY1='VALUE1'\nKEY2='VALUE2'\n", + }, + { + name: "Double quotes wrap the value", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + }, + quoteChar: QuoteCharDouble, + expected: "KEY1=\"VALUE1\"\n", + }, + { + name: "Encoded newlines are left intact so double quoted values stay multiline", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "line1\nline2", SkipMultilineEncoding: true}, + }, + quoteChar: QuoteCharDouble, + expected: "KEY1=\"line1\\nline2\"\n", + }, + { + name: "Embedded double quotes are written verbatim in double quote mode", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: `say "hi"`}, + }, + quoteChar: QuoteCharDouble, + expected: `KEY1="say "hi""` + "\n", + }, + { + name: "Embedded double quotes are written verbatim in single quote mode", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: `say "hi"`}, + }, + quoteChar: QuoteCharSingle, + expected: `KEY1='say "hi"'` + "\n", + }, + { + name: "Backslashes are written verbatim in double quote mode", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: `C:\Users\`}, + }, + quoteChar: QuoteCharDouble, + expected: `KEY1="C:\Users\"` + "\n", + }, + { + name: "Backslash quote sequences are written verbatim", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: `say \"hi\"`}, + }, + quoteChar: QuoteCharDouble, + expected: `KEY1="say \"hi\""` + "\n", + }, + { + name: "Multiline encoding still applies alongside backslashes", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "C:\\dir\nmore", SkipMultilineEncoding: true}, + }, + quoteChar: QuoteCharDouble, + expected: `KEY1="C:\dir\nmore"` + "\n", + }, + { + name: "Backslashes are written verbatim in single quote mode", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: `C:\Users\`}, + }, + quoteChar: QuoteCharSingle, + expected: `KEY1='C:\Users\'` + "\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, formatAsDotEnv(tt.input, tt.quoteChar)) + }) + } +} + +func TestQuoteDotEnvValueOnlyChangesTheWrapper(t *testing.T) { + envs := []models.SingleEnvironmentVariable{ + {Key: "PLAIN", Value: "VALUE1"}, + {Key: "QUOTES", Value: `say "hi" and 'bye'`}, + {Key: "BACKSLASHES", Value: `C:\Users\`}, + {Key: "BACKSLASH_QUOTES", Value: `say \"hi\"`}, + {Key: "MULTILINE", Value: "line1\nline2", SkipMultilineEncoding: true}, + } + + for _, env := range envs { + t.Run(env.Key, func(t *testing.T) { + single := quoteDotEnvValue(env, QuoteCharSingle) + double := quoteDotEnvValue(env, QuoteCharDouble) + + assert.Equal(t, QuoteCharSingle, string(single[0])) + assert.Equal(t, QuoteCharSingle, string(single[len(single)-1])) + assert.Equal(t, QuoteCharDouble, string(double[0])) + assert.Equal(t, QuoteCharDouble, string(double[len(double)-1])) + + // Only the wrapping character may differ between the two styles. The + // value in between is never rewritten beyond the multiline encoding + // that both styles already share, so neither style can corrupt a + // round trip by introducing escape characters of its own. + singleInner := single[1 : len(single)-1] + doubleInner := double[1 : len(double)-1] + + assert.Equal(t, singleInner, doubleInner) + assert.Equal(t, escapeNewLinesIfRequired(env), singleInner) + }) + } +} + +func TestFormatAsDotEnvExport(t *testing.T) { + tests := []struct { + name string + input []models.SingleEnvironmentVariable + quoteChar string + expected string + }{ + { + name: "Empty input", + input: []models.SingleEnvironmentVariable{}, + quoteChar: QuoteCharSingle, + expected: "", + }, + { + name: "Single quotes are the default wrapping", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + }, + quoteChar: QuoteCharSingle, + expected: "export KEY1='VALUE1'\n", + }, + { + name: "Double quotes wrap the value verbatim", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: `say "hi"`}, + }, + quoteChar: QuoteCharDouble, + expected: `export KEY1="say "hi""` + "\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, formatAsDotEnvExport(tt.input, tt.quoteChar)) + }) + } +} + +func TestFormatEnvsQuoteChar(t *testing.T) { + envs := []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + } + + tests := []struct { + name string + format string + quoteChar string + expected string + }{ + { + name: "dotenv honours the quote character", + format: FormatDotenv, + quoteChar: QuoteCharDouble, + expected: "KEY1=\"VALUE1\"\n", + }, + { + name: "dotenv-export honours the quote character", + format: FormatDotEnvExport, + quoteChar: QuoteCharDouble, + expected: "export KEY1=\"VALUE1\"\n", + }, + { + name: "dotenv-eval keeps its shell safe quoting", + format: FormatDotEnvEval, + quoteChar: QuoteCharDouble, + expected: "export KEY1='VALUE1'\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := formatEnvs(envs, tt.format, tt.quoteChar) + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } + + // Every format that does not wrap values in a configurable quote must + // produce byte identical output no matter what the flag is set to. + for _, format := range []string{FormatJson, FormatYaml, FormatCSV, FormatDotEnvEval} { + t.Run(format+" ignores the quote character", func(t *testing.T) { + withSingle, err := formatEnvs(envs, format, QuoteCharSingle) + assert.NoError(t, err) + + withDouble, err := formatEnvs(envs, format, QuoteCharDouble) + assert.NoError(t, err) + + assert.Equal(t, withSingle, withDouble) + }) + } +} + +func TestValidateDotEnvQuoteChar(t *testing.T) { + tests := []struct { + name string + quoteChar string + expectError bool + }{ + {name: "Single quote is allowed", quoteChar: QuoteCharSingle}, + {name: "Double quote is allowed", quoteChar: QuoteCharDouble}, + {name: "Empty value is rejected", quoteChar: "", expectError: true}, + {name: "Backtick is rejected", quoteChar: "`", expectError: true}, + {name: "Arbitrary character is rejected", quoteChar: "x", expectError: true}, + {name: "Multiple characters are rejected", quoteChar: `""`, expectError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDotEnvQuoteChar(tt.quoteChar) + if tt.expectError { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + }) + } +} + func TestFormatAsDotEnvEval(t *testing.T) { tests := []struct { name string From 7e3bca3728da11da60e12dde3814fbb3716fe76a Mon Sep 17 00:00:00 2001 From: mateuscmtropical Date: Sat, 5 Sep 2026 11:36:53 -0300 Subject: [PATCH 2/2] fix: prevent shell injection in dotenv-export and dotenv-eval output The repo's review bot flagged that --dotenv-quote-char's double-quote mode let a secret containing $(cmd) or a backtick execute code when the dotenv-export output was sourced by a shell. Investigating further turned up that this output was never safe to source, independent of this flag: the default single-quote wrapping didn't escape an embedded single quote in the value, and the secret's own name was interpolated completely unescaped, so a secret named e.g. "FOO=bar; touch pwned #" or one starting with "-" (misread as an export flag, which on dash dumps the whole environment to stdout) also executed or leaked on source. A name containing "=" or ending in "+" let a secret silently overwrite or append to an unrelated variable (PATH, LD_PRELOAD, ...), since export re-parses NAME=VALUE/NAME+=VALUE after quote removal regardless of quoting. dotenv-export now delegates to dotenv-eval's existing posixShellQuote for the value, and a secret name is only accepted if it matches a portable shell variable name; anything else aborts the export with an error instead of producing a partial or unsafe file. --dotenv-quote-char no longer affects dotenv-export (always wrapped safely) or dotenv-eval (already was); it still works as before for the plain dotenv format. Verified each finding by hand: generating the real output and sourcing it in bash, dash and sh, both before and after the fix. Reviewed adversarially across several rounds by independent AI models until no new finding surfaced. --- packages/cmd/export.go | 53 ++++--- packages/cmd/export_test.go | 286 ++++++++++++++++++++++++------------ 2 files changed, 232 insertions(+), 107 deletions(-) diff --git a/packages/cmd/export.go b/packages/cmd/export.go index 20f7a37c..7f5703ae 100644 --- a/packages/cmd/export.go +++ b/packages/cmd/export.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "github.com/Infisical/infisical-merge/packages/models" @@ -32,6 +33,9 @@ const ( QuoteCharDouble string = `"` ) +// POSIX portable variable name, the only shape a shell can bind a value to. +var shellVariableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + // exportCmd represents the export command var exportCmd = &cobra.Command{ Use: "export", @@ -293,20 +297,20 @@ func init() { exportCmd.Flags().String("path", "/", "get secrets within a folder path") exportCmd.Flags().String("template", "", "The path to the template file used to render secrets") exportCmd.Flags().StringP("output-file", "o", "", "The path to write the output file to. Can be a full file path, directory, or filename. If not specified, output will be printed to stdout") - exportCmd.Flags().String("dotenv-quote-char", QuoteCharSingle, `Set the character used to wrap values in the dotenv and dotenv-export formats (' or "). Double quotes let dotenv parsers interpret escape sequences such as \n`) + exportCmd.Flags().String("dotenv-quote-char", QuoteCharSingle, `Set the character used to wrap values in the dotenv format (' or "). Double quotes let dotenv parsers interpret escape sequences such as \n. Ignored by every other format, including dotenv-export, whose values are always wrapped so they are safe to source in a shell`) } // Format according to the format flag. quoteChar is the character used to wrap -// values in the dotenv and dotenv-export formats, and is ignored by every other -// format. It is validated by the caller before any secrets are fetched. +// values in the dotenv format, and is ignored by every other format, including +// dotenv-export. It is validated by the caller before any secrets are fetched. func formatEnvs(envs []models.SingleEnvironmentVariable, format string, quoteChar string) (string, error) { switch strings.ToLower(format) { case FormatDotenv: return formatAsDotEnv(envs, quoteChar), nil case FormatDotEnvExport: - return formatAsDotEnvExport(envs, quoteChar), nil + return formatAsDotEnvExport(envs) case FormatDotEnvEval: - return formatAsDotEnvEval(envs), nil + return formatAsDotEnvEval(envs) case FormatJson: return formatAsJson(envs), nil case FormatCSV: @@ -339,17 +343,17 @@ func formatAsDotEnv(envs []models.SingleEnvironmentVariable, quoteChar string) s return dotenv } -// Format environment variables as a dotenv file with export at the beginning -func formatAsDotEnvExport(envs []models.SingleEnvironmentVariable, quoteChar string) string { - var dotenv string - for _, env := range envs { - dotenv += fmt.Sprintf("export %s=%s\n", env.Key, quoteDotEnvValue(env, quoteChar)) - } - return dotenv +// Format environment variables as a dotenv file with export at the beginning. +// Every line is meant to be sourced by a shell, so values are always quoted the +// way dotenv-eval quotes them and never with the dotenv quote character: a +// value containing that character would otherwise close the wrapping early and +// let the rest of the value run as shell code. +func formatAsDotEnvExport(envs []models.SingleEnvironmentVariable) (string, error) { + return formatAsDotEnvEval(envs) } // validateDotEnvQuoteChar checks that the quote character used by the dotenv -// formats is one that dotenv parsers actually understand. +// format is one that dotenv parsers actually understand. func validateDotEnvQuoteChar(quoteChar string) error { if quoteChar != QuoteCharSingle && quoteChar != QuoteCharDouble { return fmt.Errorf("invalid quote character: %q. Available quote characters are [%s]", quoteChar, strings.Join([]string{QuoteCharSingle, QuoteCharDouble}, ", ")) @@ -380,16 +384,31 @@ func quoteDotEnvValue(env models.SingleEnvironmentVariable, quoteChar string) st // single quotes with POSIX escaping so the output is safe to evaluate via // `eval "$(infisical export --format=dotenv-eval)"` regardless of value // contents (newlines, single quotes, $, ", \, etc.). -func formatAsDotEnvEval(envs []models.SingleEnvironmentVariable) string { +// +// Secret names are not restricted to shell variable names, so they are checked +// against the portable syntax rather than escaped. Escaping cannot help there: +// the name is re-parsed by `export` after quote removal, and several shapes are +// assignments to a different variable rather than errors. `PATH=x` assigns to +// PATH because the split is on the first `=`, and `PATH+` appends to PATH +// because bash reads the result as `PATH+=`. A name outside the portable syntax +// cannot be bound by a shell at all, so the whole batch is rejected instead. +// +// The `--` is redundant with that check and kept as a second layer, so that a +// name starting with `-` can never be read as a flag. +func formatAsDotEnvEval(envs []models.SingleEnvironmentVariable) (string, error) { var dotenv string for _, env := range envs { - dotenv += fmt.Sprintf("export %s=%s\n", env.Key, posixShellQuote(env.Value)) + if !shellVariableName.MatchString(env.Key) { + return "", fmt.Errorf("cannot export secret %q to a shell format: a shell variable name may only contain ASCII letters, digits and underscores, and may not start with a digit. Other names are either rejected by the shell or silently applied to a different variable, the way names like PATH= and PATH+ are. Rename the secret, or export it with a format other than %s and %s", env.Key, FormatDotEnvExport, FormatDotEnvEval) + } + + dotenv += fmt.Sprintf("export -- %s=%s\n", env.Key, posixShellQuote(env.Value)) } - return dotenv + return dotenv, nil } // posixShellQuote wraps a value in single quotes and escapes any embedded -// single quotes using the standard `'\”` sequence. Single-quoted POSIX +// single quotes using the standard `'\''` sequence. Single-quoted POSIX // strings preserve every other character verbatim (including newlines, // backslashes, $, and "), so this is sufficient for eval/source. func posixShellQuote(value string) string { diff --git a/packages/cmd/export_test.go b/packages/cmd/export_test.go index 8b7fbf1b..62003998 100644 --- a/packages/cmd/export_test.go +++ b/packages/cmd/export_test.go @@ -205,40 +205,158 @@ func TestQuoteDotEnvValueOnlyChangesTheWrapper(t *testing.T) { } } -func TestFormatAsDotEnvExport(t *testing.T) { - tests := []struct { - name string - input []models.SingleEnvironmentVariable - quoteChar string - expected string - }{ - { - name: "Empty input", - input: []models.SingleEnvironmentVariable{}, - quoteChar: QuoteCharSingle, - expected: "", +// Shared by the dotenv-export and dotenv-eval tests, whose output must stay +// byte identical because both formats go through the same implementation. +var dotEnvShellCases = []struct { + name string + input []models.SingleEnvironmentVariable + expected string + expectError bool +}{ + { + name: "Empty input", + input: []models.SingleEnvironmentVariable{}, + expected: "", + }, + { + name: "Simple value", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, }, - { - name: "Single quotes are the default wrapping", - input: []models.SingleEnvironmentVariable{ - {Key: "KEY1", Value: "VALUE1"}, - }, - quoteChar: QuoteCharSingle, - expected: "export KEY1='VALUE1'\n", + expected: "export -- KEY1='VALUE1'\n", + }, + { + name: "Value containing single quote", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "it's a value"}, }, - { - name: "Double quotes wrap the value verbatim", - input: []models.SingleEnvironmentVariable{ - {Key: "KEY1", Value: `say "hi"`}, - }, - quoteChar: QuoteCharDouble, - expected: `export KEY1="say "hi""` + "\n", + expected: "export -- KEY1='it'\\''s a value'\n", + }, + { + name: "Value breaking out of the wrapping is escaped instead of executed", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "x' ; touch pwned ; echo '"}, }, - } + expected: "export -- KEY1='x'\\'' ; touch pwned ; echo '\\'''\n", + }, + { + name: "Shell metacharacters in the value are preserved literally", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "$(rm -rf /) `id` \"quotes\" \\backslash"}, + }, + expected: "export -- KEY1='$(rm -rf /) `id` \"quotes\" \\backslash'\n", + }, + { + name: "Empty value", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: ""}, + }, + expected: "export -- KEY1=''\n", + }, + { + name: "Multiline value is preserved verbatim", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "line1\nline2"}, + }, + expected: "export -- KEY1='line1\nline2'\n", + }, + { + name: "Multiline value with skipMultilineEncoding set still emits real newlines", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "line1\nline2", SkipMultilineEncoding: true}, + }, + expected: "export -- KEY1='line1\nline2'\n", + }, + { + name: "Every shape of portable variable name is accepted", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + {Key: "API_KEY", Value: "VALUE2"}, + {Key: "_PRIVATE", Value: "VALUE3"}, + {Key: "lowercase", Value: "VALUE4"}, + {Key: "MiXeD_9", Value: "VALUE5"}, + }, + expected: "export -- KEY1='VALUE1'\nexport -- API_KEY='VALUE2'\nexport -- _PRIVATE='VALUE3'\nexport -- lowercase='VALUE4'\nexport -- MiXeD_9='VALUE5'\n", + }, + { + name: "Key assigning to another variable is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "PATH=/tmp/evil", Value: "ignored"}, + }, + expectError: true, + }, + { + name: "Key appending to another variable is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "PATH+", Value: ":/tmp/evil"}, + }, + expectError: true, + }, + { + name: "Key with an array subscript is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "path[1]", Value: "/tmp/evil"}, + }, + expectError: true, + }, + { + name: "Key ending the assignment is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "FOO=bar; touch pwned #", Value: "VALUE1"}, + }, + expectError: true, + }, + { + name: "Key breaking out of the wrapping is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "FOO' ; touch pwned ; echo '", Value: "VALUE1"}, + }, + expectError: true, + }, + { + name: "Key with shell metacharacters is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "$(id)`id`", Value: "VALUE1"}, + }, + expectError: true, + }, + { + name: "Key starting with a dash is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "-p", Value: "VALUE1"}, + }, + expectError: true, + }, + { + name: "Key starting with a digit is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "1KEY", Value: "VALUE1"}, + }, + expectError: true, + }, + { + name: "A rejected key emits no output at all for the other secrets", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + {Key: "LD_PRELOAD+", Value: "/tmp/evil.so"}, + {Key: "KEY2", Value: "VALUE2"}, + }, + expectError: true, + }, +} - for _, tt := range tests { +func TestFormatAsDotEnvExport(t *testing.T) { + for _, tt := range dotEnvShellCases { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, formatAsDotEnvExport(tt.input, tt.quoteChar)) + result, err := formatAsDotEnvExport(tt.input) + if tt.expectError { + assert.Error(t, err) + assert.Empty(t, result) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) }) } } @@ -261,16 +379,16 @@ func TestFormatEnvsQuoteChar(t *testing.T) { expected: "KEY1=\"VALUE1\"\n", }, { - name: "dotenv-export honours the quote character", + name: "dotenv-export keeps its shell safe quoting", format: FormatDotEnvExport, quoteChar: QuoteCharDouble, - expected: "export KEY1=\"VALUE1\"\n", + expected: "export -- KEY1='VALUE1'\n", }, { name: "dotenv-eval keeps its shell safe quoting", format: FormatDotEnvEval, quoteChar: QuoteCharDouble, - expected: "export KEY1='VALUE1'\n", + expected: "export -- KEY1='VALUE1'\n", }, } @@ -284,7 +402,7 @@ func TestFormatEnvsQuoteChar(t *testing.T) { // Every format that does not wrap values in a configurable quote must // produce byte identical output no matter what the flag is set to. - for _, format := range []string{FormatJson, FormatYaml, FormatCSV, FormatDotEnvEval} { + for _, format := range []string{FormatJson, FormatYaml, FormatCSV, FormatDotEnvExport, FormatDotEnvEval} { t.Run(format+" ignores the quote character", func(t *testing.T) { withSingle, err := formatEnvs(envs, format, QuoteCharSingle) assert.NoError(t, err) @@ -297,6 +415,30 @@ func TestFormatEnvsQuoteChar(t *testing.T) { } } +func TestFormatEnvsKeyThatIsNotAShellName(t *testing.T) { + for _, key := range []string{"PATH=/tmp/evil", "PATH+", "path[1]"} { + envs := []models.SingleEnvironmentVariable{{Key: key, Value: "ignored"}} + + for _, format := range []string{FormatDotEnvExport, FormatDotEnvEval} { + t.Run(format+" rejects "+key, func(t *testing.T) { + result, err := formatEnvs(envs, format, QuoteCharSingle) + assert.Error(t, err) + assert.Empty(t, result) + }) + } + + // The other formats are not sourced by a shell, so the same key stays a + // plain field there and must keep working. + for _, format := range []string{FormatDotenv, FormatJson, FormatYaml, FormatCSV} { + t.Run(format+" still accepts "+key, func(t *testing.T) { + result, err := formatEnvs(envs, format, QuoteCharSingle) + assert.NoError(t, err) + assert.Contains(t, result, key) + }) + } + } +} + func TestValidateDotEnvQuoteChar(t *testing.T) { tests := []struct { name string @@ -325,76 +467,40 @@ func TestValidateDotEnvQuoteChar(t *testing.T) { } func TestFormatAsDotEnvEval(t *testing.T) { - tests := []struct { - name string - input []models.SingleEnvironmentVariable - expected string - }{ - { - name: "Empty input", - input: []models.SingleEnvironmentVariable{}, - expected: "", - }, - { - name: "Simple value", - input: []models.SingleEnvironmentVariable{ - {Key: "KEY1", Value: "simple"}, - }, - expected: "export KEY1='simple'\n", - }, - { - name: "Value containing single quote", - input: []models.SingleEnvironmentVariable{ - {Key: "KEY1", Value: "it's a value"}, - }, - expected: "export KEY1='it'\\''s a value'\n", - }, - { - name: "Multiline value is preserved verbatim", - input: []models.SingleEnvironmentVariable{ - {Key: "KEY1", Value: "line1\nline2"}, - }, - expected: "export KEY1='line1\nline2'\n", - }, - { - name: "Multiline value with skipMultilineEncoding set still emits real newlines", - input: []models.SingleEnvironmentVariable{ - {Key: "KEY1", Value: "line1\nline2", SkipMultilineEncoding: true}, - }, - expected: "export KEY1='line1\nline2'\n", - }, - { - name: "Shell metacharacters are preserved literally inside single quotes", - input: []models.SingleEnvironmentVariable{ - {Key: "KEY1", Value: `$(rm -rf /) "quotes" \backslash`}, - }, - expected: "export KEY1='$(rm -rf /) \"quotes\" \\backslash'\n", - }, - } - - for _, tt := range tests { + for _, tt := range dotEnvShellCases { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, formatAsDotEnvEval(tt.input)) + result, err := formatAsDotEnvEval(tt.input) + if tt.expectError { + assert.Error(t, err) + assert.Empty(t, result) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) }) } } func TestPosixShellQuote(t *testing.T) { tests := []struct { + name string input string expected string }{ - {input: "", expected: "''"}, - {input: "plain", expected: "'plain'"}, - {input: "it's", expected: `'it'\''s'`}, - {input: "'leading", expected: `''\''leading'`}, - {input: "trailing'", expected: `'trailing'\'''`}, - {input: "a'b'c", expected: `'a'\''b'\''c'`}, - {input: "with\nnewline", expected: "'with\nnewline'"}, + {name: "Empty string", input: "", expected: "''"}, + {name: "No escaping needed", input: "plain", expected: "'plain'"}, + {name: "Embedded single quote", input: "it's", expected: `'it'\''s'`}, + {name: "Leading single quote", input: "'leading", expected: `''\''leading'`}, + {name: "Trailing single quote", input: "trailing'", expected: `'trailing'\'''`}, + {name: "Several single quotes", input: "a'b'c", expected: `'a'\''b'\''c'`}, + {name: "Newline is kept verbatim", input: "with\nnewline", expected: "'with\nnewline'"}, + {name: "Leading dash", input: "-p", expected: "'-p'"}, + {name: "Whitespace only", input: " \t ", expected: "' \t '"}, } for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { + t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expected, posixShellQuote(tt.input)) }) }