diff --git a/README.md b/README.md index 9490c2b..aca0a5e 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,35 @@ Additional Gunicorn options can be passed through `GUNICORN_CMD_ARGS`. The application factory is in `src/app/__init__.py`; API routes live in `src/app/routes/api.py`. +## Behind Caddy + +For a single Caddy proxy running on the host, add these settings to the Quadlet's +`[Container]` section: + +```ini +Environment=PROXY_PASS=1 +PublishPort=127.0.0.1:12345:8000 +``` + +To serve the app under e.g., `/!/placeholder`, configure the Caddyfile: + +```caddyfile +files.example.com { + handle_path /!/placeholder/* { + reverse_proxy 127.0.0.1:12345 { + header_up X-Forwarded-Prefix /!/placeholder + } + } +} +``` + +Images are available at `/!/placeholder/example.png` or `.svg`, and health checks +at `/!/placeholder/healthz`. Caddy strips the prefix before forwarding. + +`PROXY_PASS=1` trusts one proxy's forwarded headers for client IPs and public URLs. +It is disabled by default. Enable it only behind a trusted, public-facing Caddy +proxy, and keep the backend port bound to loopback as shown. + ## Development checks Install the Git hooks once after cloning and running `uv sync`: diff --git a/src/app/__init__.py b/src/app/__init__.py index 8f3d704..75eaa57 100644 --- a/src/app/__init__.py +++ b/src/app/__init__.py @@ -1,4 +1,7 @@ +import os + from flask import Flask +from werkzeug.middleware.proxy_fix import ProxyFix from app.extensions import cache, limiter from app.routes import api @@ -7,6 +10,9 @@ from app.routes import api def create_app() -> Flask: app = Flask(__name__) app.config.from_object("app.config") + # Enable only when clients can reach the app through one trusted proxy. + if os.environ.get("PROXY_PASS") == "1": + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=0, x_prefix=1) cache.init_app(app) limiter.init_app(app) app.register_blueprint(api, url_prefix="/") diff --git a/tests/conftest.py b/tests/conftest.py index 0591615..2cf5d47 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,11 @@ from app.extensions import cache, limiter @pytest.fixture -def app(): +def app(monkeypatch, request): + # Ignore the host's deployment setting; proxy tests opt in via indirect parameters. + monkeypatch.delenv("PROXY_PASS", raising=False) + if getattr(request, "param", False): + monkeypatch.setenv("PROXY_PASS", "1") application = create_app() application.config["TESTING"] = True # Keep cached images and rate-limit counters isolated between tests. diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..ca5d9d4 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,75 @@ +import pytest +from flask import request, url_for + +from app import config + + +@pytest.mark.parametrize( + ("app", "expected_ip", "expected_scheme", "expected_host", "expected_prefix"), + [ + (False, "127.0.0.1", "http", "localhost", ""), + (True, "192.0.2.1", "https", "files.example.com", "/!/placeholder"), + ], + indirect=["app"], +) +def test_forwarded_headers(app, client, expected_ip, expected_scheme, expected_host, expected_prefix): + @app.get("/request-info") + def request_info(): + return { + "ip": request.remote_addr, + "scheme": request.scheme, + "host": request.host, + "prefix": request.script_root, + "health_url": url_for("healthz", _external=True), + } + + response = client.get( + "/request-info", + headers={ + "X-Forwarded-For": "198.51.100.1, 192.0.2.1", + "X-Forwarded-Proto": "http, https", + "X-Forwarded-Host": "untrusted.example, files.example.com", + "X-Forwarded-Port": "1234", + "X-Forwarded-Prefix": "/untrusted, /!/placeholder", + }, + ) + + assert response.status_code == 200 + assert response.json == { + "ip": expected_ip, + "scheme": expected_scheme, + "host": expected_host, + "prefix": expected_prefix, + "health_url": f"{expected_scheme}://{expected_host}{expected_prefix}/healthz", + } + + +@pytest.mark.parametrize("app", [True], indirect=True) +@pytest.mark.parametrize("extension", ["svg", "png"]) +def test_image_with_stripped_proxy_prefix(app, client, extension): + # Caddy removes the prefix from the path and supplies it separately in a header. + response = client.get( + f"/placeholder.{extension}?width=10&height=10", + headers={"X-Forwarded-Prefix": "/!/placeholder"}, + ) + assert response.status_code == 200 + assert response.mimetype == ("image/svg+xml" if extension == "svg" else "image/png") + + +@pytest.mark.parametrize(("app", "other_client_status"), [(False, 429), (True, 200)], indirect=["app"]) +@pytest.mark.parametrize("extension", ["svg", "png"]) +def test_rate_limit_client_identity(app, client, monkeypatch, extension, other_client_status): + monkeypatch.setattr(config, f"{extension.upper()}_RENDER_LIMIT", "1/minute") + + def render(width, forwarded_for): + return client.get( + f"/placeholder.{extension}", + query_string={"width": width, "height": 10}, + headers={"X-Forwarded-For": forwarded_for}, + ) + + assert render(10, "198.51.100.1, 192.0.2.1").status_code == 200 + # Changing an earlier header value cannot bypass the same client's quota. + assert render(11, "198.51.100.2, 192.0.2.1").status_code == 429 + # Distinct forwarded clients get separate quotas only when proxy trust is enabled. + assert render(12, "192.0.2.2").status_code == other_client_status