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:
parent
801bb94eab
commit
6205e32741
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue