From 86c9c095d56151846c2950f2402813a34be1c469 Mon Sep 17 00:00:00 2001 From: Clara Vanacker Date: Mon, 20 Jul 2026 12:37:44 +0200 Subject: [PATCH] fix(privacy): fetch the star count server-side, drop all third-party calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer fetched api.github.com from the visitor's browser on every page load, paste pages included. On a zero-knowledge paste service that is the wrong shape: opening a secret link disclosed the reader's IP, User-Agent and the timing of the read to a third party. The service promises the server cannot see your content, then had the client announce itself elsewhere. The count is now fetched by the server at most once an hour and rendered into the page. GITHUB_REPO="" disables the outbound call entirely for air-gapped deployments; the footer then links to GitHub without a number, and never shows a misleading 0 when the count is simply unknown. Refreshing runs as a background task started in lifespan and cancelled on shutdown, with the fetch on a worker thread so a hanging GitHub cannot block the event loop. Failures keep the previous value and are logged, never raised: a vanity counter must not affect serving pastes. Uses urllib rather than httpx — httpx is a test-only dependency here, and webhook.py already establishes stdlib HTTP as the convention for outbound calls. With nothing calling out any more, connect-src drops back to 'self'. Verified in a real browser on both page types: zero third-party requests, no CSP violations, footer renders the count, and a paste round-trips end to end. --- .env.example | 5 ++++ app/config.py | 6 ++++ app/main.py | 13 ++++++-- app/stars.py | 71 ++++++++++++++++++++++++++++++++++++++++++++ static/footer.js | 32 -------------------- templates/base.html | 1 - templates/index.html | 2 +- templates/paste.html | 2 +- tests/test_api.py | 33 ++++++++++++++++++++ 9 files changed, 127 insertions(+), 38 deletions(-) create mode 100644 app/stars.py delete mode 100644 static/footer.js diff --git a/.env.example b/.env.example index 7b222f4..c32f046 100644 --- a/.env.example +++ b/.env.example @@ -43,5 +43,10 @@ TRUST_PROXY_HEADERS=false # otherwise make the app advertise http:// URLs. A bad value fails fast at startup. # BASE_URL= +# Repo whose stargazer count the footer shows, fetched server-side once an hour. +# Leave empty to disable the outbound call entirely (air-gapped deployments) — +# the footer then just links to GitHub without a number. +# GITHUB_REPO=stackopshq/ghostbit + # Server port (default: 8000) PORT=8000 diff --git a/app/config.py b/app/config.py index 7a0a0d1..80099ab 100644 --- a/app/config.py +++ b/app/config.py @@ -34,6 +34,12 @@ class Settings(BaseSettings): # a TLS-terminating proxy would otherwise leave the app advertising http://. base_url: str = "" + # Repo whose stargazer count the footer shows, fetched server-side once an + # hour. Set to "" to disable the outbound call entirely — the footer then + # omits the number. Never fetched from the visitor's browser: that would + # disclose every visitor's IP to GitHub, paste readers included. + github_repo: str = "stackopshq/ghostbit" + # Ignore extra env vars (e.g. a stale ENCRYPTION_KEY from pre-E2E setups) # instead of failing at startup. model_config = {"env_file": ".env", "extra": "ignore"} diff --git a/app/main.py b/app/main.py index e721b13..f462f8a 100644 --- a/app/main.py +++ b/app/main.py @@ -23,7 +23,7 @@ from slowapi.errors import RateLimitExceeded from starlette.middleware.base import BaseHTTPMiddleware -from . import __version__, metrics +from . import __version__, metrics, stars from .api import router as api_router from .config import settings from .languages import codemirror_mode_map, extension_map, slugs @@ -45,7 +45,9 @@ # practical impact is limited (no script execution via CSS in current # browsers, modulo already-broken setups). # -# api.github.com is whitelisted for the footer star counter. +# connect-src is 'self' only: nothing on these pages talks to a third party. +# The footer star count is fetched server-side (app/stars.py) precisely so the +# visitor's browser never contacts GitHub. def _build_csp(nonce: str) -> str: # 'wasm-unsafe-eval' lets hash-wasm instantiate its embedded Argon2id # WebAssembly module. Modern (Chrome 92+, Firefox 122+) browsers honor @@ -58,7 +60,7 @@ def _build_csp(nonce: str) -> str: "style-src 'self' 'unsafe-inline'; " "img-src 'self' data:; " "font-src 'self'; " - "connect-src 'self' https://api.github.com; " + "connect-src 'self'; " "frame-ancestors 'none'; " "base-uri 'self'; " "form-action 'self'; " @@ -167,7 +169,11 @@ async def dispatch(self, request: Request, call_next): @asynccontextmanager async def lifespan(app: FastAPI): app.state.storage = await get_storage() + app.state.background_tasks = [] + stars.start(app.state.background_tasks) yield + for task in app.state.background_tasks: + task.cancel() await app.state.storage.close() @@ -343,6 +349,7 @@ def _sri(path: str) -> str: templates.env.globals["sri"] = _sri +templates.env.globals["star_count"] = stars.get_count def _abs_url(request: Request, path: str) -> str: diff --git a/app/stars.py b/app/stars.py new file mode 100644 index 0000000..4e0bc65 --- /dev/null +++ b/app/stars.py @@ -0,0 +1,71 @@ +"""Server-side stargazer count for the footer. + +The footer used to fetch api.github.com from the visitor's browser. On a +zero-knowledge paste service that is the wrong shape: it disclosed the IP of +every visitor to GitHub, including someone opening a secret paste link, and it +cost a third-party connection on the critical path of every page load. + +The server now fetches the count at most once an hour and renders it into the +page. Deployments with no egress, or self-hosters who want no outbound calls at +all, set GITHUB_REPO="" and the footer simply omits the number. + +Uses urllib rather than httpx: httpx is a test-only dependency here, and +webhook.py already establishes stdlib HTTP as the convention for outbound calls. +""" + +import asyncio +import json +import logging +import urllib.error +import urllib.request + +from .config import settings + +log = logging.getLogger(__name__) + +_REFRESH_INTERVAL = 3600 # GitHub allows 60 unauthenticated calls/hour/IP +_TIMEOUT = 5 + +# None means "no number to show" — either never fetched, or every attempt +# failed. The template omits the count rather than rendering a misleading 0. +_count: int | None = None + + +def get_count() -> int | None: + """Last known stargazer count, or None if unavailable.""" + return _count + + +def _fetch_blocking(repo: str) -> int | None: + req = urllib.request.Request( + f"https://api.github.com/repos/{repo}", + headers={"Accept": "application/vnd.github+json", "User-Agent": "ghostbit"}, + ) + with urllib.request.urlopen(req, timeout=_TIMEOUT) as r: # noqa: S310 - fixed https host + data = json.load(r) + count = data.get("stargazers_count") + return int(count) if isinstance(count, int) else None + + +async def _refresh_loop() -> None: + global _count + while True: + try: + # to_thread so a slow or hanging GitHub never blocks the event loop. + fetched = await asyncio.to_thread(_fetch_blocking, settings.github_repo) + if fetched is not None: + _count = fetched + except (urllib.error.URLError, TimeoutError, ValueError, OSError) as e: + # Keep the previous value: a stale count beats a disappearing one, + # and this must never affect serving pastes. + log.warning("stargazer count refresh failed: %s", e) + await asyncio.sleep(_REFRESH_INTERVAL) + + +def start(loop_task_holder: list) -> None: + """Start the background refresher unless GITHUB_REPO is empty.""" + if not settings.github_repo: + return + # Keep a strong reference: bare asyncio tasks can be garbage-collected + # mid-flight (same reason webhook.py holds its delivery tasks). + loop_task_holder.append(asyncio.create_task(_refresh_loop())) diff --git a/static/footer.js b/static/footer.js deleted file mode 100644 index 52ee38b..0000000 --- a/static/footer.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Footer star counter — reads the GitHub repo stargazer count once per - * hour per browser and caches it in localStorage to stay polite with - * the API (unauthenticated rate limit is 60/h/IP). - */ -(function () { - const el = document.getElementById('footerStarCount'); - const key = 'gb_stars'; - const tsKey = 'gb_stars_ts'; - const TTL = 3600 * 1000; - - function show(n) { - if (el && n != null) el.textContent = n >= 1000 ? (n / 1000).toFixed(1) + 'k' : n; - } - - const cached = localStorage.getItem(key); - const ts = parseInt(localStorage.getItem(tsKey) || '0'); - if (cached && Date.now() - ts < TTL) { - show(parseInt(cached)); - return; - } - - fetch('https://api.github.com/repos/stackopshq/ghostbit') - .then(r => r.json()) - .then(d => { - if (d.stargazers_count == null) return; - localStorage.setItem(key, d.stargazers_count); - localStorage.setItem(tsKey, Date.now()); - show(d.stargazers_count); - }) - .catch(() => {}); -})(); diff --git a/templates/base.html b/templates/base.html index 07f33ac..8ef9421 100644 --- a/templates/base.html +++ b/templates/base.html @@ -82,6 +82,5 @@ {% block scripts %}{% endblock %} - diff --git a/templates/index.html b/templates/index.html index 6191740..d1b9702 100644 --- a/templates/index.html +++ b/templates/index.html @@ -153,7 +153,7 @@

