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
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description = "Backend for the Climate Rapid Evaluation Framework"
requires-python = ">=3.12"
dependencies = [
"fastapi[standard]<1.0.0,>=0.114.2",
"httpx>=0.27",
"pydantic>2.0",
"psycopg[binary]<4.0.0,>=3.1.13",
"pydantic-settings<3.0.0,>=2.13.1",
Expand Down
79 changes: 79 additions & 0 deletions backend/src/ref_backend/analytics.py
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)

Copy link
Copy Markdown

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.py

Repository: 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/architecture

Length of output: 26993


🏁 Script executed:

printf '%s\n' '--- deployment files ---'
git ls-files | rg '(^|/)(netlify\.toml|Dockerfile[^/]*|docker-compose[^/]*|nginx[^/]*|traefik[^/]*|caddy[^/]*|helm|k8s|kubernetes|ingress|fly\.toml|render\.yaml|railway\.json)$|(^|/)(netlify|deploy|deployment|infrastructure)/'
printf '%s\n' '--- request-size controls ---'
rg -n -i --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' \
  'client_max_body_size|max_body_size|max_request_body|request[_-]?size|body[_-]?limit|limit[_-]?request|payload[_-]?limit|MAX_BODY|CONTENT_LENGTH' .

Repository: Climate-REF/ref-app

Length of output: 317


🏁 Script executed:

for f in Dockerfile frontend/config/nginx.conf frontend/config/nginx-backend-not-found.conf frontend/netlify.toml; do
  printf '\n--- %s ---\n' "$f"
  sed -n '1,220p' "$f"
done

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 oversized Content-Length values and enforce the same limit while reading chunked bodies. Keep the ingress limit as defence in depth.

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Return 202 for upstream 5xx responses.

httpx.AsyncClient.post() does not raise for an upstream 5xx response. This line returns that failure to the tracker, which breaks the stated best-effort event contract. Preserve upstream 4xx responses, but map upstream 5xx responses to 202 and add a mock-5xx test.

media_type=upstream.headers.get("content-type"),
)
4 changes: 4 additions & 0 deletions backend/src/ref_backend/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from climate_ref.config import Config
from climate_ref.database import Database
from ref_backend.analytics import router as analytics_router
from ref_backend.api.main import api_router
from ref_backend.core.config import Settings

Expand Down Expand Up @@ -130,6 +131,9 @@ def build_app(settings: Settings, ref_config: Config, database: Database) -> Fas

app.include_router(api_router, prefix=settings.API_V1_STR)

# Mounted above the static files, which only answer GET and HEAD.
app.include_router(analytics_router)

if settings.STATIC_DIR:
logger.info(f"Serving static files from {settings.STATIC_DIR}")
app.mount(
Expand Down
82 changes: 82 additions & 0 deletions backend/tests/test_analytics.py
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
2 changes: 2 additions & 0 deletions backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions changelog/95.fix.md
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.
5 changes: 3 additions & 2 deletions frontend/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ import { WelcomeModal } from "@/components/app/welcomeModal";
import { useApiEndpoint } from "@/hooks/useApiEndpoint";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";

// Initialize Plausible Analytics
// The endpoint is relative so that events go to whichever origin is serving the app.
// Both Netlify and the container image proxy /log through to plausible.io.
init({
domain: "climate-ref.org",
endpoint: "https://staging.climate-ref.org/log/api/event",
endpoint: "/log/api/event",
outboundLinks: true,
captureOnLocalhost: false,
fileDownloads: true,
Expand Down
Loading