Add trusted_proxies env var to control nginx x-forwarded-for behaviour (#1982)

This commit is contained in:
Leendert de Borst
2026-05-01 14:15:05 +02:00
committed by Leendert de Borst
parent 04968032fd
commit c9eaf2d807
11 changed files with 379 additions and 20 deletions

View File

@@ -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=

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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 "$@"