{% endblock %} diff --git a/templates/paste.html b/templates/paste.html index 7b1aab1..fe94fe2 100644 --- a/templates/paste.html +++ b/templates/paste.html @@ -176,7 +176,7 @@

Scan to open

diff --git a/tests/test_api.py b/tests/test_api.py index 4433d46..d929b48 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -502,3 +502,36 @@ async def test_security_headers_present(client): assert r.headers.get("x-frame-options") == "DENY" assert r.headers.get("referrer-policy") == "no-referrer" assert "default-src 'self'" in r.headers.get("content-security-policy", "") + + +async def test_footer_does_not_call_github_from_the_browser(client): + """The star count is fetched server-side. A browser-side call would disclose + every visitor's IP to GitHub — including someone opening a secret paste.""" + html = (await client.get("/")).text + assert "api.github.com" not in html + assert "footer.js" not in html + + pid = (await client.post("/api/v1/pastes", json=_fake_paste())).json()["id"] + paste_html = (await client.get(f"/{pid}")).text + assert "api.github.com" not in paste_html + + +async def test_csp_forbids_third_party_connections(client): + """connect-src must stay 'self'-only now that nothing calls out.""" + csp = (await client.get("/")).headers["content-security-policy"] + connect = next(d for d in csp.split(";") if d.strip().startswith("connect-src")) + assert connect.strip() == "connect-src 'self'" + + +async def test_footer_degrades_when_star_count_unavailable(client, monkeypatch): + """No count yet (no egress, disabled, or GitHub down) must not break the + footer — it falls back to a plain call to action, never a misleading 0.""" + from app import stars + + monkeypatch.setattr(stars, "_count", None) + html = (await client.get("/")).text + assert "Star us on GitHub" in html + + monkeypatch.setattr(stars, "_count", 1234) + html = (await client.get("/")).text + assert "1234 stars on GitHub" in html