Skip to content

Fix infinite loop on EOF, eval injection risk, and subshell inefficiency#1436

Open
ecrist wants to merge 2 commits intomasterfrom
fix/bugs-and-efficiency
Open

Fix infinite loop on EOF, eval injection risk, and subshell inefficiency#1436
ecrist wants to merge 2 commits intomasterfrom
fix/bugs-and-efficiency

Conversation

@ecrist
Copy link
Copy Markdown
Member

@ecrist ecrist commented Mar 14, 2026

Summary

Four bugs and efficiency issues found by static analysis and code review:

  • [BUG - High] get_passphrase() infinite loops when stdin reaches EOF
  • [BUG - Medium] set_var() passes insufficiently validated input to eval, allowing shell metacharacter injection
  • [EFFICIENCY] build_ca() forks unnecessary subshells to read single-line passphrase temp files
  • [EFFICIENCY/CLARITY] easyrsa_mktemp() uses opaque nested loops that hide the 20-slot temp file limit

Detail

1. Infinite loop on EOF — hide_read_pass() / get_passphrase()

hide_read_pass() called read -r in each branch but discarded its exit status, always returning 0. When stdin reaches EOF (read returns 1 — e.g. non-interactive use, piped input, closed terminal), the caller get_passphrase() never detected the failure. Because r stays empty, ${#r} < 4 is true, the "too short" message is printed, and the while : loop restarts — indefinitely.

The return 1 at the end of get_passphrase() (after done) was unreachable dead code.

Fix: Capture read's exit status in every branch of hide_read_pass() and return $ret. In get_passphrase(), add || return 1 to the hide_read_pass call so EOF propagates cleanly to the caller.

# hide_read_pass — each branch now:
read -r "$@"
ret=$?
# ... restore echo state ...
return $ret

# get_passphrase — break out of infinite loop on EOF:
hide_read_pass r || return 1

2. set_var() — incomplete validation before eval

The existing guard only rejected names containing =:

case "$1" in
    *=*) user_error "set_var - var '$1'"
esac
eval "export \"$1\"=\"\${$1-$2}\""

Names containing backticks, $(), semicolons, spaces, or other shell metacharacters passed the check and were handed directly to eval.

Fix: Restrict $1 to valid POSIX identifier characters — alphanumeric plus underscore, not starting with a digit — and reject empty strings:

case "$1" in
    ''|*=*|*[!A-Za-z0-9_]*|[0-9]*) user_error "set_var - var '$1'"
esac

No legitimate internal call site is affected; all existing callers pass known-good variable names.


3. Unnecessary subshells reading passphrase temp files — build_ca()

# Before — forks a subshell + external cat process per line:
p="$(cat "$in_key_pass_tmp")"
q="$(cat "$out_key_pass_tmp")"

# After — no fork, no external process:
read -r p < "$in_key_pass_tmp"
read -r q < "$out_key_pass_tmp"

get_passphrase() writes passphrases with printf '%s' (no trailing newline); the value is always a single line. read -r and $(cat ...) are semantically equivalent here. ($(...) strips trailing newlines, but there are none to strip.)


4. Opaque nested loop in easyrsa_mktemp()

The slot allocator used two nested for loops over literal word lists to produce temp.00temp.19. The hard limit of 20 slots was invisible and required editing two separate loop heads to change.

# Before:
for high in 0 1; do
    for low in 0 1 2 3 4 5 6 7 8 9; do
        shotfile="${secured_session}/temp.${high}${low}"
        ...

# After — limit is explicit and in one place:
i=0
while [ "$i" -lt 20 ]; do
    shotfile="${secured_session}/temp.$(printf '%02d' "$i")"
    i=$((i + 1))
    ...

Slot names (temp.00temp.19) are identical; no existing temp files or tests are affected.


Test plan

A regression test script (unit-tests-pr1436.sh) is included in this PR and wired into op-test.sh via the new run_local_regression_tests() function. It runs automatically alongside the existing downloaded unit tests with no external dependencies.

The seven tests and what they cover:

Test What it validates
FIX-1-passphrase-eof-exits build-ca with /dev/null stdin exits within 10 s — confirms no infinite loop
FIX-2a-set_var-rejects-digit-leading-name set_var 0INVALID … in vars file → non-zero exit
FIX-2b-set_var-rejects-hyphen-in-name set_var EASYRSA-INVALID … in vars file → non-zero exit
FIX-2c-set_var-accepts-valid-identifier set_var EASYRSA_REQ_CN … still works (regression guard)
FIX-2d-set_var-rejects-empty-name Empty variable name → non-zero exit
FIX-3-passphrase-comparison-regression build-ca with --passout/--passin succeeds (read -r parity check)
FIX-4-easyrsa-mktemp-naming --keep-tmp snapshot contains temp.00 with counter-loop naming

Run locally: sh unit-tests-pr1436.sh -v

All 7 tests pass on the current branch.

🤖 Generated with Claude Code

Eric Crist and others added 2 commits March 13, 2026 21:11
Four bugs and efficiency issues addressed:

