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()