fix(news): harden the feed proxy + honor the self-host privacy promise

Security review follow-ups (all LOW):
- proxy: negative cache on upstream failure (no outbound fetch per
  request while velxio.dev is unreachable) and a 2MB body cap via
  streaming read (httpx timeouts are per read op, not per body)
- NewsModal: OSS builds render markdown images as alt text so viewers'
  browsers never contact remote hosts; pro cloud builds show them
This commit is contained in:
David Montero Crespo 2026-08-05 15:59:03 +02:00
parent 801bb94eab
commit 6205e32741
2 changed files with 41 additions and 5 deletions

View File

@ -14,6 +14,7 @@ The upstream feed is plain public JSON (no auth, no identifiers sent).
from __future__ import annotations
import json
import logging
import os
import time
@ -27,6 +28,14 @@ router = APIRouter()
DEFAULT_FEED_URL = "https://velxio.dev/api/pro/news/feed"
_CACHE_TTL_SECONDS = 6 * 60 * 60
# On a failed upstream fetch, don't retry for a while — otherwise every
# anonymous /feed hit while upstream is unreachable opens a fresh 5s
# outbound connection (a request loop would turn this instance into a
# slow hammer against the feed host).
_FAIL_RETRY_SECONDS = 5 * 60
# Upstream body cap. httpx's timeout is per read operation, so a
# byte-dripping upstream could otherwise deliver an unbounded body.
_MAX_FEED_BYTES = 2_000_000
_cache: dict = {"at": 0.0, "posts": None}
@ -79,12 +88,23 @@ async def news_feed() -> list[dict]:
url = os.environ.get("VELXIO_NEWS_URL", DEFAULT_FEED_URL)
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(url)
resp.raise_for_status()
posts = _sanitize(resp.json())
async with client.stream("GET", url) as resp:
resp.raise_for_status()
size = 0
chunks: list[bytes] = []
async for chunk in resp.aiter_bytes():
size += len(chunk)
if size > _MAX_FEED_BYTES:
raise ValueError("news feed exceeds size cap")
chunks.append(chunk)
posts = _sanitize(json.loads(b"".join(chunks)))
except Exception as exc: # offline / DNS / upstream down — all fine
logger.debug("[news] feed fetch failed (%s); serving stale/empty", exc)
return _cache["posts"] or []
# Negative cache: keep serving the stale/empty result without
# touching the network again until the retry window passes.
_cache["posts"] = _cache["posts"] or []
_cache["at"] = now - _CACHE_TTL_SECONDS + _FAIL_RETRY_SECONDS
return _cache["posts"]
_cache["posts"] = posts
_cache["at"] = now

View File

@ -17,6 +17,13 @@ import './NewsModal.css';
const FETCH_DELAY_MS = 4000;
// Self-hosted privacy promise: the feed is proxied through the local
// backend precisely so users' browsers never contact velxio.dev — an
// inline remote image would undo that (one request per viewer). OSS
// builds therefore render images as their alt text; the pro cloud build
// (VITE_PRO_BUILD) shows them normally.
const SHOW_REMOTE_IMAGES = import.meta.env.VITE_PRO_BUILD === 'true';
export function NewsModal() {
const [post, setPost] = useState<NewsPost | null>(null);
@ -58,7 +65,16 @@ export function NewsModal() {
</button>
</div>
<div className="velxio-news-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{post.body_md}</ReactMarkdown>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={
SHOW_REMOTE_IMAGES
? undefined
: { img: ({ alt }) => <em>[{alt || 'image'}]</em> }
}
>
{post.body_md}
</ReactMarkdown>
</div>
<div className="velxio-news-footer">
<button className="velxio-news-ok" onClick={() => setPost(null)}>