-
Notifications
You must be signed in to change notification settings - Fork 1
Proxy Plausible analytics through the backend #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| """ | ||
| Same-origin proxy for Plausible Analytics. | ||
|
|
||
| The tracker posts to a path on this app rather than to plausible.io directly, | ||
| so that content blockers and strict connect-src policies do not drop the events. | ||
| Netlify deploys handle the same paths through redirects in `frontend/netlify.toml`, | ||
| so both deploy targets need to stay in step. | ||
| """ | ||
|
|
||
| import httpx | ||
| from fastapi import APIRouter, Request, Response | ||
| from loguru import logger | ||
|
|
||
| PLAUSIBLE_SCRIPT_URL = "https://plausible.io/js/script.file-downloads.outbound-links.js" | ||
| PLAUSIBLE_EVENT_URL = "https://plausible.io/api/event" | ||
|
|
||
| UPSTREAM_TIMEOUT_SECONDS = 10.0 | ||
|
|
||
| router = APIRouter(prefix="/log", tags=["analytics"], include_in_schema=False) | ||
|
|
||
| _client = httpx.AsyncClient(timeout=UPSTREAM_TIMEOUT_SECONDS, follow_redirects=True) | ||
|
|
||
|
|
||
| def _forwarded_for(request: Request) -> str | None: | ||
| """ | ||
| Build the client chain that Plausible uses to derive a visitor hash. | ||
| """ | ||
| existing = request.headers.get("x-forwarded-for") | ||
| if existing: | ||
| return existing | ||
| if request.client: | ||
| return request.client.host | ||
| return None | ||
|
|
||
|
|
||
| @router.get("/script.js") | ||
| async def script() -> Response: | ||
| """ | ||
| Serve the Plausible tracker script from this origin. | ||
| """ | ||
| try: | ||
| upstream = await _client.get(PLAUSIBLE_SCRIPT_URL) | ||
| except httpx.HTTPError as exc: | ||
| logger.warning(f"Could not fetch the Plausible script: {exc}") | ||
| return Response(status_code=502) | ||
|
|
||
| return Response( | ||
| content=upstream.content, | ||
| status_code=upstream.status_code, | ||
| media_type=upstream.headers.get("content-type", "application/javascript"), | ||
| headers={"cache-control": upstream.headers.get("cache-control", "public, max-age=3600")}, | ||
| ) | ||
|
|
||
|
|
||
| @router.post("/api/event") | ||
| async def event(request: Request) -> Response: | ||
| """ | ||
| Forward a tracker event to Plausible. | ||
| """ | ||
| headers = { | ||
| "content-type": request.headers.get("content-type", "text/plain"), | ||
| "user-agent": request.headers.get("user-agent", ""), | ||
| } | ||
| forwarded_for = _forwarded_for(request) | ||
| if forwarded_for: | ||
| headers["x-forwarded-for"] = forwarded_for | ||
|
|
||
| try: | ||
| upstream = await _client.post(PLAUSIBLE_EVENT_URL, content=await request.body(), headers=headers) | ||
| except httpx.HTTPError as exc: | ||
| # A dropped event is not worth failing the page over. | ||
| logger.warning(f"Could not forward a Plausible event: {exc}") | ||
| return Response(status_code=202) | ||
|
|
||
| return Response( | ||
| content=upstream.content, | ||
| status_code=upstream.status_code, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Return
|
||
| media_type=upstream.headers.get("content-type"), | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| """ | ||
| Tests for the Plausible proxy. | ||
|
|
||
| The upstream calls are served by a mock transport, so no request leaves the test run. | ||
| """ | ||
|
|
||
| import httpx | ||
| import pytest | ||
| from starlette.testclient import TestClient | ||
|
|
||
| from ref_backend import analytics | ||
|
|
||
|
|
||
| @pytest.fixture() | ||
| def upstream_requests(monkeypatch): | ||
| """ | ||
| Capture the requests the proxy makes and answer them from a mock transport. | ||
| """ | ||
| captured: list[httpx.Request] = [] | ||
|
|
||
| def handler(request: httpx.Request) -> httpx.Response: | ||
| captured.append(request) | ||
| if request.url.path == "/api/event": | ||
| return httpx.Response(202, text="ok", headers={"content-type": "text/plain"}) | ||
| return httpx.Response( | ||
| 200, | ||
| text="console.log('tracker');", | ||
| headers={"content-type": "application/javascript", "cache-control": "public, max-age=60"}, | ||
| ) | ||
|
|
||
| monkeypatch.setattr(analytics, "_client", httpx.AsyncClient(transport=httpx.MockTransport(handler))) | ||
| return captured | ||
|
|
||
|
|
||
| def test_script_is_proxied(client: TestClient, upstream_requests): | ||
| response = client.get("/log/script.js") | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.text == "console.log('tracker');" | ||
| assert response.headers["content-type"].startswith("application/javascript") | ||
| assert response.headers["cache-control"] == "public, max-age=60" | ||
| assert str(upstream_requests[0].url) == analytics.PLAUSIBLE_SCRIPT_URL | ||
|
|
||
|
|
||
| def test_event_is_proxied(client: TestClient, upstream_requests): | ||
| response = client.post( | ||
| "/log/api/event", | ||
| content=b'{"n":"pageview"}', | ||
| headers={"content-type": "text/plain", "user-agent": "test-agent"}, | ||
| ) | ||
|
|
||
| assert response.status_code == 202 | ||
| assert response.text == "ok" | ||
|
|
||
| forwarded = upstream_requests[0] | ||
| assert str(forwarded.url) == analytics.PLAUSIBLE_EVENT_URL | ||
| assert forwarded.content == b'{"n":"pageview"}' | ||
| assert forwarded.headers["content-type"] == "text/plain" | ||
| assert forwarded.headers["user-agent"] == "test-agent" | ||
| assert forwarded.headers["x-forwarded-for"] == "testclient" | ||
|
|
||
|
|
||
| def test_event_survives_an_unreachable_upstream(client: TestClient, monkeypatch): | ||
| def handler(request: httpx.Request) -> httpx.Response: | ||
| raise httpx.ConnectError("upstream down", request=request) | ||
|
|
||
| monkeypatch.setattr(analytics, "_client", httpx.AsyncClient(transport=httpx.MockTransport(handler))) | ||
|
|
||
| response = client.post("/log/api/event", content=b'{"n":"pageview"}') | ||
|
|
||
| assert response.status_code == 202 | ||
|
|
||
|
|
||
| def test_script_reports_an_unreachable_upstream(client: TestClient, monkeypatch): | ||
| def handler(request: httpx.Request) -> httpx.Response: | ||
| raise httpx.ConnectError("upstream down", request=request) | ||
|
|
||
| monkeypatch.setattr(analytics, "_client", httpx.AsyncClient(transport=httpx.MockTransport(handler))) | ||
|
|
||
| response = client.get("/log/script.js") | ||
|
|
||
| assert response.status_code == 502 |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| Fixed page view analytics on the container deployments. | ||
| The backend now proxies `/log/script.js` and `/log/api/event` through to plausible.io, | ||
| so the tracker no longer hits the static file mount and gets a 405. | ||
| The tracker also posts to a relative path, so production stops reporting into the staging site. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
sed -n '1,130p' backend/src/ref_backend/analytics.pyRepository: Climate-REF/ref-app
Length of output: 2769
🤖 get_repo_knowledge executed:
get_repo_knowledge Climate-REF/ref-app /tmp/coderabbit-repo-knowledge/climate-ref-ref-app-33a75429/architectureLength of output: 26993
🏁 Script executed:
Repository: Climate-REF/ref-app
Length of output: 317
🏁 Script executed:
Repository: Climate-REF/ref-app
Length of output: 2881
Denial of Service (CWE-400): Uncontrolled Resource Consumption
Reachability: External · Exploitability: Trivial
Enforce a request-size limit before buffering event bodies.
The public endpoint buffers the complete request with
await request.body(). Reject oversizedContent-Lengthvalues and enforce the same limit while reading chunked bodies. Keep the ingress limit as defence in depth.