joininbox/scripts/install.hiddenservice.sh
autoblitzbot f14730caa3
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 <autoblitzbot@users.noreply.github.com>
2026-08-15 20:39:28 +02:00

219 lines
6.2 KiB
Bash
Executable file

#!/bin/bash
# based on https://github.com/rootzoll/raspiblitz/blob/v1.6/home.admin/config.scripts/internet.hiddenservice.sh
# $1 is the service name, same as the HiddenServiceDir in torrc
# $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"
echo "install.hiddenservice.sh [service] [toPort] [fromPort] [optional-toPort2] [optional-fromPort2]"
echo "install.hiddenservice.sh off [service]"
exit 1
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"
validateService "$service"
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 systemctl reload tor
sleep 5
echo "# Done"
exit 0
fi
service="$1"
validateService "$service"
toPort="$2"
if [ ${#toPort} -eq 0 ]; then
echo "ERROR: the port to forward to is missing"
exit 1
fi
fromPort="$3"
validatePort "$toPort"
validatePort "$fromPort"
# not mandatory
toPort2="$4"
# needed if $4 is given
fromPort2="$5"
if [ ${#toPort2} -gt 0 ]; then
if [ ${#fromPort2} -eq 0 ]; then
echo "ERROR: the second port to forward from is missing"
exit 1
fi
validatePort "$toPort2"
validatePort "$fromPort2"
fi
if [ -z "$HiddenServiceDir" ]; then
if [ -d "/mnt/hdd/tor" ] ; then
HiddenServiceDir="/mnt/hdd/tor"
else
HiddenServiceDir="/var/lib/tor"
fi
echo "HiddenServiceDir=$HiddenServiceDir" >> /home/joinmarket/joinin.conf
fi
validateHiddenServiceDir "$HiddenServiceDir"
if [ "${runBehindTor}" = "on" ]; then
candidate=$(sudo mktemp /etc/tor/torrc.joininbox.XXXXXX) || exit 1
trap 'sudo rm -f -- "$candidate"' EXIT
generateTorrcCandidate "$service" "$candidate"
echo "
# Hidden Service for $service
HiddenServiceDir $HiddenServiceDir/$service
HiddenServiceVersion 3
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 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 "$candidate" >/dev/null
fi
fi
installTorrc "$candidate"
trap - EXIT
# reload tor
echo
echo "Reloading Tor to activate the Hidden Service..."
sudo systemctl reload tor
sleep 10
# show the Hidden Service address
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")
if [ -z "$TOR_ADDRESS" ]; then
echo " FAIL - The Hidden Service address could not be found - Tor error?"
exit 1
fi
fi
echo
echo "The Tor Hidden Service address for $service is:"
echo "$TOR_ADDRESS"
echo "use with the port: $toPort"
if [ ${#toPort2} -gt 0 ]; then
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
fi
else
echo "Tor is not active"
exit 1
fi