From 99813f77631389ed637fa258e062554e2d332bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Fri, 14 Aug 2026 10:58:15 +0200 Subject: [PATCH] fix: restrict forwarded IP trust The built-in nginx previously trusted X-Forwarded-For from every IPv4 and IPv6 peer whenever Weblate proxy handling was enabled. A client able to reach the container directly could therefore choose the address recorded by nginx and consumed by Weblate security controls. Require operators to identify trusted proxy addresses, resolve the chain in nginx so access logs retain the real client IP, and forward one normalized address to Weblate at offset zero. This preserves useful logging while preventing the application from reinterpreting an attacker-controlled chain. --- .github/workflows/container-test.yml | 2 + README.md | 10 +++ docker-compose | 2 +- etc/nginx/default.tpl | 18 ++++- etc/nginx/generate-site.py | 43 ++++++++++- start | 37 +++++----- tests/test-nginx-config.py | 104 +++++++++++++++++++++++++++ 7 files changed, 192 insertions(+), 24 deletions(-) create mode 100644 tests/test-nginx-config.py diff --git a/.github/workflows/container-test.yml b/.github/workflows/container-test.yml index c4d0ad959..11c030690 100644 --- a/.github/workflows/container-test.yml +++ b/.github/workflows/container-test.yml @@ -54,6 +54,8 @@ jobs: run: .github/bin/docker-build load - name: List Docker images run: docker image ls --all + - name: Test nginx configuration generation + run: python3 tests/test-nginx-config.py - name: Test content run: ./docker-compose/test-content - name: Generate configuration diff --git a/README.md b/README.md index 10f9750f7..9bd190cd6 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,16 @@ used by over 2500 libre projects and companies in more than 165 countries.** The webserver is running on the port 8080. +## Reverse proxy addresses + +When `WEBLATE_IP_PROXY_HEADER=HTTP_X_FORWARDED_FOR` is enabled, configure +`WEBLATE_TRUSTED_PROXY_ADDRESSES` with a whitespace-separated list of the IP +addresses, networks, or hostnames of reverse proxies allowed to supply client +addresses. The built-in nginx uses the resolved address both in its logs and +when forwarding the request to Weblate. With an empty list, it uses the +immediate TCP peer. Because nginx forwards a single normalized address, the +container uses an effective `WEBLATE_IP_PROXY_OFFSET` of `0` in this mode. + ## Documentation Detailed documentation is available in [Weblate documentation][doc]. diff --git a/docker-compose b/docker-compose index 98d116c98..985f9c583 160000 --- a/docker-compose +++ b/docker-compose @@ -1 +1 @@ -Subproject commit 98d116c98368a7826b99a1b9c6787f4ca8365793 +Subproject commit 985f9c583c0005150f99eee29466477ea57d08a8 diff --git a/etc/nginx/default.tpl b/etc/nginx/default.tpl index e14cadb31..8da89ca5c 100644 --- a/etc/nginx/default.tpl +++ b/etc/nginx/default.tpl @@ -33,7 +33,13 @@ server { error_page 502 /__weblate_starting__.html; error_page 504 /__weblate_timeout__.html; - {{ WEBLATE_REALIP }} +{% if USE_X_FORWARDED_FOR %} + real_ip_header X-Forwarded-For; + real_ip_recursive on; +{% for address in TRUSTED_PROXY_ADDRESSES %} + set_real_ip_from {{ address }}; +{% endfor %} +{% endif %} location = /__weblate_starting__.html { internal; @@ -58,7 +64,11 @@ server { proxy_pass {{ WEBLATE_ANUBIS_URL }}; auth_request off; proxy_set_header X-Real-IP $remote_addr; +{% if USE_X_FORWARDED_FOR %} + proxy_set_header X-Forwarded-For $remote_addr; +{% else %} proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +{% endif %} proxy_set_header Host $http_host; proxy_set_header X-Original-URI $request_uri; proxy_set_header X-Forwarded-Host $http_host; @@ -90,8 +100,12 @@ server { expires 30d; } -{% if WEBLATE_BUILTIN_SSL %} +{% if USE_X_FORWARDED_FOR %} + proxy_set_header X-Forwarded-For $remote_addr; +{% elif WEBLATE_BUILTIN_SSL %} proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +{% endif %} +{% if WEBLATE_BUILTIN_SSL %} proxy_set_header X-Forwarded-Proto $scheme; {% endif %} proxy_set_header Host $http_host; diff --git a/etc/nginx/generate-site.py b/etc/nginx/generate-site.py index f0e308993..76211ee74 100755 --- a/etc/nginx/generate-site.py +++ b/etc/nginx/generate-site.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +import ipaddress +import re import sys import django @@ -8,7 +10,8 @@ ( TEMPLATE_DIRS, WEBLATE_URL_PREFIX, - WEBLATE_REALIP, + WEBLATE_IP_PROXY_HEADER, + TRUSTED_PROXY_ADDRESSES_RAW, CLIENT_MAX_BODY_SIZE, WEBLATE_BUILTIN_SSL, WEBLATE_ANUBIS_URL, @@ -18,6 +21,41 @@ ENABLE_IPV6, ) = sys.argv[1:] + +HOSTNAME_LABEL = re.compile(r"[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?") + + +def is_hostname(value: str) -> bool: + value = value.removesuffix(".") + return 0 < len(value) <= 253 and all( + HOSTNAME_LABEL.fullmatch(label) for label in value.split(".") + ) + + +def parse_trusted_proxy_addresses(value: str) -> list[str]: + result = [] + for address in value.split(): + try: + if "/" in address: + ipaddress.ip_network(address, strict=True) + else: + ipaddress.ip_address(address) + except ValueError: + if not is_hostname(address): + raise ValueError( + f"Invalid trusted proxy address: {address!r}" + ) from None + result.append(address) + return result + + +try: + TRUSTED_PROXY_ADDRESSES = parse_trusted_proxy_addresses(TRUSTED_PROXY_ADDRESSES_RAW) +except ValueError as error: + sys.exit(str(error)) + +USE_X_FORWARDED_FOR = WEBLATE_IP_PROXY_HEADER == "HTTP_X_FORWARDED_FOR" + WEBLATE_SITE_URL = "{}://{}".format( "https" if ENABLE_HTTPS and ENABLE_HTTPS.lower() not in {"0", "false", "no", "off"} @@ -43,7 +81,8 @@ template.render( { "WEBLATE_URL_PREFIX": WEBLATE_URL_PREFIX, - "WEBLATE_REALIP": WEBLATE_REALIP, + "USE_X_FORWARDED_FOR": USE_X_FORWARDED_FOR, + "TRUSTED_PROXY_ADDRESSES": TRUSTED_PROXY_ADDRESSES, "CLIENT_MAX_BODY_SIZE": CLIENT_MAX_BODY_SIZE, "WEBLATE_BUILTIN_SSL": WEBLATE_BUILTIN_SSL, "WEBLATE_ANUBIS_URL": WEBLATE_ANUBIS_URL, diff --git a/start b/start index 1eef3c6ad..857bbfb70 100755 --- a/start +++ b/start @@ -412,22 +412,6 @@ prepare_runtime_sockets_and_pids() { rm -f /run/celery/beat.pid } -configure_real_ip() { - # Parse upstream X-Forwarded-For. - case "$WEBLATE_IP_PROXY_HEADER" in - HTTP_X_FORWARDED_FOR) - WEBLATE_REALIP=" -real_ip_header X-Forwarded-For; -set_real_ip_from 0.0.0.0/0; -set_real_ip_from ::/0; -" - ;; - *) - WEBLATE_REALIP= - ;; - esac -} - detect_builtin_ssl() { # Detect SSL setup. if [[ -f /app/data/ssl/privkey.pem ]]; then @@ -441,13 +425,24 @@ detect_builtin_ssl() { fi } -generate_nginx_config() { - configure_real_ip +configure_forwarded_for() { detect_builtin_ssl + if [[ $WEBLATE_IP_PROXY_HEADER != "HTTP_X_FORWARDED_FOR" ]]; then + return 0 + fi + + if [[ -n ${WEBLATE_IP_PROXY_OFFSET:-} && $WEBLATE_IP_PROXY_OFFSET != 0 ]]; then + echo "Ignoring WEBLATE_IP_PROXY_OFFSET=$WEBLATE_IP_PROXY_OFFSET: the built-in nginx normalizes X-Forwarded-For to one address." + fi + export WEBLATE_IP_PROXY_OFFSET=0 +} + +generate_nginx_config() { # Make sure WEBLATE_ANUBIS_URL is set. : "${WEBLATE_ANUBIS_URL:=}" : "${WEBLATE_ENABLE_HTTPS:=}" + : "${WEBLATE_TRUSTED_PROXY_ADDRESSES:=}" if ipv6_available; then WEBLATE_ENABLE_IPV6=1 else @@ -460,7 +455,8 @@ generate_nginx_config() { /etc/nginx/generate-site.py \ /etc/nginx \ "$WEBLATE_URL_PREFIX" \ - "$WEBLATE_REALIP" \ + "$WEBLATE_IP_PROXY_HEADER" \ + "$WEBLATE_TRUSTED_PROXY_ADDRESSES" \ "$CLIENT_MAX_BODY_SIZE" \ "$WEBLATE_BUILTIN_SSL" \ "$WEBLATE_ANUBIS_URL" \ @@ -667,6 +663,9 @@ main() { export_runtime_env configure_timezone ensure_customize_app + if [[ ${1:-} == "runserver" ]]; then + configure_forwarded_for + fi check_weblate_config if [[ ${1:-} == "runserver" ]]; then start_early_nginx diff --git a/tests/test-nginx-config.py b/tests/test-nginx-config.py new file mode 100644 index 000000000..de865ef90 --- /dev/null +++ b/tests/test-nginx-config.py @@ -0,0 +1,104 @@ +import os +import subprocess +import unittest +from pathlib import Path + +IMAGE = os.environ.get("TEST_CONTAINER", "weblate/weblate:test") +LOCAL_PYTHON = os.environ.get("GENERATE_SITE_PYTHON") +ROOT = Path(__file__).resolve().parents[1] + + +def generate_config( + proxy_header: str = "", trusted_proxy_addresses: str = "" +) -> subprocess.CompletedProcess[str]: + if LOCAL_PYTHON: + command = [ + LOCAL_PYTHON, + str(ROOT / "etc/nginx/generate-site.py"), + str(ROOT / "etc/nginx"), + ] + else: + command = [ + "docker", + "run", + "--rm", + "--entrypoint", + "/app/venv/bin/python", + IMAGE, + "/etc/nginx/generate-site.py", + "/etc/nginx", + ] + return subprocess.run( + [ + *command, + "", + proxy_header, + trusted_proxy_addresses, + "100m", + "", + "", + "test.example.com", + "", + "/run/granian/granian.sock", + "", + ], + check=False, + capture_output=True, + text=True, + ) + + +class NginxConfigTest(unittest.TestCase): + def test_forwarded_for_disabled(self) -> None: + result = generate_config() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("real_ip_header", result.stdout) + self.assertNotIn("set_real_ip_from", result.stdout) + + def test_forwarded_for_without_trusted_proxy(self) -> None: + result = generate_config("HTTP_X_FORWARDED_FOR") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("real_ip_header X-Forwarded-For;", result.stdout) + self.assertIn("real_ip_recursive on;", result.stdout) + self.assertNotIn("set_real_ip_from", result.stdout) + self.assertIn("proxy_set_header X-Forwarded-For $remote_addr;", result.stdout) + self.assertNotIn("$proxy_add_x_forwarded_for", result.stdout) + self.assertNotIn("0.0.0.0/0", result.stdout) + self.assertNotIn("::/0", result.stdout) + + def test_trusted_proxy_addresses(self) -> None: + addresses = ( + "192.0.2.10 198.51.100.0/24 2001:db8::1 2001:db8:1::/48 proxy.internal" + ) + result = generate_config("HTTP_X_FORWARDED_FOR", addresses) + + self.assertEqual(result.returncode, 0, result.stderr) + for address in addresses.split(): + self.assertIn(f"set_real_ip_from {address};", result.stdout) + + def test_trusted_proxies_do_not_enable_forwarded_for(self) -> None: + result = generate_config("HTTP_X_REAL_IP", "192.0.2.10") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("real_ip_header", result.stdout) + self.assertNotIn("set_real_ip_from", result.stdout) + + def test_invalid_trusted_proxy_address(self) -> None: + for address in ( + "192.0.2.1/24", + "192.0.2.1/33", + "proxy;include", + "proxy{", + "-proxy", + ): + with self.subTest(address=address): + result = generate_config("HTTP_X_FORWARDED_FOR", address) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("Invalid trusted proxy address", result.stderr) + + +if __name__ == "__main__": + unittest.main()