Skip to content
Closed
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
73 changes: 65 additions & 8 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch overflow before checking timestamp finiteness

When published is a sufficiently large JSON integer (for example, a few hundred digits), math.isfinite(value) raises OverflowError while converting it to a float. Because this occurs before the guarded datetime.fromtimestamp call and Pydantic does not wrap OverflowError from a field validator as ValidationError, the endpoint returns an internal 500 instead of the sanitized 502 promised for malformed FreshRSS responses.

Useful? React with 👍 / 👎.

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:
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dependencies = [
"fastapi>=0.115.0",
"uvicorn>=0.32.0",
"humanize>=4.12.3",
"pydantic>=2.0",
"requests>=2.32.4",
]

Expand Down
79 changes: 79 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.