Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/container-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
18 changes: 16 additions & 2 deletions etc/nginx/default.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
43 changes: 41 additions & 2 deletions etc/nginx/generate-site.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
import ipaddress
import re
import sys

import django
Expand All @@ -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,
Expand All @@ -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"}
Expand All @@ -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,
Expand Down
37 changes: 18 additions & 19 deletions start
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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" \
Expand Down Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions tests/test-nginx-config.py
Original file line number Diff line number Diff line change
@@ -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()
Loading