1. BUG: Infinite loop on EOF in get_passphrase() / hide_read_pass()

   hide_read_pass() called read -r but discarded its exit status, always
   returning 0. When stdin reaches EOF (e.g. non-interactive use, piped
   input, or a closed terminal), read returns 1 but the caller never
   knew. get_passphrase() would then loop forever: r stays empty,
   ${#r} < 4, the "too short" message prints, and the while : loop
   repeats indefinitely with no way to break out.

   Fix: capture read's exit status in each branch of hide_read_pass()
   and propagate it via `return $ret`. In get_passphrase(), treat a
   non-zero return from hide_read_pass as EOF and return 1 immediately.
   The previously unreachable `return 1` after the while loop is now
   correctly reachable when the caller supplies no --passout/--passin
   and stdin is not a terminal.

2. BUG: set_var() passes insufficiently validated input to eval

   The guard in set_var() only rejected variable names containing '='.
   Names with shell metacharacters (backticks, $(), semicolons, spaces,
   etc.) passed the check and were handed directly to eval, creating a
   code-injection path for any caller that forwards unsanitised input.

   Fix: expand the case pattern to reject empty strings, names that
   start with a digit, and names containing any character outside the
   POSIX identifier set [A-Za-z0-9_]:

       ''|*=*|*[!A-Za-z0-9_]*|[0-9]*)

   This matches the POSIX definition of a valid variable name and
   eliminates the injection surface without changing behaviour for any
   legitimate call site.

3. EFFICIENCY: Unnecessary subshells when reading passphrase temp files

   build_ca() read passphrase temp files with:
       p="$(cat "$in_key_pass_tmp")"
       q="$(cat "$out_key_pass_tmp")"
   Each $(cat ...) forks a subshell and an external process. Because
   get_passphrase() writes passphrases with `printf '%s'` (no trailing
   newline) and a passphrase is always a single line, plain shell
   redirection is equivalent and avoids the forks:
       read -r p < "$in_key_pass_tmp"
       read -r q < "$out_key_pass_tmp"

4. EFFICIENCY/CLARITY: easyrsa_mktemp() used an opaque nested loop

   The temp-file slot allocator used two nested for loops over literal
   lists ("0 1" × "0 1 2 3 4 5 6 7 8 9") to generate names temp.00
   through temp.19. The hard limit of 20 slots was invisible from the
   code; changing it required editing two loop heads. Replace with a
   single counter-based while loop whose bound (20) is stated
   explicitly in both the comment and the condition, making the limit
   self-documenting and trivially adjustable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
unit-tests-pr1436.sh is a self-contained test script that validates
each of the four changes made in PR #1436. It can be run standalone
(sh unit-tests-pr1436.sh [-v] [-k]) or through op-test.sh.

op-test.sh is extended with run_local_regression_tests() which runs
the in-tree test file unconditionally after the downloaded unit tests,
requiring no curl and no external repo.

Tests included:

  FIX-1  passphrase-eof-exits
    Starts 'easyrsa build-ca' with stdin redirected from /dev/null and
    a 10-second background timeout. Before the fix get_passphrase()
    looped forever because hide_read_pass() swallowed read's exit
    status. The test fails (and reports "infinite loop") if the process
    is still running when the timer fires.

  FIX-2a  set_var-rejects-digit-leading-name
    Writes a vars file containing 'set_var 0INVALID "test"' and
    verifies easyrsa exits non-zero. The [0-9]* guard added to
    set_var() catches the leading digit before eval is reached.

  FIX-2b  set_var-rejects-hyphen-in-name
    Writes a vars file containing 'set_var EASYRSA-INVALID "test"' and
    verifies easyrsa exits non-zero. The *[!A-Za-z0-9_]* guard catches
    the hyphen, which is not a valid POSIX identifier character.

  FIX-2c  set_var-accepts-valid-identifier  (regression guard)
    Writes a vars file with a normal 'set_var EASYRSA_REQ_CN "..."'
    and verifies that init-pki still succeeds, confirming the tightened
    validation does not break legitimate usage.

  FIX-2d  set_var-rejects-empty-name
    Verifies that an empty string as the variable name is rejected.
    The '' arm of the case guard is new in PR #1436.

  FIX-3  passphrase-comparison-regression
    Builds a CA with explicit --passout=pass:X --passin=pass:X.
    Confirms that the passphrase comparison in build_ca() still works
    correctly after replacing $(cat file) with 'read -r var < file'.

  FIX-4  easyrsa-mktemp-naming
    Builds a CA with --keep-tmp to preserve the temp session, then
    checks that temp.00 exists in the snapshot directory.  Confirms
    the counter-based while loop produces the same temp.NN names as
    the original nested for-loop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ecrist pushed a commit that referenced this pull request Mar 14, 2026
unit-tests-pr1436.py reimplements the same seven regression tests
using Python 3 stdlib only (no third-party packages).  Python's
native primitives eliminate every portability workaround that the
shell version required:

  subprocess.run(stdin=DEVNULL, timeout=10)
    FIX-1's EOF/infinite-loop test becomes a single call.
    TimeoutExpired is a typed exception — no background processes,
    no signal juggling, no SECONDS busy-wait, no platform ifdefs.
    subprocess.run() kills the child process automatically when the
    timeout fires, on every platform including Windows.

  tempfile.mkdtemp()
    Returns a writable temp directory on every OS using the platform's
    native temp mechanism (TMPDIR on Unix, TEMP/TMP on Windows).
    No mkdir, no /tmp hard-coding, no fallback chain needed.

  shutil.rmtree()
    Portable recursive directory removal — no rm.exe dependency.

  pathlib.Path / Path.glob() / Path.iterdir()
    Path manipulation and directory listing without ls, find, or
    any external tool.

  path.write_text() / path.read_text()
    File I/O without cat or redirected printf.

  _easyrsa_cmd() detects Windows (sys.platform == 'win32') and
    prepends 'sh' to the easyrsa invocation, because the Windows
    kernel does not process POSIX shebangs.

op-test.sh: run_local_regression_tests() now prefers the Python
  version and falls back to the shell version only if Python 3 is
  absent.  The Python executable is detected by probing 'python3'
  then 'python' and checking that the reported version starts with
  'Python 3'.

The shell version (unit-tests-pr1436.sh) is retained as the fallback
and remains the correct choice for environments without Python (e.g.
minimal embedded Linux, some CI images).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant