From c9eaf2d807d80b63a100c5cc1d6443bf98dcff3b Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 1 May 2026 14:15:05 +0200 Subject: [PATCH] Add trusted_proxies env var to control nginx x-forwarded-for behaviour (#1982) --- .env.example | 10 ++ apps/server/entrypoint.sh | 45 +++++ apps/server/nginx-443.conf | 7 +- apps/server/nginx-80-443.conf | 7 +- dockerfiles/all-in-one/config/nginx-443.conf | 7 +- .../all-in-one/config/nginx-80-443.conf | 7 +- dockerfiles/all-in-one/s6-scripts/nginx/run | 45 +++++ .../advanced/trusted-proxies.md | 63 +++++++ .../script/advanced/admin-access.md | 4 +- .../script/advanced/trusted-proxies.md | 42 +++++ install.sh | 162 +++++++++++++++++- 11 files changed, 379 insertions(+), 20 deletions(-) create mode 100644 docs/installation/docker-compose/advanced/trusted-proxies.md create mode 100644 docs/installation/script/advanced/trusted-proxies.md diff --git a/.env.example b/.env.example index 6fadc7017..ee53e2914 100644 --- a/.env.example +++ b/.env.example @@ -119,3 +119,13 @@ MAX_UPLOAD_SIZE_MB=100 # Requests from non-allowlisted IPs are silently routed to the client app and will # result in a 404 error. ADMIN_IP_ALLOWLIST= + +# Trusted upstream proxies that AliasVault's reverse proxy will accept the +# X-Forwarded-For header from when determining the real client IP. Options: +# - Empty = trust all RFC1918 ranges (10/8, 172.16/12, 192.168/16). +# - A comma-separated list of CIDRs/IPs (e.g. "10.0.1.5,192.168.1.0/24") to +# trust only those upstream proxies. Recommended when running behind a +# specific reverse proxy (HAProxy, Traefik, Cloudflare, etc.) so X-Forwarded-For +# from any other source is ignored. +# - "none" = trust no upstream proxies; logs always show the direct peer IP. +TRUSTED_PROXIES= diff --git a/apps/server/entrypoint.sh b/apps/server/entrypoint.sh index 9d5d56214..24153373e 100644 --- a/apps/server/entrypoint.sh +++ b/apps/server/entrypoint.sh @@ -73,6 +73,51 @@ build_admin_ip_geo_rules() { ADMIN_IP_GEO_RULES_VALUE=$(build_admin_ip_geo_rules) sed -i "s|__ADMIN_IP_GEO_RULES__|${ADMIN_IP_GEO_RULES_VALUE}|g" /etc/nginx/nginx.conf +# Build the set_real_ip_from directives from TRUSTED_PROXIES. These tell nginx +# which upstream proxies it should accept the X-Forwarded-For header from when +# determining the real client IP. Empty -> RFC1918 (backwards-compatible +# default). "none" -> no directives (raw $remote_addr is always used). Otherwise +# treated as a comma-separated list of CIDRs/IPs; invalid tokens are skipped +# with a warning so a typo can't crash-loop nginx, and if no valid tokens +# remain we emit no directives (equivalent to "none"). +build_trusted_proxies_directives() { + local raw="${TRUSTED_PROXIES:-}" + case "$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" in + "") + printf '%s' "set_real_ip_from 10.0.0.0/8; set_real_ip_from 172.16.0.0/12; set_real_ip_from 192.168.0.0/16;" + ;; + none) + printf '%s' "" + ;; + *) + local out="" + local valid=0 + local IFS=',' + for cidr in $raw; do + cidr=$(printf '%s' "$cidr" | tr -d '[:space:]') + [ -z "$cidr" ] && continue + if is_valid_cidr "$cidr"; then + if [ -z "$out" ]; then + out="set_real_ip_from $cidr;" + else + out="$out set_real_ip_from $cidr;" + fi + valid=$((valid + 1)) + else + printf 'warning: TRUSTED_PROXIES: ignoring invalid CIDR/IP %s\n' "$cidr" >&2 + fi + done + if [ "$valid" -eq 0 ]; then + printf 'warning: TRUSTED_PROXIES has no valid entries; X-Forwarded-For will be ignored from all upstreams\n' >&2 + fi + printf '%s' "$out" + ;; + esac +} + +TRUSTED_PROXIES_VALUE=$(build_trusted_proxies_directives) +sed -i "s|__TRUSTED_PROXIES__|${TRUSTED_PROXIES_VALUE}|g" /etc/nginx/nginx.conf + # Function to check if certificate needs regeneration needs_cert_regeneration() { # If cert doesn't exist, need to generate diff --git a/apps/server/nginx-443.conf b/apps/server/nginx-443.conf index 7aa4c3d78..4c036dcc0 100644 --- a/apps/server/nginx-443.conf +++ b/apps/server/nginx-443.conf @@ -22,10 +22,9 @@ http { } # Preserve any existing X-Forwarded-* headers, this is relevant if AliasVault - # is running behind another reverse proxy. - set_real_ip_from 10.0.0.0/8; - set_real_ip_from 172.16.0.0/12; - set_real_ip_from 192.168.0.0/16; + # is running behind another reverse proxy. The set_real_ip_from directives + # are rendered by entrypoint.sh from TRUSTED_PROXIES (default: RFC1918). + __TRUSTED_PROXIES__ real_ip_header X-Forwarded-For; real_ip_recursive on; diff --git a/apps/server/nginx-80-443.conf b/apps/server/nginx-80-443.conf index 3a963637b..b1713ede9 100644 --- a/apps/server/nginx-80-443.conf +++ b/apps/server/nginx-80-443.conf @@ -22,10 +22,9 @@ http { } # Preserve any existing X-Forwarded-* headers, this is relevant if AliasVault - # is running behind another reverse proxy. - set_real_ip_from 10.0.0.0/8; - set_real_ip_from 172.16.0.0/12; - set_real_ip_from 192.168.0.0/16; + # is running behind another reverse proxy. The set_real_ip_from directives + # are rendered by entrypoint.sh from TRUSTED_PROXIES (default: RFC1918). + __TRUSTED_PROXIES__ real_ip_header X-Forwarded-For; real_ip_recursive on; diff --git a/dockerfiles/all-in-one/config/nginx-443.conf b/dockerfiles/all-in-one/config/nginx-443.conf index 876b7e53f..bb5ebee06 100644 --- a/dockerfiles/all-in-one/config/nginx-443.conf +++ b/dockerfiles/all-in-one/config/nginx-443.conf @@ -20,10 +20,9 @@ http { } # Preserve any existing X-Forwarded-* headers, this is relevant if AliasVault - # is running behind another reverse proxy. - set_real_ip_from 10.0.0.0/8; - set_real_ip_from 172.16.0.0/12; - set_real_ip_from 192.168.0.0/16; + # is running behind another reverse proxy. The set_real_ip_from directives + # are rendered by the nginx s6 script from TRUSTED_PROXIES (default: RFC1918). + __TRUSTED_PROXIES__ real_ip_header X-Forwarded-For; real_ip_recursive on; diff --git a/dockerfiles/all-in-one/config/nginx-80-443.conf b/dockerfiles/all-in-one/config/nginx-80-443.conf index 9dc27acaf..ff709623c 100644 --- a/dockerfiles/all-in-one/config/nginx-80-443.conf +++ b/dockerfiles/all-in-one/config/nginx-80-443.conf @@ -21,10 +21,9 @@ http { } # Preserve any existing X-Forwarded-* headers, this is relevant if AliasVault - # is running behind another reverse proxy. - set_real_ip_from 10.0.0.0/8; - set_real_ip_from 172.16.0.0/12; - set_real_ip_from 192.168.0.0/16; + # is running behind another reverse proxy. The set_real_ip_from directives + # are rendered by the nginx s6 script from TRUSTED_PROXIES (default: RFC1918). + __TRUSTED_PROXIES__ real_ip_header X-Forwarded-For; real_ip_recursive on; diff --git a/dockerfiles/all-in-one/s6-scripts/nginx/run b/dockerfiles/all-in-one/s6-scripts/nginx/run index 616b09cfa..514d84a15 100644 --- a/dockerfiles/all-in-one/s6-scripts/nginx/run +++ b/dockerfiles/all-in-one/s6-scripts/nginx/run @@ -83,6 +83,51 @@ build_admin_ip_geo_rules() { ADMIN_IP_GEO_RULES_VALUE=$(build_admin_ip_geo_rules) sed -i "s|__ADMIN_IP_GEO_RULES__|${ADMIN_IP_GEO_RULES_VALUE}|g" /etc/nginx/nginx.conf +# Build the set_real_ip_from directives from TRUSTED_PROXIES. These tell nginx +# which upstream proxies it should accept the X-Forwarded-For header from when +# determining the real client IP. Empty -> RFC1918 (backwards-compatible +# default). "none" -> no directives (raw $remote_addr is always used). Otherwise +# treated as a comma-separated list of CIDRs/IPs; invalid tokens are skipped +# with a warning so a typo can't crash-loop nginx, and if no valid tokens +# remain we emit no directives (equivalent to "none"). +build_trusted_proxies_directives() { + local raw="${TRUSTED_PROXIES:-}" + case "$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" in + "") + printf '%s' "set_real_ip_from 10.0.0.0/8; set_real_ip_from 172.16.0.0/12; set_real_ip_from 192.168.0.0/16;" + ;; + none) + printf '%s' "" + ;; + *) + local out="" + local valid=0 + local IFS=',' + for cidr in $raw; do + cidr=$(printf '%s' "$cidr" | tr -d '[:space:]') + [ -z "$cidr" ] && continue + if is_valid_cidr "$cidr"; then + if [ -z "$out" ]; then + out="set_real_ip_from $cidr;" + else + out="$out set_real_ip_from $cidr;" + fi + valid=$((valid + 1)) + else + printf 'warning: TRUSTED_PROXIES: ignoring invalid CIDR/IP %s\n' "$cidr" >&2 + fi + done + if [ "$valid" -eq 0 ]; then + printf 'warning: TRUSTED_PROXIES has no valid entries; X-Forwarded-For will be ignored from all upstreams\n' >&2 + fi + printf '%s' "$out" + ;; + esac +} + +TRUSTED_PROXIES_VALUE=$(build_trusted_proxies_directives) +sed -i "s|__TRUSTED_PROXIES__|${TRUSTED_PROXIES_VALUE}|g" /etc/nginx/nginx.conf + echo "Starting Nginx reverse proxy..." # Set nginx error log level based on verbosity diff --git a/docs/installation/docker-compose/advanced/trusted-proxies.md b/docs/installation/docker-compose/advanced/trusted-proxies.md new file mode 100644 index 000000000..35638d4a4 --- /dev/null +++ b/docs/installation/docker-compose/advanced/trusted-proxies.md @@ -0,0 +1,63 @@ +--- +layout: default +title: Trusted proxies +parent: Advanced +grand_parent: Docker Compose +nav_order: 6 +--- + +# Trusted proxies + +When AliasVault sits behind another reverse proxy (HAProxy, Traefik, Cloudflare, an upstream nginx, etc.), the built-in nginx reads the real client IP from the `X-Forwarded-For` header so that audit logs and the IP allowlist see the actual client rather than the upstream proxy. + +To prevent header spoofing, nginx only honors `X-Forwarded-For` when the request comes from an explicitly trusted upstream. The `TRUSTED_PROXIES` environment variable controls that list. + +## How it works + +For every incoming request, nginx checks the direct peer's IP against `TRUSTED_PROXIES`: + +- If the peer IP matches a trusted entry, nginx replaces `$remote_addr` with the value from `X-Forwarded-For`. +- If it doesn't match, the header is ignored and the direct peer IP is used. + +`real_ip_recursive` is enabled, so when a chain of trusted proxies is configured nginx walks the `X-Forwarded-For` list right-to-left until it finds the first untrusted address. + +## Options + +Set `TRUSTED_PROXIES` in the `environment:` section of your `docker-compose.yml` to one of: + +| Value | Effect | +|---|---| +| _empty_ (default) | Trust all RFC1918 ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`). | +| Comma-separated list of CIDRs/IPs | Trust only the listed proxies. **Recommended** when you know your upstream proxy address(es). | +| `none` | Trust no upstream proxies. `X-Forwarded-For` is always ignored and the direct peer IP is logged. | + +### Examples + +```yaml +# ... + environment: + # Trust only a specific HAProxy at 10.0.1.5 and a /24 of internal proxies: + TRUSTED_PROXIES: "10.0.1.5,192.168.10.0/24" +# ... +``` + +```yaml +# ... + environment: + # Disable X-Forwarded-For handling entirely: + TRUSTED_PROXIES: "none" +# ... +``` + +## Apply the change + +After updating `docker-compose.yml`, the container must be recreated for the new environment value to take effect: + +```bash +docker compose down +docker compose up -d +``` + +## Why narrow this down + +The default of all RFC1918 ranges is convenient. Most setups place AliasVault and its upstream proxy in the same private network. But it does mean that **any** request originating from a private IP can spoof `X-Forwarded-For` and appear in the logs as a different client. If you have other workloads on the same private network, set `TRUSTED_PROXIES` to your specific upstream proxy address(es) so only that proxy is trusted to set the header. diff --git a/docs/installation/script/advanced/admin-access.md b/docs/installation/script/advanced/admin-access.md index 86662dade..d7e369e96 100644 --- a/docs/installation/script/advanced/admin-access.md +++ b/docs/installation/script/advanced/admin-access.md @@ -13,7 +13,7 @@ By default the admin panel at `/admin` is reachable from the public internet, al - Sign-in requires the admin password you set during installation (and optional 2FA). - The admin account is protected against brute force: after 10 failed sign-in attempts the account is locked for 30 minutes. -If you'd still rather not expose `/admin` to the open internet — for example if your AliasVault server is only meant to be reached from a home network or VPN — you can restrict it by client IP at the reverse-proxy layer. +If you'd still rather not expose `/admin` to the open internet, for example if your AliasVault server is only meant to be reached from a home network or VPN, you can restrict it by client IP at the reverse-proxy layer using the `ADMIN_IP_ALLOWLIST` environment variable. ## How it works @@ -23,7 +23,7 @@ Requests from allowlisted IPs reach the admin panel as normal. ## Configure -Run the install script — it walks you through the options, validates your input, and offers to restart the containers for you: +Run the install script which walks you through the options, validates your input, and offers to restart the containers for you: ```bash $ ./install.sh configure-admin-access diff --git a/docs/installation/script/advanced/trusted-proxies.md b/docs/installation/script/advanced/trusted-proxies.md new file mode 100644 index 000000000..3fd380b96 --- /dev/null +++ b/docs/installation/script/advanced/trusted-proxies.md @@ -0,0 +1,42 @@ +--- +layout: default +title: Trusted proxies +parent: Advanced +grand_parent: Install Script +nav_order: 6 +--- + +# Trusted proxies + +When AliasVault sits behind another reverse proxy (HAProxy, Traefik, Cloudflare, an upstream nginx, etc.), the built-in nginx reads the real client IP from the `X-Forwarded-For` header so that audit logs and the IP allowlist see the actual client rather than the upstream proxy. + +To prevent header spoofing, nginx only honors `X-Forwarded-For` when the request comes from an explicitly trusted upstream. The `TRUSTED_PROXIES` environment variable controls that list. + +## How it works + +For every incoming request, nginx checks the direct peer's IP against `TRUSTED_PROXIES`: + +- If the peer IP matches a trusted entry, nginx replaces `$remote_addr` with the value from `X-Forwarded-For`. +- If it doesn't match, the header is ignored and the direct peer IP is used. + +`real_ip_recursive` is enabled, so when a chain of trusted proxies is configured nginx walks the `X-Forwarded-For` list right-to-left until it finds the first untrusted address. + +## Configure + +Run the install script which walks you through the options, validates your input, and offers to restart the containers for you: + +```bash +$ ./install.sh configure-trusted-proxies +``` + +You'll be prompted to choose one of: + +| Option | Effect | +|---|---| +| Default | Trust all RFC1918 ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`). | +| Custom CIDRs/IPs | Trust only the listed proxies (recommended when you know your upstream proxy address). | +| None | Trust no upstream proxies. `X-Forwarded-For` is always ignored and the direct peer IP is logged. | + +## Why narrow this down + +The default of all RFC1918 ranges is convenient. Most setups place AliasVault and its upstream proxy in the same private network. But it does mean that **any** request originating from a private IP can spoof `X-Forwarded-For` and appear in the logs as a different client. If you have other workloads on the same private network, set `TRUSTED_PROXIES` to your specific upstream proxy address(es) so only that proxy is trusted to set the header. diff --git a/install.sh b/install.sh index b90dd255e..9f4229c4e 100755 --- a/install.sh +++ b/install.sh @@ -62,6 +62,7 @@ show_usage() { printf " configure-registration Configure new account registration (enable or disable)\n" printf " configure-ip-logging Configure IP address logging (enable or disable)\n" printf " configure-admin-access Configure /admin IP allowlist (restrict admin access by client IP)\n" + printf " configure-trusted-proxies Configure trusted upstream proxies for X-Forwarded-For\n" printf " reset-admin-password Reset admin password\n" printf " uninstall Uninstall AliasVault\n" printf "\n" @@ -95,6 +96,30 @@ print_logo() { } # Function to parse command line arguments +# Canonical command list (primary forms only, no aliases). Used by +# suggest_commands to render "Did you mean..." prefix matches when the user +# enters an unknown command. Keep sorted so suggestions appear in stable order. +KNOWN_COMMANDS="build configure-admin-access configure-dev-db configure-email configure-hostname configure-ip-logging configure-registration configure-ssl configure-trusted-proxies db-export db-import install migrate-db reset-admin-password restart start stop uninstall update update-installer" + +# Print "Did you mean ..." suggestions for any known command that has the user's +# input as a prefix. Returns 0 if at least one match was found, 1 otherwise. +suggest_commands() { + local input="$1" + local found=0 + for cmd in $KNOWN_COMMANDS; do + case "$cmd" in + "$input"*) + if [ "$found" -eq 0 ]; then + printf "${YELLOW}Did you mean:${NC}\n" + found=1 + fi + printf " %s\n" "$cmd" + ;; + esac + done + [ "$found" -eq 1 ] +} + parse_args() { COMMAND="" VERBOSE=false @@ -169,6 +194,10 @@ parse_args() { COMMAND="configure-admin-access" shift ;; + configure-trusted-proxies|trusted-proxies) + COMMAND="configure-trusted-proxies" + shift + ;; start|s) COMMAND="start" shift @@ -220,8 +249,11 @@ parse_args() { exit 0 ;; *) - echo "Unknown option: $1" - show_usage + printf "${RED}Unknown command: %s${NC}\n" "$1" + if suggest_commands "$1"; then + printf "\n" + fi + printf "Run '%s --help' to see all available commands.\n" "$0" exit 1 ;; esac @@ -978,6 +1010,9 @@ main() { "configure-admin-access") handle_admin_access_configuration ;; + "configure-trusted-proxies") + handle_trusted_proxies_configuration + ;; "start") handle_start ;; @@ -3434,6 +3469,123 @@ handle_admin_access_configuration() { fi } +# Function to handle trusted upstream proxies configuration +handle_trusted_proxies_configuration() { + printf "${YELLOW}+++ Trusted Proxies Configuration +++${NC}\n" + printf "\n" + + # Check if AliasVault is installed + if [ ! -f "docker-compose.yml" ]; then + printf "${RED}Error: AliasVault must be installed first.${NC}\n" + exit 1 + fi + + CURRENT_SETTING=$(grep "^TRUSTED_PROXIES=" "$ENV_FILE" | cut -d '=' -f2-) + + printf "${CYAN}About Trusted Proxies:${NC}\n" + printf "When AliasVault sits behind another reverse proxy (HAProxy, Traefik, Cloudflare, etc.),\n" + printf "the built-in nginx reads the real client IP from the X-Forwarded-For header. To prevent\n" + printf "spoofing, this header is only honored when the request comes from a trusted upstream.\n" + printf "\n" + printf "${CYAN}Current Configuration:${NC}\n" + if [ -z "$CURRENT_SETTING" ]; then + printf "Trusted Proxies: ${GREEN}All RFC1918${NC} (default: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)\n" + elif [ "$CURRENT_SETTING" = "none" ]; then + printf "Trusted Proxies: ${CYAN}none${NC} (X-Forwarded-For is ignored)\n" + else + printf "Trusted Proxies: ${CYAN}${CURRENT_SETTING}${NC}\n" + fi + printf "\n" + printf "${CYAN}Options:${NC}\n" + printf "1) Default — trust all RFC1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)\n" + printf "2) Custom — comma-separated CIDRs/IPs (e.g. 10.0.1.5,192.168.1.0/24) — recommended\n" + printf "3) None — trust no upstream proxies, log the direct peer IP only\n" + printf "4) Cancel\n" + printf "\n" + + read -p "Select an option [1-4]: " trusted_option + + NEW_VALUE="" + case $trusted_option in + 1) + NEW_VALUE="" + ;; + 2) + while true; do + read -p "Enter comma-separated CIDRs/IPs: " CUSTOM_LIST + if [ -z "$CUSTOM_LIST" ]; then + printf "${YELLOW}> List cannot be empty. Use option 1 to restore default or option 3 to trust none.${NC}\n" + continue + fi + + INVALID="" + CLEANED="" + OLD_IFS="$IFS" + IFS=',' + for token in $CUSTOM_LIST; do + token=$(printf '%s' "$token" | tr -d '[:space:]') + [ -z "$token" ] && continue + if is_valid_cidr "$token"; then + if [ -z "$CLEANED" ]; then + CLEANED="$token" + else + CLEANED="$CLEANED,$token" + fi + else + if [ -z "$INVALID" ]; then + INVALID="$token" + else + INVALID="$INVALID, $token" + fi + fi + done + IFS="$OLD_IFS" + + if [ -n "$INVALID" ]; then + printf "${YELLOW}> Invalid entries: ${INVALID}${NC}\n" + printf "${YELLOW}> Each entry must be an IPv4/IPv6 address with an optional /mask. Try again.${NC}\n" + continue + fi + + NEW_VALUE="$CLEANED" + break + done + ;; + 3) + NEW_VALUE="none" + ;; + 4) + printf "${YELLOW}Trusted proxies configuration cancelled.${NC}\n" + return 0 + ;; + *) + printf "${RED}Invalid option selected.${NC}\n" + return 1 + ;; + esac + + update_env_var "TRUSTED_PROXIES" "$NEW_VALUE" + + printf "\n${YELLOW}Warning: Docker containers need to be restarted to apply these changes.${NC}\n" + read -p "Restart now? (y/n): " restart_confirm + + if [ "$restart_confirm" != "y" ] && [ "$restart_confirm" != "Y" ]; then + printf "${YELLOW}Please restart manually to apply the changes.${NC}\n" + exit 0 + fi + + handle_restart + + printf "\n" + if [ -z "$NEW_VALUE" ]; then + print_success_box "Trusted proxies reset to default (all RFC1918 ranges)." + elif [ "$NEW_VALUE" = "none" ]; then + print_success_box "Trusted proxies disabled — X-Forwarded-For will be ignored." + else + print_success_box "Trusted proxies set to: ${NEW_VALUE}" + fi +} + check_and_populate_env() { printf "${CYAN}ℹ Checking .env values...${NC} ${GREEN}✓${NC}\n" @@ -3517,6 +3669,12 @@ check_and_populate_env() { update_env_var "ADMIN_IP_ALLOWLIST" "" printf " Set ADMIN_IP_ALLOWLIST\n" fi + + # TRUSTED_PROXIES + if ! grep -q "^TRUSTED_PROXIES=" "$ENV_FILE" 2>/dev/null; then + update_env_var "TRUSTED_PROXIES" "" + printf " Set TRUSTED_PROXIES\n" + fi } main "$@"