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