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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
13 changes: 10 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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'; "
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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:
Expand Down
71 changes: 71 additions & 0 deletions app/stars.py
Original file line number Diff line number Diff line change
@@ -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()))
32 changes: 0 additions & 32 deletions static/footer.js

This file was deleted.

1 change: 0 additions & 1 deletion templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,5 @@

{% block scripts %}{% endblock %}
<script src="/static/theme.js?v={{ v }}" defer></script>
<script src="/static/footer.js?v={{ v }}" defer></script>
</body>
</html>
2 changes: 1 addition & 1 deletion templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ <h1 class="terminal-hero">
</div><!-- /index-sidebar -->
</form>
<footer class="site-footer">
Made with ❤️ by <a href="https://github.com/stackopshq" target="_blank" rel="noopener"><strong>StackOps</strong></a> — Enjoying Ghostbit? We have <a href="https://github.com/stackopshq/ghostbit" target="_blank" rel="noopener" id="footerStarLink">⭐ <span id="footerStarCount">0</span> stars on GitHub</a> — yours would mean a lot!
Made with ❤️ by <a href="https://github.com/stackopshq" target="_blank" rel="noopener"><strong>StackOps</strong></a> — Enjoying Ghostbit? {% set stars = star_count() %}<a href="https://github.com/stackopshq/ghostbit" target="_blank" rel="noopener" id="footerStarLink">⭐ {% if stars is not none %}{{ stars }} stars on GitHub{% else %}Star us on GitHub{% endif %}</a> — yours would mean a lot!
</footer>
</div>
{% endblock %}
Expand Down
2 changes: 1 addition & 1 deletion templates/paste.html
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ <h3 id="qrModalTitle">Scan to open</h3>

</div>
<footer class="site-footer">
Made with ❤️ by <a href="https://github.com/stackopshq" target="_blank" rel="noopener"><strong>StackOps</strong></a> — Enjoying Ghostbit? We have <a href="https://github.com/stackopshq/ghostbit" target="_blank" rel="noopener" id="footerStarLink">⭐ <span id="footerStarCount">0</span> stars on GitHub</a> — yours would mean a lot!
Made with ❤️ by <a href="https://github.com/stackopshq" target="_blank" rel="noopener"><strong>StackOps</strong></a> — Enjoying Ghostbit? {% set stars = star_count() %}<a href="https://github.com/stackopshq/ghostbit" target="_blank" rel="noopener" id="footerStarLink">⭐ {% if stars is not none %}{{ stars }} stars on GitHub{% else %}Star us on GitHub{% endif %}</a> — yours would mean a lot!
</footer>
</div>

Expand Down
33 changes: 33 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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