Compare commits
No commits in common. "devel" and "v0.1.0" have entirely different histories.
29
README.md
29
README.md
|
|
@ -59,35 +59,6 @@ Additional Gunicorn options can be passed through `GUNICORN_CMD_ARGS`.
|
||||||
The application factory is in `src/app/__init__.py`; API routes live in
|
The application factory is in `src/app/__init__.py`; API routes live in
|
||||||
`src/app/routes/api.py`.
|
`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
|
## Development checks
|
||||||
|
|
||||||
Install the Git hooks once after cloning and running `uv sync`:
|
Install the Git hooks once after cloning and running `uv sync`:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.2.0"
|
version = "0.1.0"
|
||||||
description = "Placeholder image API with configurable dimensions and font size."
|
description = "Placeholder image API with configurable dimensions and font size."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "BSD-3-Clause"
|
license = "BSD-3-Clause"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import os
|
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
||||||
|
|
||||||
from app.extensions import cache, limiter
|
from app.extensions import cache, limiter
|
||||||
from app.routes import api
|
from app.routes import api
|
||||||
|
|
@ -10,9 +7,6 @@ from app.routes import api
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object("app.config")
|
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)
|
cache.init_app(app)
|
||||||
limiter.init_app(app)
|
limiter.init_app(app)
|
||||||
app.register_blueprint(api, url_prefix="/")
|
app.register_blueprint(api, url_prefix="/")
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,7 @@ from app.extensions import cache, limiter
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app(monkeypatch, request):
|
def app():
|
||||||
# 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 = create_app()
|
||||||
application.config["TESTING"] = True
|
application.config["TESTING"] = True
|
||||||
# Keep cached images and rate-limit counters isolated between tests.
|
# Keep cached images and rate-limit counters isolated between tests.
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
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
|
|
||||||
Loading…
Reference in a new issue