General remark
Summary. If you point testssl at an HTTP proxy and the proxy turns out to be IPv6, the scan silently fails. You get no useful error; just an openssl connection error that looks like the proxy is down or the target is unreachable. Nothing in the error mentions IPv6, the proxy, or testssl.
Root cause. check_proxy() mangles the OpenSSL version comparison for IPv6 proxies with a stray $ inside the parameter expansion. At testssl.sh:23437 and testssl.sh:23450 the script writes ${OSSL_VER_MAJOR$}${OSSL_VER_MINOR} instead of ${OSSL_VER_MAJOR}${OSSL_VER_MINOR}. Bash reports bad substitution on stderr and; because the fault is a parse-time expansion error inside a function body; aborts the rest of check_proxy() at that line. Neither the PROXYIP="[$PROXYNODE]" branch nor the fatal_cmd_line "OpenSSL version >= 1.1.0 required..." branch executes, and the trailing PROXY="-proxy $PROXYIP:$PROXYPORT" assignment on line 23457 also never runs. The function returns with PROXYIP unset and PROXY still holding the raw [addr]:port string from line 23422 rather than a valid -proxy [addr]:port argument. Downstream openssl calls then fail with an argument-parse error whose message doesn't mention proxies, so the operator has no signal that testssl silently gave up on their --proxy flag.
The [[ -z "$PROXYIP" ]] guard at line 23455 does not save this case: it lives in the DNS-hostname else branch, not the literal-IPv6 branch, so it can't backstop this exit path even in principle.
This bites two groups of users:
- People who typed an IPv6 proxy address on purpose. Example:
--proxy='[2001:db8::1]:8080'. Uncommon today, but growing as networks go IPv6-only.
- People who typed a hostname proxy that happens to resolve to IPv6. Example:
--proxy=proxy.company.example:8080 where that hostname has no IPv4 (A) record and only an IPv6 (AAAA) record. These users don't think they're using IPv6 at all. testssl silently prefers the AAAA record when the A record is missing, and lands them in the same broken code path. Easy to hit accidentally during an IPv4-to-IPv6 migration or on IPv6-only cloud networks.
Because the resulting openssl error mentions neither IPv6 nor the proxy, operators blame their proxy team or the scan target and file tickets there. The report almost never reaches the testssl maintainers, which is why this bug is hard to catch.
Impact --proxy. --proxy=host:port routes all testssl outbound scans through an HTTP CONNECT proxy instead of connecting directly (see doc/testssl.1.md:130). The literal-IPv6 form this bug breaks is much rarer but is becoming more common. When someone does hit it with ipv6 only, they hit it every time.
Before you open an issue please check which version you are running and whether it is the latest in stable / dev branch
Before you open an issue please consult the FAQ and check whether this is a known problem by searching the issues
Command line to reproduce (or docker command)
./testssl.sh --proxy='[2001:db8::1]:8080' example.com
Exact source at testssl.sh:23431-23457:
elif is_ipv6addr "$PROXYNODE"; then
# Maybe an option like --proxy6 is better for purists
if [[ "$OSSL_NAME" =~ LibreSSL ]]; then
PROXYIP="$PROXYNODE"
else
# This was tested with vanilla OpenSSL versions
if [[ ${OSSL_VER_MAJOR$}${OSSL_VER_MINOR} -ge 11 ]]; then # <-- line 23437: stray $
PROXYIP="[$PROXYNODE]"
else
fatal_cmd_line "OpenSSL version >= 1.1.0 required for IPv6 proxy support" $ERR_OSSLBIN
fi
fi
else
# We check now preferred whether there was an IPv4 proxy via DNS specified
...
PROXYIP="$(get_a_record "$PROXYNODE" 2>/dev/null | grep -v alias | sed 's/^.*address //')"
if [[ -z "$PROXYIP" ]]; then
PROXYIP="$(get_aaaa_record "$PROXYNODE" 2>/dev/null | grep -v alias | sed 's/^.*address //')"
if [[ -n "$PROXYIP" ]]; then
if [[ ${OSSL_VER_MAJOR$}${OSSL_VER_MINOR} -lt 11 ]]; then # <-- line 23450: stray $
fatal_cmd_line "OpenSSL version >= 1.1.0 required for IPv6 proxy support" $ERR_OSSLBIN
fi
fi
fi
[[ -z "$PROXYIP" ]] && fatal "Proxy IP cannot be determined from \"$PROXYNODE\"" $ERR_CMDLINE
fi
PROXY="-proxy $PROXYIP:$PROXYPORT"
The intent; visible elsewhere in the file, e.g. testssl.sh:4793, testssl.sh:7327, testssl.sh:21460, testssl.sh:21590; is to compare against a version threshold. Everywhere else the codebase uses $OSSL_VER_MAJOR.$OSSL_VER_MINOR (dotted string, matched by glob) or plain $OSSL_VER_MAJOR. These two lines are the only ones that try to concatenate the digits into "11" for a numeric -ge 11; and both mistype the expansion.
Fix; lines 23437 and 23450:
if [[ ${OSSL_VER_MAJOR}${OSSL_VER_MINOR} -ge 11 ]]; then
if [[ ${OSSL_VER_MAJOR}${OSSL_VER_MINOR} -lt 11 ]]; then
Or, matching the idiom used everywhere else in the file for OpenSSL >= 1.1 gates (see lines 4793-4794, 7327, 21460):
if [[ $OSSL_VER_MAJOR -ge 3 ]] || [[ "$OSSL_VER_MAJOR.$OSSL_VER_MINOR" == 1.1.* ]]; then
The concatenation trick ${OSSL_VER_MAJOR}${OSSL_VER_MINOR} -ge 11 was invented locally at these two lines and typoed on both. Nowhere else in testssl.sh does that pattern appear; adopting the file-wide dotted-glob idiom eliminates the ad-hoc arithmetic (which quietly assumes OSSL_VER_MINOR is a single digit) and makes future greps for OpenSSL-version checks find these two sites too.
Reproduction (isolated)
cat > /tmp/repro.sh <<'EOF'
#!/usr/bin/env bash
OSSL_VER_MAJOR=1
OSSL_VER_MINOR=1
OSSL_NAME="OpenSSL"
# Verbatim: PROXY parsing from check_proxy()
PROXY="[2001:db8::1]:8080"
PROXYPORT="${PROXY##*:}"
PROXYNODE="${PROXY%:*}"
PROXYNODE="${PROXYNODE/\[/}"
PROXYNODE="${PROXYNODE/\]/}"
is_ipv4addr() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; }
is_ipv6addr() { [[ "$1" == *:* ]]; }
if is_ipv4addr "$PROXYNODE"; then
PROXYIP="$PROXYNODE"
elif is_ipv6addr "$PROXYNODE"; then
if [[ "$OSSL_NAME" =~ LibreSSL ]]; then
PROXYIP="$PROXYNODE"
else
# Verbatim from testssl.sh:23437
if [[ ${OSSL_VER_MAJOR$}${OSSL_VER_MINOR} -ge 11 ]]; then
PROXYIP="[$PROXYNODE]"
else
echo "FATAL: openssl >= 1.1 required" >&2; exit 1
fi
fi
fi
PROXY="-proxy $PROXYIP:$PROXYPORT"
echo "PROXY=[$PROXY]"
echo "PROXYIP=[$PROXYIP]"
EOF
bash /tmp/repro.sh
Output (bash 3.2, top level):
/tmp/repro.sh: line 23: ${OSSL_VER_MAJOR$}${OSSL_VER_MINOR}: bad substitution
PROXY=[-proxy :8080]
PROXYIP=[]
Note: at top level, execution does continue past the broken if and the final PROXY= assignment runs with an empty PROXYIP. Inside a function body (as check_proxy() actually is in testssl.sh) bash 3.2 aborts the whole function at the bad-substitution line, so the final PROXY="-proxy $PROXYIP:$PROXYPORT" line at 23457 never runs at all; PROXY retains the raw [addr]:port form set at 23422. Either way, no valid -proxy argument reaches openssl; the exact failure openssl reports differs by version but is never the intended "OpenSSL >= 1.1.0 required" fatal.
To reproduce the in-function behavior, wrap the reproducer in check_proxy() { ...; }; check_proxy.
Expected behavior
With OpenSSL >= 1.1 and --proxy='[2001:db8::1]:8080', check_proxy should set PROXYIP="[2001:db8::1]" and PROXY="-proxy [2001:db8::1]:8080". Instead the [[ ]] version test triggers bad substitution, which aborts the rest of check_proxy() at line 23437; no branch of the version-check runs, and the PROXY="-proxy ..." rebuild at line 23457 also never runs. The function returns leaving PROXY in the raw [addr]:port form from earlier parsing. Downstream openssl calls then fail with an argument-parse error that mentions neither IPv6 nor proxies, so operators see something that looks like a connection failure rather than a testssl bug. On a truly-old OpenSSL (< 1.1), the intended "OpenSSL version >= 1.1.0 required" fatal never fires either; same abort mechanism.
Your system (please complete the following information):
; OS: macOS 26.5.1 (Build 25F80)
; Platform: Darwin 25.5.0 arm64
; OpenSSL + bash: OpenSSL 3.6.2 (7 Apr 2026) / GNU bash 3.2.57(1)-release (arm64-apple-darwin25)
Additional context
Also affects the DNS-resolved IPv6 proxy branch at testssl.sh:23450. There the fault is less damaging (only suppresses the "OpenSSL too old" fatal on a truly old OpenSSL), but it is the same typo and should be fixed together.
Why this bug survived. The test suite at t/*.t has no coverage for --proxy at all; grep -rn '\-\-proxy' t/ returns zero matches, so check_proxy() has never been exercised by CI. The only IPv6 test file, 11_baseline_ipv6_http.t, is disabled (renamed to .DISABLED so the runner skips it), which means the two overlapping preconditions to reach this bug; IPv6 addressing and a --proxy argument; have zero coverage between them. Manual users who do trigger the bug get one bash: line 23437: bad substitution line on stderr, easily lost among testssl's banner and rating output (and often silently dropped by CI wrappers that only capture stdout), and typically attribute the resulting openssl failure to their proxy or target rather than to a shell typo in testssl. But the bad expansion fires unconditionally on every --proxy=[IPv6]:port invocation on non-LibreSSL, so anyone using that feature hits it 100% of the time.
General remark
Summary. If you point testssl at an HTTP proxy and the proxy turns out to be IPv6, the scan silently fails. You get no useful error; just an openssl connection error that looks like the proxy is down or the target is unreachable. Nothing in the error mentions IPv6, the proxy, or testssl.
Root cause.
check_proxy()mangles the OpenSSL version comparison for IPv6 proxies with a stray$inside the parameter expansion. Attestssl.sh:23437andtestssl.sh:23450the script writes${OSSL_VER_MAJOR$}${OSSL_VER_MINOR}instead of${OSSL_VER_MAJOR}${OSSL_VER_MINOR}. Bash reportsbad substitutionon stderr and; because the fault is a parse-time expansion error inside a function body; aborts the rest ofcheck_proxy()at that line. Neither thePROXYIP="[$PROXYNODE]"branch nor thefatal_cmd_line "OpenSSL version >= 1.1.0 required..."branch executes, and the trailingPROXY="-proxy $PROXYIP:$PROXYPORT"assignment on line 23457 also never runs. The function returns withPROXYIPunset andPROXYstill holding the raw[addr]:portstring from line 23422 rather than a valid-proxy [addr]:portargument. Downstream openssl calls then fail with an argument-parse error whose message doesn't mention proxies, so the operator has no signal that testssl silently gave up on their--proxyflag.The
[[ -z "$PROXYIP" ]]guard at line 23455 does not save this case: it lives in the DNS-hostnameelsebranch, not the literal-IPv6 branch, so it can't backstop this exit path even in principle.This bites two groups of users:
--proxy='[2001:db8::1]:8080'. Uncommon today, but growing as networks go IPv6-only.--proxy=proxy.company.example:8080where that hostname has no IPv4 (A) record and only an IPv6 (AAAA) record. These users don't think they're using IPv6 at all. testssl silently prefers the AAAA record when the A record is missing, and lands them in the same broken code path. Easy to hit accidentally during an IPv4-to-IPv6 migration or on IPv6-only cloud networks.Because the resulting openssl error mentions neither IPv6 nor the proxy, operators blame their proxy team or the scan target and file tickets there. The report almost never reaches the testssl maintainers, which is why this bug is hard to catch.
Impact
--proxy.--proxy=host:portroutes all testssl outbound scans through an HTTP CONNECT proxy instead of connecting directly (seedoc/testssl.1.md:130). The literal-IPv6 form this bug breaks is much rarer but is becoming more common. When someone does hit it with ipv6 only, they hit it every time.Before you open an issue please check which version you are running and whether it is the latest in stable / dev branch
git log | head -1) from the git repo; 3.3dev, current HEADtestssl.sh -v | grep from; 3.3devBefore you open an issue please consult the FAQ and check whether this is a known problem by searching the issues
Command line to reproduce (or docker command)
Exact source at
testssl.sh:23431-23457:The intent; visible elsewhere in the file, e.g.
testssl.sh:4793,testssl.sh:7327,testssl.sh:21460,testssl.sh:21590; is to compare against a version threshold. Everywhere else the codebase uses$OSSL_VER_MAJOR.$OSSL_VER_MINOR(dotted string, matched by glob) or plain$OSSL_VER_MAJOR. These two lines are the only ones that try to concatenate the digits into"11"for a numeric-ge 11; and both mistype the expansion.Fix; lines 23437 and 23450:
Or, matching the idiom used everywhere else in the file for OpenSSL >= 1.1 gates (see lines 4793-4794, 7327, 21460):
The concatenation trick
${OSSL_VER_MAJOR}${OSSL_VER_MINOR} -ge 11was invented locally at these two lines and typoed on both. Nowhere else in testssl.sh does that pattern appear; adopting the file-wide dotted-glob idiom eliminates the ad-hoc arithmetic (which quietly assumesOSSL_VER_MINORis a single digit) and makes future greps for OpenSSL-version checks find these two sites too.Reproduction (isolated)
Output (bash 3.2, top level):
Note: at top level, execution does continue past the broken
ifand the finalPROXY=assignment runs with an emptyPROXYIP. Inside a function body (ascheck_proxy()actually is in testssl.sh) bash 3.2 aborts the whole function at the bad-substitution line, so the finalPROXY="-proxy $PROXYIP:$PROXYPORT"line at 23457 never runs at all;PROXYretains the raw[addr]:portform set at 23422. Either way, no valid-proxyargument reaches openssl; the exact failure openssl reports differs by version but is never the intended "OpenSSL >= 1.1.0 required" fatal.To reproduce the in-function behavior, wrap the reproducer in
check_proxy() { ...; }; check_proxy.Expected behavior
With OpenSSL >= 1.1 and
--proxy='[2001:db8::1]:8080',check_proxyshould setPROXYIP="[2001:db8::1]"andPROXY="-proxy [2001:db8::1]:8080". Instead the[[ ]]version test triggersbad substitution, which aborts the rest ofcheck_proxy()at line 23437; no branch of the version-check runs, and thePROXY="-proxy ..."rebuild at line 23457 also never runs. The function returns leavingPROXYin the raw[addr]:portform from earlier parsing. Downstream openssl calls then fail with an argument-parse error that mentions neither IPv6 nor proxies, so operators see something that looks like a connection failure rather than a testssl bug. On a truly-old OpenSSL (< 1.1), the intended "OpenSSL version >= 1.1.0 required" fatal never fires either; same abort mechanism.Your system (please complete the following information):
; OS: macOS 26.5.1 (Build 25F80)
; Platform: Darwin 25.5.0 arm64
; OpenSSL + bash: OpenSSL 3.6.2 (7 Apr 2026) / GNU bash 3.2.57(1)-release (arm64-apple-darwin25)
Additional context
Also affects the DNS-resolved IPv6 proxy branch at
testssl.sh:23450. There the fault is less damaging (only suppresses the "OpenSSL too old" fatal on a truly old OpenSSL), but it is the same typo and should be fixed together.Why this bug survived. The test suite at
t/*.thas no coverage for--proxyat all;grep -rn '\-\-proxy' t/returns zero matches, socheck_proxy()has never been exercised by CI. The only IPv6 test file,11_baseline_ipv6_http.t, is disabled (renamed to.DISABLEDso the runner skips it), which means the two overlapping preconditions to reach this bug; IPv6 addressing and a--proxyargument; have zero coverage between them. Manual users who do trigger the bug get onebash: line 23437: bad substitutionline on stderr, easily lost among testssl's banner and rating output (and often silently dropped by CI wrappers that only capture stdout), and typically attribute the resulting openssl failure to their proxy or target rather than to a shell typo in testssl. But the bad expansion fires unconditionally on every--proxy=[IPv6]:portinvocation on non-LibreSSL, so anyone using that feature hits it 100% of the time.