diff --git a/main.py b/main.py index 627c74a..d1bab4d 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,8 @@ import requests import humanize from datetime import datetime, timezone +from math import isfinite +from pydantic import BaseModel, ConfigDict, StrictFloat, StrictInt, ValidationError, field_validator # Print ASCII skull skull = r""" @@ -39,6 +41,60 @@ AUTH_TOKEN = None +class FreshRSSOrigin(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + title: str | None = None + + +class FreshRSSAlternate(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + href: str | None = None + + +class FreshRSSItem(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + title: str | None = None + origin: FreshRSSOrigin | None = None + published: StrictInt | StrictFloat | None = None + alternate: list[FreshRSSAlternate] | None = None + + @field_validator("published") + @classmethod + def validate_published_timestamp(cls, value): + if value is None: + return value + if not isfinite(value): + raise ValueError("timestamp must be finite") + try: + datetime.fromtimestamp(value, timezone.utc) + except (OverflowError, OSError, ValueError) as exc: + raise ValueError("timestamp is outside the supported range") from exc + return value + + +class FreshRSSResponse(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + items: list[FreshRSSItem] + + +def validate_freshrss_response(raw): + """Validate FreshRSS data, rejecting the whole malformed response. + + A response with any malformed item produces a sanitized 502 rather than a + partial result. This keeps upstream data-quality failures visible and gives + callers consistent all-or-nothing results. + """ + try: + return FreshRSSResponse.model_validate(raw) + except ValidationError as exc: + logging.warning("FreshRSS returned an invalid unread response: %s", exc) + raise HTTPException(status_code=502, detail="FreshRSS returned an invalid unread response") from exc + + def get_greader_token(): global AUTH_TOKEN if AUTH_TOKEN: @@ -92,25 +148,26 @@ def freshrss_unread( except requests.RequestException as exc: logging.warning("FreshRSS unread request failed: %s", exc) raise HTTPException(status_code=502, detail="FreshRSS unread request failed") from exc + response = validate_freshrss_response(raw) items = [] now = datetime.now(timezone.utc) - for entry in raw.get("items", []): - published_ts = entry.get("published") + for entry in response.items: + published_ts = entry.published if published_ts is None: continue published_dt = datetime.fromtimestamp(published_ts, timezone.utc) published_str = humanize.naturaltime(now - published_dt) - alternates = entry.get("alternate") or [] - item_url = alternates[0].get("href", "") if alternates else "" + alternates = entry.alternate or [] + item_url = alternates[0].href or "" if alternates else "" items.append( { - "title": entry.get("title"), - "feed": entry.get("origin", {}).get("title"), - "published": entry.get("published"), + "title": entry.title, + "feed": entry.origin.title if entry.origin else None, + "published": published_ts, "url": item_url, - "display": f"{entry.get('title')} • {published_str}", + "display": f"{entry.title} • {published_str}", } ) return items diff --git a/pyproject.toml b/pyproject.toml index 1309bfc..314b948 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "fastapi>=0.115.0", "uvicorn>=0.32.0", "humanize>=4.12.3", + "pydantic>=2.0", "requests>=2.32.4", ] diff --git a/tests/test_main.py b/tests/test_main.py index 8e624e9..4c90556 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -204,3 +204,82 @@ def test_freshrss_unread_wraps_upstream_http_errors(monkeypatch): assert excinfo.value.status_code == 502 assert excinfo.value.detail == "FreshRSS unread request failed" + + +@pytest.mark.parametrize("payload", [[], "not an object", None]) +def test_freshrss_unread_rejects_malformed_top_level_payload(monkeypatch, payload): + main = import_app(monkeypatch) + monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") + response = FakeResponse(payload={}) + response._payload = payload + monkeypatch.setattr(main.requests, "get", lambda *args, **kwargs: response) + + with pytest.raises(HTTPException) as excinfo: + main.freshrss_unread() + + assert excinfo.value.status_code == 502 + assert excinfo.value.detail == "FreshRSS returned an invalid unread response" + + +@pytest.mark.parametrize("items", [None, {}, "not a list", ["not an object"]]) +def test_freshrss_unread_rejects_invalid_items(monkeypatch, items): + main = import_app(monkeypatch) + monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") + monkeypatch.setattr(main.requests, "get", lambda *args, **kwargs: FakeResponse(payload={"items": items})) + + with pytest.raises(HTTPException) as excinfo: + main.freshrss_unread() + + assert excinfo.value.status_code == 502 + assert excinfo.value.detail == "FreshRSS returned an invalid unread response" + + +@pytest.mark.parametrize( + "invalid_field", + [ + {"origin": "not an object"}, + {"origin": []}, + {"alternate": "not a list"}, + {"alternate": {}}, + {"alternate": ["not an object"]}, + ], +) +def test_freshrss_unread_rejects_malformed_item_containers(monkeypatch, invalid_field): + main = import_app(monkeypatch) + monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") + item = {"title": "Bad item", "published": 1700000000, **invalid_field} + monkeypatch.setattr(main.requests, "get", lambda *args, **kwargs: FakeResponse(payload={"items": [item]})) + + with pytest.raises(HTTPException) as excinfo: + main.freshrss_unread() + + assert excinfo.value.status_code == 502 + assert excinfo.value.detail == "FreshRSS returned an invalid unread response" + + +@pytest.mark.parametrize("timestamp", ["1700000000", True, float("nan"), float("inf")]) +def test_freshrss_unread_rejects_nonnumeric_timestamps(monkeypatch, timestamp): + main = import_app(monkeypatch) + monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") + payload = {"items": [{"title": "Bad timestamp", "published": timestamp}]} + monkeypatch.setattr(main.requests, "get", lambda *args, **kwargs: FakeResponse(payload=payload)) + + with pytest.raises(HTTPException) as excinfo: + main.freshrss_unread() + + assert excinfo.value.status_code == 502 + assert excinfo.value.detail == "FreshRSS returned an invalid unread response" + + +@pytest.mark.parametrize("timestamp", [10**30, -(10**30)]) +def test_freshrss_unread_rejects_out_of_range_timestamps(monkeypatch, timestamp): + main = import_app(monkeypatch) + monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") + payload = {"items": [{"title": "Bad timestamp", "published": timestamp}]} + monkeypatch.setattr(main.requests, "get", lambda *args, **kwargs: FakeResponse(payload=payload)) + + with pytest.raises(HTTPException) as excinfo: + main.freshrss_unread() + + assert excinfo.value.status_code == 502 + assert excinfo.value.detail == "FreshRSS returned an invalid unread response" diff --git a/uv.lock b/uv.lock index ed9b650..52b7b00 100644 --- a/uv.lock +++ b/uv.lock @@ -431,6 +431,7 @@ source = { virtual = "." } dependencies = [ { name = "fastapi" }, { name = "humanize" }, + { name = "pydantic" }, { name = "requests" }, { name = "uvicorn" }, ] @@ -444,6 +445,7 @@ dev = [ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "humanize", specifier = ">=4.12.3" }, + { name = "pydantic", specifier = ">=2.0" }, { name = "requests", specifier = ">=2.32.4" }, { name = "uvicorn", specifier = ">=0.32.0" }, ]