Fix infinite loop on EOF, eval injection risk, and subshell inefficiency#1436
Open
Fix infinite loop on EOF, eval injection risk, and subshell inefficiency#1436
Conversation
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>
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Four bugs and efficiency issues found by static analysis and code review:
get_passphrase()infinite loops when stdin reaches EOFset_var()passes insufficiently validated input toeval, allowing shell metacharacter injectionbuild_ca()forks unnecessary subshells to read single-line passphrase temp fileseasyrsa_mktemp()uses opaque nested loops that hide the 20-slot temp file limitDetail
1. Infinite loop on EOF —
hide_read_pass()/get_passphrase()hide_read_pass()calledread -rin each branch but discarded its exit status, always returning 0. When stdin reaches EOF (readreturns 1 — e.g. non-interactive use, piped input, closed terminal), the callerget_passphrase()never detected the failure. Becauserstays empty,${#r} < 4is true, the "too short" message is printed, and thewhile :loop restarts — indefinitely.The
return 1at the end ofget_passphrase()(afterdone) was unreachable dead code.Fix: Capture
read's exit status in every branch ofhide_read_pass()andreturn $ret. Inget_passphrase(), add|| return 1to thehide_read_passcall so EOF propagates cleanly to the caller.2.
set_var()— incomplete validation beforeevalThe existing guard only rejected names containing
=:Names containing backticks,
$(), semicolons, spaces, or other shell metacharacters passed the check and were handed directly toeval.Fix: Restrict
$1to valid POSIX identifier characters — alphanumeric plus underscore, not starting with a digit — and reject empty strings:No legitimate internal call site is affected; all existing callers pass known-good variable names.
3. Unnecessary subshells reading passphrase temp files —
build_ca()get_passphrase()writes passphrases withprintf '%s'(no trailing newline); the value is always a single line.read -rand$(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
forloops over literal word lists to producetemp.00–temp.19. The hard limit of 20 slots was invisible and required editing two separate loop heads to change.Slot names (
temp.00–temp.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 intoop-test.shvia the newrun_local_regression_tests()function. It runs automatically alongside the existing downloaded unit tests with no external dependencies.The seven tests and what they cover:
FIX-1-passphrase-eof-exitsbuild-cawith/dev/nullstdin exits within 10 s — confirms no infinite loopFIX-2a-set_var-rejects-digit-leading-nameset_var 0INVALID …in vars file → non-zero exitFIX-2b-set_var-rejects-hyphen-in-nameset_var EASYRSA-INVALID …in vars file → non-zero exitFIX-2c-set_var-accepts-valid-identifierset_var EASYRSA_REQ_CN …still works (regression guard)FIX-2d-set_var-rejects-empty-nameFIX-3-passphrase-comparison-regressionbuild-cawith--passout/--passinsucceeds (read -r parity check)FIX-4-easyrsa-mktemp-naming--keep-tmpsnapshot containstemp.00with counter-loop namingRun locally:
sh unit-tests-pr1436.sh -vAll 7 tests pass on the current branch.
🤖 Generated with Claude Code