mirror of
https://github.com/ohmyzsh/ohmyzsh.git
synced 2026-05-29 04:53:17 +02:00
fix(dotenv): implement secure parsing for .env files and add comprehensive tests
This commit is contained in:
parent
139bc2b5a1
commit
2014363332
10 changed files with 850 additions and 1 deletions
|
|
@ -10,6 +10,138 @@
|
|||
|
||||
## Functions
|
||||
|
||||
parse_dotenv() {
|
||||
setopt localoptions extendedglob
|
||||
|
||||
local filename="$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 content node line key value
|
||||
local -A parsed_vars
|
||||
local -a nodes lines
|
||||
|
||||
# Read entire file
|
||||
content="$(<$filename)" || return 1
|
||||
|
||||
# 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
|
||||
# typeset -p nodes lines
|
||||
|
||||
# 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]}"
|
||||
|
||||
# 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 remove remove this filter block, this is what happens:
|
||||
#
|
||||
# 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
|
||||
local sq dq uq safe remainder
|
||||
# - 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
|
||||
|
||||
# Unquote the value to handle special characters and multiline values
|
||||
value="${(Q)value}"
|
||||
|
||||
# Expand variables from in-file parsed vars (same as double-quoted)
|
||||
local expanded="$value"
|
||||
for var_name in "${(@k)parsed_vars}"; do
|
||||
local var_value="${parsed_vars[$var_name]}"
|
||||
expanded="${expanded//\$\{${var_name}\}/${var_value}}"
|
||||
expanded="${expanded//\$${var_name}/${var_value}}"
|
||||
done
|
||||
value="$expanded"
|
||||
|
||||
# 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}")
|
||||
}
|
||||
|
||||
source_env() {
|
||||
if [[ ! -f "$ZSH_DOTENV_FILE" ]] && [[ ! -p "$ZSH_DOTENV_FILE" ]]; then
|
||||
return
|
||||
|
|
@ -58,7 +190,7 @@ source_env() {
|
|||
}
|
||||
|
||||
setopt localoptions allexport
|
||||
source $ZSH_DOTENV_FILE
|
||||
parse_dotenv "$ZSH_DOTENV_FILE"
|
||||
}
|
||||
|
||||
autoload -U add-zsh-hook
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue