From f14730caa304eb70577619dc35fa3785a4da7ca0 Mon Sep 17 00:00:00 2001 From: autoblitzbot Date: Sat, 15 Aug 2026 20:39:28 +0200 Subject: [PATCH] security: validate and atomically install Tor config (#188) * security: validate and atomically install Tor config * fix: harden candidate generation and HiddenServiceDir handling Address review blocking findings: - Replace sed|awk|tee pipelines with generateTorrcCandidate() which checks every stage explicitly and refuses empty/truncated candidates before installTorrc ever runs (both add and removal paths) - Stop sourcing joinin.conf; read values with a grep-based helper - Validate HiddenServiceDir against the expected Tor data roots (/var/lib/tor, /mnt/hdd/tor) so a crafted value cannot inject Tor directives into the candidate - Quote HiddenServiceDir path expansions * fix: keep torrc staging writes inside the privileged pipeline Addresses re-review: sudo mktemp made the stage file root-owned while the shell redirect ran unprivileged (EACCES on every add/remove path). * fix: enable pipefail so failed torrc producers are detected Without pipefail the 'sudo sed|sudo tee' and 'sudo awk|sudo tee' pipelines only observed tee's status, so a producer failing after partial output could yield a truncated candidate that still passes tor --verify-config. --------- Co-authored-by: autoblitzbot --- scripts/install.hiddenservice.sh | 153 +++++++++++++++++++++++-------- 1 file changed, 115 insertions(+), 38 deletions(-) diff --git a/scripts/install.hiddenservice.sh b/scripts/install.hiddenservice.sh index f39dd80..47e7919 100755 --- a/scripts/install.hiddenservice.sh +++ b/scripts/install.hiddenservice.sh @@ -4,6 +4,11 @@ # $2 is the port the Hidden Service forwards to (to be used in the Tor browser) # $3 is the port to be forwarded with the Hidden Service +# Detect failures anywhere in a pipeline (eg sed | tee): without pipefail only +# the last command's status is observed, so a failed producer writing partial +# output would go unnoticed and could yield a truncated candidate torrc. +set -o pipefail + # command info if [ $# -eq 0 ] || [ "$1" = "-h" ] || [ "$1" = "-help" ]; then echo "config script to configure a Tor Hidden Service" @@ -15,38 +20,111 @@ fi source /home/joinmarket/_functions.sh sourceConf /home/joinmarket/joinin.conf +# hidden-service base directory: strict absolute path, no whitespace or control +# characters, must live under an expected Tor data root +validateHiddenServiceDir() { + case "$1" in + /var/lib/tor|/mnt/hdd/tor) ;; + *) + echo "ERROR: unexpected HiddenServiceDir: $1" >&2 + echo "Expected /var/lib/tor or /mnt/hdd/tor - refusing to continue" >&2 + exit 1 + ;; + esac +} + +validateService() { + case "$1" in + ''|*[!A-Za-z0-9_-]*) + echo "ERROR: invalid service name: $1" >&2 + exit 1 + ;; + esac +} + +validatePort() { + case "$1" in + ''|*[!0-9]*) + echo "ERROR: invalid port: $1" >&2 + exit 1 + ;; + esac + if [ "$1" -lt 1 ] || [ "$1" -gt 65535 ]; then + echo "ERROR: port outside the range 1-65535: $1" >&2 + exit 1 + fi +} + +# Install a candidate torrc only after Tor accepts it. The temporary file lives +# next to torrc so the final rename is atomic and remains root-owned. +installTorrc() { + candidate="$1" + sudo chmod 644 "$candidate" + sudo chown root:root "$candidate" + if ! sudo tor --verify-config -f "$candidate"; then + echo "ERROR: Tor rejected the generated configuration" >&2 + sudo rm -f -- "$candidate" + exit 1 + fi + sudo mv -f -- "$candidate" /etc/tor/torrc +} + +# Generate a candidate torrc without the block of the given service. +# Every stage is checked explicitly so a failed read or write never produces +# an empty or truncated candidate that would replace the live torrc. +generateTorrcCandidate() { + service="$1" + candidate="$2" + # the stage file is root-owned (sudo mktemp), so the write must stay + # inside the privileged pipeline - a plain shell redirect would be opened + # by the unprivileged caller and fail with EACCES + stage=$(sudo mktemp) || exit 1 + if ! sudo sed "/# Hidden Service for ${service}/,/^\s*$/{d}" /etc/tor/torrc | sudo tee "$stage" >/dev/null; then + echo "ERROR: failed to read/process /etc/tor/torrc" >&2 + sudo rm -f -- "$stage" + exit 1 + fi + # a failed read must not yield an empty candidate + if [ ! -s "$stage" ]; then + echo "ERROR: processed torrc is empty - refusing to install" >&2 + sudo rm -f -- "$stage" + exit 1 + fi + if ! sudo awk 'NF > 0 {blank=0} NF == 0 {blank++} blank < 2' "$stage" | \ + sudo tee "$candidate" >/dev/null; then + echo "ERROR: failed to write the candidate torrc" >&2 + sudo rm -f -- "$stage" + exit 1 + fi + if [ ! -s "$candidate" ]; then + echo "ERROR: generated candidate is empty - refusing to install" >&2 + sudo rm -f -- "$stage" + exit 1 + fi + sudo rm -f -- "$stage" +} + # delete a hidden service if [ "$1" == "off" ]; then service="$2" - if [ ${#service} -eq 0 ]; then - echo "ERROR: service name is missing" - exit 1 - fi + validateService "$service" - # remove service paragraph - sudo sed -i "/# Hidden Service for ${service}/,/^\s*$/{d}" /etc/tor/torrc - - # remove double empty lines - sudo cp /etc/tor/torrc /home/joinmarket/tmp - sudo chmod 777 /home/joinmarket/tmp - sudo awk 'NF > 0 {blank=0} NF == 0 {blank++} blank < 2' /etc/tor/torrc > /home/joinmarket/tmp - sudo mv /home/joinmarket/tmp /etc/tor/torrc - sudo chmod 644 /etc/tor/torrc - sudo chown bitcoin:bitcoin /etc/tor/torrc + candidate=$(sudo mktemp /etc/tor/torrc.joininbox.XXXXXX) || exit 1 + trap 'sudo rm -f -- "$candidate"' EXIT + generateTorrcCandidate "$service" "$candidate" + installTorrc "$candidate" + trap - EXIT echo "# OK service is removed - reloading Tor ..." - sudo pkill -sighup tor + sudo systemctl reload tor sleep 5 echo "# Done" exit 0 fi service="$1" -if [ ${#service} -eq 0 ]; then - echo "ERROR: service name is missing" - exit 1 -fi +validateService "$service" toPort="$2" if [ ${#toPort} -eq 0 ]; then @@ -55,10 +133,8 @@ if [ ${#toPort} -eq 0 ]; then fi fromPort="$3" -if [ ${#fromPort} -eq 0 ]; then - echo "ERROR: the port to forward from is missing" - exit 1 -fi +validatePort "$toPort" +validatePort "$fromPort" # not mandatory toPort2="$4" @@ -70,10 +146,11 @@ if [ ${#toPort2} -gt 0 ]; then echo "ERROR: the second port to forward from is missing" exit 1 fi + validatePort "$toPort2" + validatePort "$fromPort2" fi -checkDirEntry=$(grep -c "HiddenServiceDir" < /home/joinmarket/joinin.conf) -if [ "$checkDirEntry" -eq 0 ]; then +if [ -z "$HiddenServiceDir" ]; then if [ -d "/mnt/hdd/tor" ] ; then HiddenServiceDir="/mnt/hdd/tor" else @@ -81,45 +158,45 @@ if [ "$checkDirEntry" -eq 0 ]; then fi echo "HiddenServiceDir=$HiddenServiceDir" >> /home/joinmarket/joinin.conf fi +validateHiddenServiceDir "$HiddenServiceDir" if [ "${runBehindTor}" = "on" ]; then - # delete any old entry for that service - sudo sed -i "/# Hidden Service for ${service}/,/^\s*$/{d}" /etc/tor/torrc + candidate=$(sudo mktemp /etc/tor/torrc.joininbox.XXXXXX) || exit 1 + trap 'sudo rm -f -- "$candidate"' EXIT + generateTorrcCandidate "$service" "$candidate" - # make new entry for that service echo " # Hidden Service for $service HiddenServiceDir $HiddenServiceDir/$service HiddenServiceVersion 3 -HiddenServicePort $toPort 127.0.0.1:$fromPort" | sudo tee -a /etc/tor/torrc - - # remove double empty lines - awk 'NF > 0 {blank=0} NF == 0 {blank++} blank < 2' /etc/tor/torrc | sudo tee /home/joinmarket/tmp >/dev/null && sudo mv /home/joinmarket/tmp /etc/tor/torrc +HiddenServicePort $toPort 127.0.0.1:$fromPort" | sudo tee -a "$candidate" >/dev/null # check and insert second port pair if [ ${#toPort2} -gt 0 ]; then - alreadyThere=$(sudo cat /etc/tor/torrc 2>/dev/null | grep -c "\b127.0.0.1:$fromPort2\b") + alreadyThere=$(sudo grep -c "\b127.0.0.1:$fromPort2\b" "$candidate" 2>/dev/null) if [ ${alreadyThere} -gt 0 ]; then echo "The port $fromPort2 is already forwarded. Check the /etc/tor/torrc for the details." else - echo "HiddenServicePort $toPort2 127.0.0.1:$fromPort2" | sudo tee -a /etc/tor/torrc + echo "HiddenServicePort $toPort2 127.0.0.1:$fromPort2" | sudo tee -a "$candidate" >/dev/null fi fi + installTorrc "$candidate" + trap - EXIT + # reload tor echo echo "Reloading Tor to activate the Hidden Service..." - sudo chmod 644 /etc/tor/torrc sudo systemctl reload tor sleep 10 # show the Hidden Service address - TOR_ADDRESS=$(sudo cat $HiddenServiceDir/$service/hostname) + TOR_ADDRESS=$(sudo cat "$HiddenServiceDir/$service/hostname") if [ -z "$TOR_ADDRESS" ]; then echo "Waiting for the Hidden Service" sleep 10 - TOR_ADDRESS=$(sudo cat $HiddenServiceDir/$service/hostname) + TOR_ADDRESS=$(sudo cat "$HiddenServiceDir/$service/hostname") if [ -z "$TOR_ADDRESS" ]; then echo " FAIL - The Hidden Service address could not be found - Tor error?" exit 1 @@ -130,7 +207,7 @@ HiddenServicePort $toPort 127.0.0.1:$fromPort" | sudo tee -a /etc/tor/torrc echo "$TOR_ADDRESS" echo "use with the port: $toPort" if [ ${#toPort2} -gt 0 ]; then - wasAdded=$(sudo cat /etc/tor/torrc 2>/dev/null | grep -c "\b127.0.0.1:$fromPort2\b") + wasAdded=$(sudo grep -c "\b127.0.0.1:$fromPort2\b" /etc/tor/torrc 2>/dev/null) if [ ${wasAdded} -gt 0 ]; then echo "or the port: $toPort2" fi