d170d18746bb06db7b2fc97b67e281597a3fc152

Author
Marc Cornellà <marc@mcornella.com>
Committer
GitHub <noreply@github.com>
Date

Message

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 <carlosalag@protonmail.com>

Diff

This diff is truncated to protect this page.

  1diff --git a/plugins/dotenv/.zunit.yml b/plugins/dotenv/.zunit.yml
  2new file mode 100644
  3index 0000000000000000000000000000000000000000..e5ea0c3a6d5cc14adfcbb1f902720db8597f0a8b
  4--- /dev/null
  5+++ b/plugins/dotenv/.zunit.yml
  6@@ -0,0 +1,9 @@
  7+tap: false
  8+directories:
  9+  tests: tests
 10+  output: tests/_output
 11+  support: tests/_support
 12+time_limit: 0
 13+fail_fast: false
 14+allow_risky: false
 15+verbose: false
 16diff --git a/plugins/dotenv/README.md b/plugins/dotenv/README.md
 17index 5dbcf0fb1446bbd039038cc1390c614bc6015682..8b3f9ecce3265abc489748246b7f49623716f16b 100644
 18--- a/plugins/dotenv/README.md
 19+++ b/plugins/dotenv/README.md
 20@@ -34,6 +34,25 @@ PORT=3001
 21 
 22 You can even mix both formats, although it's probably a bad idea.
 23 
 24+Multi-line values are supported using quoted strings:
 25+
 26+```sh
 27+PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
 28+MIIEowIBAAKCAQEA...
 29+-----END RSA PRIVATE KEY-----"
 30+```
 31+
 32+Variables defined earlier in the file can be referenced by later entries:
 33+
 34+```sh
 35+BASE_URL=https://example.com
 36+API_URL=$BASE_URL/api
 37+ASSETS_URL=${BASE_URL}/assets
 38+```
 39+
 40+Note: only variables defined within the same `.env` file are expanded this way —
 41+shell environment variables that already exist are **not** substituted.
 42+
 43 ## Settings
 44 
 45 ### ZSH_DOTENV_FILE
 46@@ -86,13 +105,37 @@ mount `.env` files as named pipes to inject secrets on-the-fly without writing t
 47 
 48 No additional configuration is required — the plugin automatically detects and sources named pipes.
 49 
 50+## Tests
 51+
 52+The tests use [zunit](https://github.com/zunit-zsh/zunit). Install it per its [documentation](https://github.com/zunit-zsh/zunit#installation), then run:
 53+
 54+```sh
 55+cd plugins/dotenv && zunit
 56+```
 57+
 58+> [NOTE!]
 59+> zunit also requires installing [Revolver](https://github.com/molovo/revolver).
 60+
 61 ## Version Control
 62 
 63 **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.
 64 
 65-## Disclaimer
 66+## Security
 67+
 68+The plugin applies several best-effort safeguards when loading a `.env` file:
 69+
 70+- **Size limit** — files larger than 10 MiB are rejected to prevent DoS.
 71+- **Syntax check** — the file is validated with `zsh -fn` before any variables are set.
 72+- **No command substitution** — entries containing `$(...)` or backtick constructs are skipped.
 73+- **Forbidden variables** — the following variables are never overwritten, regardless of what the
 74+  `.env` file contains: `NODE_OPTIONS`, `BASH_ENV`, `ENV`, `ZDOTDIR`, `ZSH`, `LD_PRELOAD`,
 75+  `LD_LIBRARY_PATH`, `DYLD_INSERT_LIBRARIES`, `GIT_CONFIG_GLOBAL`, `GIT_DIR`, `GIT_EDITOR`,
 76+  `GIT_EXTERNAL_DIFF`, `GIT_EXEC_PATH`, `GIT_PAGER`, `GIT_SSH`, `GIT_SSH_COMMAND`,
 77+  `GIT_SSL_NO_VERIFY`, `GIT_TEMPLATE_DIR`, `VISUAL`, `PAGER`, `EDITOR`, and all zsh special
 78+  parameters.
 79 
 80diff --git a/plugins/dotenv/dotenv.plugin.zsh b/plugins/dotenv/dotenv.plugin.zsh
 81index c44c369b56d0825de08a2cc6ff430077a59c4f60..72839a501c81022702c3b1a66432793f5628469d 100644
 82--- a/plugins/dotenv/dotenv.plugin.zsh
 83+++ b/plugins/dotenv/dotenv.plugin.zsh
 84@@ -7,9 +7,271 @@
 85 : ${ZSH_DOTENV_ALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-allowed.list"}
 86 : ${ZSH_DOTENV_DISALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-disallowed.list"}
 87 
 88-
 89 ## Functions
 90 
 91+_parse_dotenv_content() {
 92+  setopt localoptions extendedglob
 93+
 94+  local content="$1"
 95+  local mode="${2:-export}"
 96+
 97+  # Validate mode argument
 98+  case "$mode" in
 99+    export|test) ;;
100+    *)
101+      echo "parse_dotenv: invalid mode '$mode' (use 'export' or 'test')" >&2
102+      return 1
103+      ;;
104+  esac
105+
106+  local node line key value
107+  local raw_value expanded prefix remainder var_name escaped_dollar_placeholder
108+  local sq dq uq safe
109+  local -A parsed_vars
110+  local -a nodes lines
111+
112+  # Parse into command lines separated by `;`, with built-in support for multi-line commands.
113+  # (Z:C:) ignores comments and preserves quotes and escapes.
114+  #
115+  # All logical commands are separated by literal ';' elements, which allows us to reconstruct logical lines
116+  # by joining all elements between ';'.
117+  #
118+  # Example input:
119+  #   VAR1=value1; VAR2=value2
120+  #   VAR3="multi
121+  #   line value"
122+  # Result:
123+  #   typeset -a nodes=( 'VAR1=value1' ';' 'VAR2=value2' ';' $'VAR3="multi\nline value"' )
124+  #   typeset -a lines=( 'VAR1=value1' 'VAR2=value2' $'VAR3="multi\nline value"' )
125+  #
126+  nodes=("${(@Z:C:)content}" ";") # last ';' ensures we add the final command
127+  for node in "${nodes[@]}"; do
128+    if [[ "$node" == ";" ]]; then
129+      if [[ -n "$line" ]]; then
130+        lines+=("$line")
131+        line=""
132+      fi
133+      continue
134+    fi
135+
136+    [[ -z "$line" ]] || line+=" "
137+    line+="$node"
138+  done
139+
140+  local -a forbidden_vars=(
141+    NODE_OPTIONS
142+    BASH_ENV
143+    ENV
144+    ZDOTDIR
145+    ZSH
146+    LD_PRELOAD
147+    LD_LIBRARY_PATH
148+    DYLD_INSERT_LIBRARIES
149+    GIT_CONFIG_GLOBAL
150+    GIT_DIR
151+    GIT_EDITOR
152+    GIT_EXTERNAL_DIFF
153+    GIT_EXEC_PATH
154+    GIT_PAGER
155+    GIT_SSH
156+    GIT_SSH_COMMAND
157+    GIT_SSL_NO_VERIFY
158+    GIT_TEMPLATE_DIR
159+    VISUAL
160+    PAGER
161+    EDITOR
162+    ${(k)parameters[(R)*export*special]}
163+  )
164+  local forbidden="${(j:|:)forbidden_vars}"
165+
166+
167+  # Each line contains a single command line, we need to parse valid KEY=VALUE pairs
168+  for line in "${lines[@]}"; do
169+    # Strip leading 'export ' keyword
170+    line="${line#export[ 	]}"
171+
172+    # Match KEY=VALUE pattern
173+    # "A name may be any sequence of alphanumeric characters and underscores"
174+    # https://zsh.sourceforge.io/Doc/Release/Parameters.html#Parameters
175+    if [[ ! "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$ ]]; then
176+      continue
177+    fi
178+
179+    key="${match[1]}"
180+    value="${match[2]}"
181+    raw_value="$value"
182+
183+    # Filter out variables to be ignored for security reasons (best effort)
184diff --git a/plugins/dotenv/tests/_output/.gitignore b/plugins/dotenv/tests/_output/.gitignore
185new file mode 100644
186index 0000000000000000000000000000000000000000..d6b7ef32c8478a48c3994dcadc86837f4371184d
187--- /dev/null
188+++ b/plugins/dotenv/tests/_output/.gitignore
189@@ -0,0 +1,2 @@
190+*
191+!.gitignore
192diff --git a/plugins/dotenv/tests/_support/bootstrap b/plugins/dotenv/tests/_support/bootstrap
193new file mode 100644
194index 0000000000000000000000000000000000000000..f45bec020fba8b72d638856c9dd747148e7ef035
195--- /dev/null
196+++ b/plugins/dotenv/tests/_support/bootstrap
197@@ -0,0 +1,139 @@
198+#!/usr/bin/env zsh
199+# Bootstrap script for dotenv plugin tests
200+# This is sourced before any tests run and provides shared utilities
201+
202+# Load the dotenv plugin
203+source "$PWD/dotenv.plugin.zsh"
204+ZSH_DOTENV_PROMPT=false
205+ZSH_DOTENV_FILE=/dev/null
206+
207+# Helper: Parse dotenv file in test mode
208+_parse_dotenv_test() {
209+  parse_dotenv "$1" "test"
210+}
211+
212+# Helper: Parse dotenv file in export mode
213+_parse_dotenv_export() {
214+  unset "${(k)parameters[(R)*export*]}" 2>/dev/null || true
215+
216+  parse_dotenv "$1" "test"
217+
218+  for key in "${(k)DOTENV_TEST_VARS}"; do
219+    typeset -x "$key"="${DOTENV_TEST_VARS[$key]}"
220+  done
221+}
222+
223+# Helper: Run parse_dotenv suppressing stderr
224+_parse_dotenv_quiet() {
225+  parse_dotenv "$@" 2>/dev/null
226+}
227+
228+# Helper: Create a temporary test fixture
229+_create_temp_fixture() {
230+  local fixture
231+  fixture==(:)  # Create temp file
232+  echo "$fixture"
233+}
234+
235+_write_temp_fixture() {
236+  local fixture="$1"
237+  > "$fixture"
238+}
239+
240+
241+# Helper: Source file with allexport and capture variables
242+# Usage: _source_with_allexport "file.env"
243+# Result is in DOTENV_SOURCE_VARS associative array
244+_source_with_allexport() {
245+  local filename="$1"
246+
247+  # Source with allexport in a subshell with no exported variables
248+
249+  # The return and capture of the exported variables is a bit of a pain:
250+  # 1. We first store the key=value pairs in $vars associative array, which is
251+  #    defined before allexport is set to avoid appearing in results.
252+  # 2. Afterwards, we join all keys and values of the associative with null delimiters. With 
253+  #    "$(@kv)vars}" we get keys and values with quotes, to retain empty values. With (pj:\0:)
254+  #    we join them with nulls.
255+  # 3. The caller reads this output with "${(@0)}" to split by nulls and quoting to retain
256+  #    empty values, and then uses it to populate an associative array.
257+  # Don't try to understand this or change it unless you have to. Debugging is a nightmare.
258+  typeset -gA DOTENV_SOURCE_VARS
259+  DOTENV_SOURCE_VARS=("${(@0)"$(
260+    local -A vars
261+
262+    # Clear all exports first
263+    zmodload zsh/parameter
264+    unset ${(k)parameters[(R)*export*]} 2>/dev/null || true
265+
266+    # Source file with allexport
267+    setopt localoptions allexport
268+    source "$filename"
269+
270+    # Set all exported variables into an associative array
271+    for key in ${(k)parameters[(R)*export*]}; do
272+      vars[$key]="${(P)key}"
273+    done
274+
275+    print -rn -- "${(@kvpj:\0:)vars}"
276+  )"}")
277+}
278+
279+
280+## ZUnit assertion helpers
281+
282+_zunit_assert_function_exists() {
283+  [[ "${+functions[$1]}" -eq 1 ]] && return 0
284+  echo "Function '$1' does not exist"
285+  exit 1
286+}
287+
288+_zunit_assert_var_same_as() {
289+  local tvalue=${${:-${(Pt)1%-*}}:-unset} tcomp=${${:-${(Pt)2%-*}}:-unset}
290+  if [[ $tvalue != $tcomp ]]; then
291+    echo "Type mismatch: '$1' ($tvalue) and '$2' ($tcomp)"
292+    exit 78
293+  fi
294+
295+  # Special case for associative arrays
296+  if [[ ${(Pt)1} == "association" ]]; then
297diff --git a/plugins/dotenv/tests/_support/fixtures/dotenvjs.env b/plugins/dotenv/tests/_support/fixtures/dotenvjs.env
298new file mode 100644
299index 0000000000000000000000000000000000000000..16a56267c417952ae35676bd0e662bc17a7962c9
300--- /dev/null
301+++ b/plugins/dotenv/tests/_support/fixtures/dotenvjs.env
302@@ -0,0 +1,88 @@
303+# Consolidated dotenv test fixture from dotenv test suite
304+# Source: https://github.com/motdotla/dotenv/tree/master/tests
305+#
306+# Copyright (c) 2015, Scott Motte
307+# All rights reserved.
308+
309+# Redistribution and use in source and binary forms, with or without
310+# modification, are permitted provided that the following conditions are met:
311+
312+# * Redistributions of source code must retain the above copyright notice, this
313+#   list of conditions and the following disclaimer.
314+
315+# * Redistributions in binary form must reproduce the above copyright notice,
316+#   this list of conditions and the following disclaimer in the documentation
317+#   and/or other materials provided with the distribution.
318+
319+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
320+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
321+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
322+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
323+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
324+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
325+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
326+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
327+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
328+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
329+
330+# Basic assignments
331+BASIC=basic
332+
333+# previous line intentionally left blank
334+AFTER_LINE=after_line
335+
336+# Empty values
337+EMPTY=
338+EMPTY_SINGLE_QUOTES=''
339+EMPTY_DOUBLE_QUOTES=""
340+
341+# Single quotes (literal, no expansion)
342+SINGLE_QUOTES='single_quotes'
343+SINGLE_QUOTES_SPACED='    single quotes    '
344+DONT_EXPAND_SQUOTED='dontexpand\nnewlines'
345+
346+# Double quotes (with escapes)
347+DOUBLE_QUOTES="double_quotes"
348+DOUBLE_QUOTES_SPACED="    double quotes    "
349+EXPAND_NEWLINES="expand\nnew\nlines"
350+
351+# Unquoted (no escape expansion)
352+DONT_EXPAND_UNQUOTED=dontexpand\nnewlines
353+
354+# Quotes inside quotes
355+DOUBLE_QUOTES_INSIDE_SINGLE='double "quotes" work inside single quotes'
356+SINGLE_QUOTES_INSIDE_DOUBLE="single 'quotes' work inside double quotes"
357+
358+# Comments
359+# COMMENTS=work
360+INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work
361+INLINE_COMMENTS_DOUBLE_QUOTES="inline comments outside of #doublequotes" # work
362+INLINE_COMMENTS_UNQUOTED=value # work
363+
364+# Special characters
365+EQUAL_SIGNS=equals==
366+RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}'
367+USEREMAIL=therealnerdybeast@example.tld
368+
369+# Multiline values with double quotes
370+MULTI_DOUBLE_QUOTED="THIS
371+IS
372+A
373+MULTILINE
374+STRING"
375+
376+# Multiline values with single quotes
377+MULTI_SINGLE_QUOTED='THIS
378+IS
379+A
380+MULTILINE
381+STRING'
382+
383+# Multiline PEM certificate
384+MULTI_PEM_DOUBLE_QUOTED="-----BEGIN PUBLIC KEY-----
385+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u
386+LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/
387+bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/
388+kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V
389+u4QuUoobAgMBAAE=
390+-----END PUBLIC KEY-----"
391diff --git a/plugins/dotenv/tests/_support/fixtures/features.env b/plugins/dotenv/tests/_support/fixtures/features.env
392new file mode 100644
393index 0000000000000000000000000000000000000000..e5862bc8e7bda503fc02d2c80608bb440fc44bfa
394--- /dev/null
395+++ b/plugins/dotenv/tests/_support/fixtures/features.env
396@@ -0,0 +1,23 @@
397+# Export syntax
398+export EXPORTED_VAR=exported_value
399+export EXPORTED_EMPTY=
400+
401+# Variable expansion (in-file forward references)
402+BASE_URL=https://api.example.com
403+API_ENDPOINT="${BASE_URL}/v1"
404+FULL_ENDPOINT=$BASE_URL/v2/users
405+COMBINED="${BASE_URL}_suffix"
406+
407+# Testing multiline quoting edge cases
408+MULTILINE_UNQUOTED=This\ is\ a\ \
409+multiline\ value\ that\ should\ be\ treated\ as\ a\ single\ line\ with\ a\ literal\ backslash\ and\ newline
410+MULTILINE_DOUBLE_QUOTED="This is a \
411+multiline value that should be treated as a single line with an actual newline character"
412+MULTILINE_SINGLE_QUOTED='This is a \
413+multiline value that should be treated as a single line with a literal backslash and newline'
414+MULTILINE_MIXED_QUOTES="This is a \
415+multiline value that should be treated as a single line with an actual newline character and a literal backslash \"and 'single quotes' inside"
416+
417+# Test for regressions
418+DATABASE_URL="postgres://user:pass@host/db;sslmode=require"
419+VAR_WITH_SEMICOLONS="value ; with ; semicolons"
420diff --git a/plugins/dotenv/tests/basic-parsing.zunit b/plugins/dotenv/tests/basic-parsing.zunit
421new file mode 100644
422index 0000000000000000000000000000000000000000..611f6a70a57cc7a84d90f681b63d3a54b5391fb6
423--- /dev/null
424+++ b/plugins/dotenv/tests/basic-parsing.zunit
425@@ -0,0 +1,398 @@
426+#!/usr/bin/env zunit
427+
428+
429+@setup {
430+  typeset -g fixture="$(_create_temp_fixture)"
431+  typeset -gA expected_vars=()
432+}
433+
434+@teardown {
435+  [[ -f "$fixture" ]] && command rm -f "$fixture"
436+  unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null
437+}
438+
439+@test 'dotenv plugin loads successfully' {
440+  assert "parse_dotenv" function_exists
441+  assert "source_env" function_exists
442+}
443+
444+@test 'parse returns error for unsupported mode' {
445+  run _parse_dotenv_quiet "/dev/null" "export"
446+  assert $state equals 0
447+
448+  run _parse_dotenv_quiet "/dev/null" "test"
449+  assert $state equals 0
450+
451+  run _parse_dotenv_quiet "/dev/null" "invalid_mode"
452+  assert $state equals 1
453+}
454+
455+@test 'parse returns error for oversized file (> 10MiB)' {
456+  command truncate -s 11M "$fixture" 2>/dev/null
457+
458+  run _parse_dotenv_quiet "$fixture" "test"
459+  assert $state equals 1
460+}
461+
462+@test 'parse returns error for non-existent file' {
463+  run _parse_dotenv_quiet "/nonexistent/path/.env" "test"
464+  assert $state equals 1
465+}
466+
467+@test 'source_env loads named pipes without blocking' {
468+  local tmpdir fifo output result
469+  local child_pid writer_pid killer_pid child_rc
470+
471+  tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/dotenv.XXXXXX")"
472+  fifo="$tmpdir/.env"
473+  output="$tmpdir/output"
474+  command mkfifo "$fifo"
475+
476+  (
477+    print -r -- 'TOKEN=secret' > "$fifo"
478+  ) &
479+  writer_pid=$!
480+
481+  (
482+    ZSH_DOTENV_PROMPT=false
483+    ZSH_DOTENV_FILE="$fifo"
484+    source_env
485+    print -r -- "${TOKEN-<unset>}" > "$output"
486+  ) &
487+  child_pid=$!
488+
489+  (
490+    sleep 2
491+    kill -0 $child_pid 2>/dev/null || exit 0
492+    kill $child_pid 2>/dev/null || exit 0
493+  ) &
494+  killer_pid=$!
495+
496+  wait $child_pid
497+  child_rc=$?
498+
499+  kill $killer_pid 2>/dev/null || true
500+  kill $writer_pid 2>/dev/null || true
501+  wait $writer_pid 2>/dev/null || true
502+
503+  [[ -f "$output" ]] && result="$(<"$output")"
504+  command rm -rf "$tmpdir"
505+
506+  assert $child_rc equals 0
507+  assert "$result" equals 'secret'
508+}
509+
510+@test 'source_env rejects oversized named pipes' {
511+  run zsh -fc '
512+    source ./dotenv.plugin.zsh
513+
514+    tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/dotenv.XXXXXX")" || exit 1
515+    fifo="$tmpdir/.env"
516+    command mkfifo "$fifo" || exit 1
517+
518+    cleanup() {
519+      kill $killer_pid 2>/dev/null || true
520+      kill $writer_pid 2>/dev/null || true
521+      wait $writer_pid 2>/dev/null || true
522+      command rm -rf "$tmpdir"
523+    }
524+    trap cleanup EXIT
525diff --git a/plugins/dotenv/tests/compatibility.zunit b/plugins/dotenv/tests/compatibility.zunit
526new file mode 100644
527index 0000000000000000000000000000000000000000..61c5dddba3355c12ea996652d922e879728ac3b6
528--- /dev/null
529+++ b/plugins/dotenv/tests/compatibility.zunit
530@@ -0,0 +1,27 @@
531+#!/usr/bin/env zunit
532+
533+@setup {
534+  unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null
535+}
536+
537+@teardown {
538+  unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null
539+}
540+
541+@test 'compatibility: dotenvjs fixture matches native source' {
542+  local fixture="${testdir:A}/_support/fixtures/dotenvjs.env"
543+
544+  _parse_dotenv_test "$fixture"
545+  _source_with_allexport "$fixture"
546+
547+  assert "DOTENV_TEST_VARS" var_same_as "DOTENV_SOURCE_VARS"
548+}
549+
550+@test 'compatibility: features fixture matches native source' {
551+  local fixture="${testdir:A}/_support/fixtures/features.env"
552+
553+  _parse_dotenv_test "$fixture"
554+  _source_with_allexport "$fixture"
555+
556+  assert "DOTENV_TEST_VARS" var_same_as "DOTENV_SOURCE_VARS"
557+}
558diff --git a/plugins/dotenv/tests/security.zunit b/plugins/dotenv/tests/security.zunit
559new file mode 100644
560index 0000000000000000000000000000000000000000..414f87bb7209870444c460193cd250c1d0caf32d
561--- /dev/null
562+++ b/plugins/dotenv/tests/security.zunit
563@@ -0,0 +1,209 @@
564+#!/usr/bin/env zunit
565+
566+@setup {
567+  typeset -g fixture="$(_create_temp_fixture)"
568+  typeset -gA expected_vars=()
569+}
570+
571+@teardown {
572+  [[ -f "$fixture" ]] && command rm -f "$fixture"
573+  unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null
574+}
575+
576+@test 'skip dangerous backtick command substitution' {
577+  > "$fixture" <<'EOF'
578+# Should be skipped
579+DANGEROUS_BACKTICK=`whoami`
580+EOF
581+
582+  _parse_dotenv_test "$fixture"
583+
584+  assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
585+}
586+
587+@test 'skip dangerous subshell command substitution' {
588+  > "$fixture" <<'EOF'
589+# Should be skipped
590+DANGEROUS_SUBSHELL=$(date)
591+EOF
592+
593+  _parse_dotenv_test "$fixture"
594+
595+  assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
596+}
597+
598+@test 'skip nested command substitution in double quotes' {
599+  > "$fixture" <<'EOF'
600+# Should be skipped
601+DANGEROUS_NESTED="prefix_$(echo malicious)_suffix"
602+EOF
603+
604+  _parse_dotenv_test "$fixture"
605+
606+  assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
607+}
608+
609+@test 'skip multiple words (potential command execution)' {
610+  > "$fixture" <<'EOF'
611+# Should be skipped - multiple words could execute commands
612+BASE_URL=/ echo command run
613+EOF
614+
615+  _parse_dotenv_test "$fixture"
616+
617+  assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
618+}
619+
620+@test 'allow literal command substitution in single quotes' {
621+  > "$fixture" <<'EOF'
622+# Single quotes make everything literal - should be parsed
623+SAFE_SINGLE_QUOTED='$(this is literal)'
624+SAFE_BACKTICK='`also literal`'
625+
626+# Should also be parsed
627+SAFE_VAR=safe_value
628+EOF
629+
630+  expected_vars=(
631+    SAFE_SINGLE_QUOTED '$(this is literal)'
632+    SAFE_BACKTICK '`also literal`'
633+    SAFE_VAR 'safe_value'
634+  )
635+
636+  _parse_dotenv_test "$fixture"
637+
638+  assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
639+}
640+
641+@test 'skip backticks in unquoted values' {
642+  > "$fixture" <<'EOF'
643+# Backticks in unquoted context - should be skipped
644+DANGEROUS_UNQUOTED=`echo danger`
645+EOF
646+
647+  _parse_dotenv_test "$fixture"
648+
649+  assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
650+}
651+
652+@test 'skip dollar-paren in unquoted values' {
653+  > "$fixture" <<'EOF'
654+# Command substitution in unquoted context - should be skipped
655+DANGEROUS_UNQUOTED=$(uname -a)
656+EOF
657+
658+  _parse_dotenv_test "$fixture"
659+
660+  assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
661+}
662+