Send browser-like User-Agent when fetching RSS feeds #16

Manually merged
marvin8 merged 7 commits from feat/issue-2-feed-user-agent into main 2026-09-11 21:51:03 +00:00
Collaborator

Some feeds (notably The Driven, https://thedriven.io/feed/) reject requests with httpx2's default User-Agent with 403 Forbidden, so their articles were never imported. The same URL returns 200 with a browser User-Agent (verified in the Sept 2026 probe recorded on issue #2).

  • fetch_feed now sends a browser-like User-Agent (Firefox on Linux, the exact string the probe verified works)
  • Override via the YUNJIN_HTTP_USER_AGENT environment variable, read at call time; empty/whitespace values fall back to the default
  • Regression tests for header, override, and fallbacks

SSRF guard (WuMing findings, maintainer-approved)

  • _validate_feed_url() runs before every fetch: http/https scheme allow-list (always enforced), host resolution blocking private/loopback/link-local/reserved/unspecified/multicast addresses (IPv4-mapped IPv6 unwrapped, mixed resolution rejected, unresolvable hosts fail closed)
  • Redirects are followed manually (_fetch_feed_response): every hop is re-validated through the same guard, with a missing-Location error and a 5-redirect cap; blocked targets are never fetched
  • LAN feed sources stay possible via the YUNJIN_ALLOW_PRIVATE_FEED_HOSTS opt-out
  • Accepted limitation (maintainer decision): validation is TOCTOU vs. the client's own DNS resolution (rebinding) — pinning requires a custom transport and is out of scope; documented in _validate_feed_url's docstring
  • New tests throughout, all with mocked resolution (no live network)

Closes #2

Some feeds (notably The Driven, `https://thedriven.io/feed/`) reject requests with httpx2's default User-Agent with `403 Forbidden`, so their articles were never imported. The same URL returns 200 with a browser User-Agent (verified in the Sept 2026 probe recorded on issue #2). - `fetch_feed` now sends a browser-like User-Agent (Firefox on Linux, the exact string the probe verified works) - Override via the `YUNJIN_HTTP_USER_AGENT` environment variable, read at call time; empty/whitespace values fall back to the default - Regression tests for header, override, and fallbacks ## SSRF guard (WuMing findings, maintainer-approved) - `_validate_feed_url()` runs before every fetch: `http`/`https` scheme allow-list (always enforced), host resolution blocking private/loopback/link-local/reserved/unspecified/multicast addresses (IPv4-mapped IPv6 unwrapped, mixed resolution rejected, unresolvable hosts fail closed) - Redirects are followed manually (`_fetch_feed_response`): every hop is re-validated through the same guard, with a missing-`Location` error and a 5-redirect cap; blocked targets are never fetched - LAN feed sources stay possible via the `YUNJIN_ALLOW_PRIVATE_FEED_HOSTS` opt-out - Accepted limitation (maintainer decision): validation is TOCTOU vs. the client's own DNS resolution (rebinding) — pinning requires a custom transport and is out of scope; documented in `_validate_feed_url`'s docstring - New tests throughout, all with mocked resolution (no live network) Closes #2
🐛 Send browser-like User-Agent when fetching RSS feeds
All checks were successful
/ gitleaks (pull_request) Successful in 23s
/ checks (pull_request) Successful in 1m22s
/ pr-review (pull_request) Successful in 2m12s
f669f0c6e8
forgejo-actions left a comment

WuMing

Found 4 issue(s). See inline comments below.

## WuMing Found **4** issue(s). See inline comments below.
@ -20,0 +32,4 @@
User-Agent string to send with feed fetch requests.
"""
return os.environ.get("YUNJIN_HTTP_USER_AGENT", _DEFAULT_USER_AGENT)

code [LOW]

If YUNJIN_HTTP_USER_AGENT is set but empty (a common misconfiguration), os.environ.get returns an empty string and fetch_feed sends an empty User-Agent, which may still be rejected with 403. Use os.environ.get("YUNJIN_HTTP_USER_AGENT") or _DEFAULT_USER_AGENT so empty values fall back to the default.

**code** [LOW] If YUNJIN_HTTP_USER_AGENT is set but empty (a common misconfiguration), os.environ.get returns an empty string and fetch_feed sends an empty User-Agent, which may still be rejected with 403. Use `os.environ.get("YUNJIN_HTTP_USER_AGENT") or _DEFAULT_USER_AGENT` so empty values fall back to the default. <!-- wuming:sha256:3d81d78a61f90b66f7717c1b7c9c0c592c28de46ebc1cb8f1577cfedb9bd17cb -->
@ -43,2 +60,3 @@
logger.info(f"Fetching feed: {feed.url}")
response = httpx2.get(feed.url, timeout=30.0, follow_redirects=True)
response = httpx2.get(
feed.url,

security [HIGH]

A10: The feed URL is passed directly to httpx2.get without allow-list validation. If untrusted users can create feeds, this permits SSRF to internal services and cloud metadata. Validate URL scheme/host and block private IP ranges.

**security** [HIGH] A10: The feed URL is passed directly to httpx2.get without allow-list validation. If untrusted users can create feeds, this permits SSRF to internal services and cloud metadata. Validate URL scheme/host and block private IP ranges. <!-- wuming:sha256:2d533c5e48f2c018342ac0cb9e03f70b6d2b9c1cab67af571f1b3c6f01ab934f -->
@ -75,0 +93,4 @@
fetch_feed(db_conn, feed)
headers = mock_get.call_args.kwargs["headers"]
assert headers["User-Agent"].startswith("Mozilla/5.0")

code [MEDIUM]

This default-header test does not isolate YUNJIN_HTTP_USER_AGENT, so if that environment variable is set in CI or locally, the assertion will fail. Also, startswith only verifies a prefix, so it would not catch an accidental change to the rest of the verified UA string. Patch/clear the env var for this test and assert equality to the expected default (e.g. import _DEFAULT_USER_AGENT).

**code** [MEDIUM] This default-header test does not isolate YUNJIN_HTTP_USER_AGENT, so if that environment variable is set in CI or locally, the assertion will fail. Also, startswith only verifies a prefix, so it would not catch an accidental change to the rest of the verified UA string. Patch/clear the env var for this test and assert equality to the expected default (e.g. import _DEFAULT_USER_AGENT). <!-- wuming:sha256:4ffdcc6d1e5f531ed819e4bebcf141bd7a06b59eefa09849e5fad50cadaa7518 -->

tests [LOW]

The test only asserts the User-Agent starts with 'Mozilla/5.0', a loose check that passes for any browser-like string (e.g. Chrome, an older Firefox version, or another Mozilla UA). Since the PR's whole premise is that one specific probe-verified Firefox string is required to avoid the 403 from thedriven.io, a regression that swaps in a different browser UA would not be caught. Assert against the exact expected value (or the _DEFAULT_USER_AGENT constant) so the verified string is locked in.

**tests** [LOW] The test only asserts the User-Agent starts with 'Mozilla/5.0', a loose check that passes for any browser-like string (e.g. Chrome, an older Firefox version, or another Mozilla UA). Since the PR's whole premise is that one specific probe-verified Firefox string is required to avoid the 403 from thedriven.io, a regression that swaps in a different browser UA would not be caught. Assert against the exact expected value (or the `_DEFAULT_USER_AGENT` constant) so the verified string is locked in. <!-- wuming:sha256:4ffdcc6d1e5f531ed819e4bebcf141bd7a06b59eefa09849e5fad50cadaa7518 -->
🐛 Treat empty YUNJIN_HTTP_USER_AGENT as unset
All checks were successful
/ gitleaks (pull_request) Successful in 17s
/ checks (pull_request) Successful in 1m25s
/ pr-review (pull_request) Successful in 2m7s
15952f7e63
Author
Collaborator

tests/test_fetcher.py

This default-header test does not isolate YUNJIN_HTTP_USER_AGENT, so if that environment variable is set in CI or locally…

Fixed in commit f9b1172 — the test now runs under patch.dict(os.environ, …, clear=True) with YUNJIN_HTTP_USER_AGENT removed, and asserts equality against the imported _DEFAULT_USER_AGENT instead of a startswith prefix check. This also resolves the duplicate finding on the loose prefix assertion.

[`tests/test_fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2538) > This default-header test does not isolate YUNJIN_HTTP_USER_AGENT, so if that environment variable is set in CI or locally… ✅ Fixed in commit `f9b1172` — the test now runs under `patch.dict(os.environ, …, clear=True)` with `YUNJIN_HTTP_USER_AGENT` removed, and asserts equality against the imported `_DEFAULT_USER_AGENT` instead of a `startswith` prefix check. This also resolves the duplicate finding on the loose prefix assertion.
Author
Collaborator

tests/test_fetcher.py

The test only asserts the User-Agent starts with 'Mozilla/5.0', a loose check that passes for any browser-like string…

Fixed in commit f9b1172 — same finding as the duplicate comment above; the assertion is now exact equality against _DEFAULT_USER_AGENT, so the probe-verified Firefox string is locked in.

[`tests/test_fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2541) > The test only asserts the User-Agent starts with 'Mozilla/5.0', a loose check that passes for any browser-like string… ✅ Fixed in commit `f9b1172` — same finding as the duplicate comment above; the assertion is now exact equality against `_DEFAULT_USER_AGENT`, so the probe-verified Firefox string is locked in.
Author
Collaborator

src/yunjin/services/fetcher.py

If YUNJIN_HTTP_USER_AGENT is set but empty (a common misconfiguration), os.environ.get returns an empty string…

Fixed in commit 15952f7_user_agent() now uses os.environ.get("YUNJIN_HTTP_USER_AGENT") or _DEFAULT_USER_AGENT, with a regression test asserting the fallback for an empty value.

[`src/yunjin/services/fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2539) > If YUNJIN_HTTP_USER_AGENT is set but empty (a common misconfiguration), os.environ.get returns an empty string… ✅ Fixed in commit `15952f7` — `_user_agent()` now uses `os.environ.get("YUNJIN_HTTP_USER_AGENT") or _DEFAULT_USER_AGENT`, with a regression test asserting the fallback for an empty value.
Author
Collaborator

src/yunjin/services/fetcher.py

A10: The feed URL is passed directly to httpx2.get without allow-list validation. If untrusted users can create feeds…

⏸️ Escalated to Marvin8 for a threat-model decision rather than fixed in this PR: yunjin is currently single-operator (feeds are added via the CLI and OPML import; there is no untrusted-user feed-creation path), the SSRF surface predates this PR, and blocking private ranges would break a legitimate deployment case — fetching feeds from LAN hosts such as a self-hosted FreshRSS. A decision on scope (scheme allow-list only, or full private-IP blocking with an opt-out) is pending.

[`src/yunjin/services/fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2540) > A10: The feed URL is passed directly to httpx2.get without allow-list validation. If untrusted users can create feeds… ⏸️ Escalated to Marvin8 for a threat-model decision rather than fixed in this PR: yunjin is currently single-operator (feeds are added via the CLI and OPML import; there is no untrusted-user feed-creation path), the SSRF surface predates this PR, and blocking private ranges would break a legitimate deployment case — fetching feeds from LAN hosts such as a self-hosted FreshRSS. A decision on scope (scheme allow-list only, or full private-IP blocking with an opt-out) is pending.
forgejo-actions left a comment

WuMing

Found 4 issue(s). See inline comments below.

## WuMing Found **4** issue(s). See inline comments below.
@ -20,0 +33,4 @@
User-Agent string to send with feed fetch requests.
"""
return os.environ.get("YUNJIN_HTTP_USER_AGENT") or _DEFAULT_USER_AGENT

code [LOW]

The fallback only treats an empty string as missing. A whitespace-only value such as ' ' is truthy and will be returned as the User-Agent, likely causing requests to be rejected. Consider stripping the value before applying the fallback: os.environ.get('YUNJIN_HTTP_USER_AGENT', '').strip() or _DEFAULT_USER_AGENT.

**code** [LOW] The fallback only treats an empty string as missing. A whitespace-only value such as ' ' is truthy and will be returned as the User-Agent, likely causing requests to be rejected. Consider stripping the value before applying the fallback: os.environ.get('YUNJIN_HTTP_USER_AGENT', '').strip() or _DEFAULT_USER_AGENT. <!-- wuming:sha256:efa6c2c82bd8159f87c4eccbf559581b3509ce9414deac6ebb82a2fe85a82e00 -->
@ -43,2 +61,3 @@
logger.info(f"Fetching feed: {feed.url}")
response = httpx2.get(feed.url, timeout=30.0, follow_redirects=True)
response = httpx2.get(
feed.url,

security [HIGH]

A10: feed.url is passed directly to httpx2.get without an allow-list or SSRF protection. If the feed URL is user-controlled, this permits requests to internal services or cloud metadata endpoints. Validate schemes and resolve/block private or link-local addresses, or route through an egress proxy.

**security** [HIGH] A10: feed.url is passed directly to httpx2.get without an allow-list or SSRF protection. If the feed URL is user-controlled, this permits requests to internal services or cloud metadata endpoints. Validate schemes and resolve/block private or link-local addresses, or route through an egress proxy. <!-- wuming:sha256:ac64efb479939e045e92fc54c902207f68fa01ca184a2e7300df90075186b65e -->
@ -75,0 +91,4 @@
env_without_override = os.environ.copy()
env_without_override.pop("YUNJIN_HTTP_USER_AGENT", None)
with patch.dict(os.environ, env_without_override, clear=True):

code [LOW]

Using patch.dict(os.environ, env_without_override, clear=True) clears the entire environment for the duration of the test. This is more invasive than necessary and can cause flaky behavior if any code under test or a library depends on other environment variables. Prefer a targeted patch that only overrides/removes YUNJIN_HTTP_USER_AGENT, or use pytest's monkeypatch.delenv.

**code** [LOW] Using patch.dict(os.environ, env_without_override, clear=True) clears the entire environment for the duration of the test. This is more invasive than necessary and can cause flaky behavior if any code under test or a library depends on other environment variables. Prefer a targeted patch that only overrides/removes YUNJIN_HTTP_USER_AGENT, or use pytest's monkeypatch.delenv. <!-- wuming:sha256:854ba1792a6adaf2385911bde6f7b8de19b269ccfd30c94258347aaa9c9e0006 -->
@ -75,0 +97,4 @@
fetch_feed(db_conn, feed)
headers = mock_get.call_args.kwargs["headers"]
assert headers["User-Agent"] == _DEFAULT_USER_AGENT

tests [LOW]

The assertion compares against _DEFAULT_USER_AGENT imported from the module under test, so it is self-referential with respect to the constant: if _DEFAULT_USER_AGENT is changed to a non-browser or broken value, this test still passes. Assert against an independent expected value (e.g. a literal string or a check that it contains Mozilla/5.0 and Firefox), and keep the _DEFAULT_USER_AGENT comparison only for the fallback/override tests where the constant is genuinely the expected outcome.

**tests** [LOW] The assertion compares against `_DEFAULT_USER_AGENT` imported from the module under test, so it is self-referential with respect to the constant: if `_DEFAULT_USER_AGENT` is changed to a non-browser or broken value, this test still passes. Assert against an independent expected value (e.g. a literal string or a check that it contains `Mozilla/5.0` and `Firefox`), and keep the `_DEFAULT_USER_AGENT` comparison only for the fallback/override tests where the constant is genuinely the expected outcome. <!-- wuming:sha256:9d8c66f8d03547a4636e986befd711539c27bee6dcee504449101fb03407484a -->
🐛 Address WuMing review: whitespace UA fallback, independent test literal, monkeypatch
All checks were successful
/ gitleaks (pull_request) Successful in 13s
/ checks (pull_request) Successful in 1m24s
/ pr-review (pull_request) Successful in 1m49s
224f2b695c
Author
Collaborator

src/yunjin/services/fetcher.py

The fallback only treats an empty string as missing. A whitespace-only value such as ' ' is truthy…

Fixed in commit 224f2b6_user_agent() now uses os.environ.get("YUNJIN_HTTP_USER_AGENT", "").strip() or _DEFAULT_USER_AGENT, with a regression test for a whitespace-only value falling back to the default.

[`src/yunjin/services/fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2548) > The fallback only treats an empty string as missing. A whitespace-only value such as ' ' is truthy… ✅ Fixed in commit `224f2b6` — `_user_agent()` now uses `os.environ.get("YUNJIN_HTTP_USER_AGENT", "").strip() or _DEFAULT_USER_AGENT`, with a regression test for a whitespace-only value falling back to the default.
Author
Collaborator

tests/test_fetcher.py

Using patch.dict(os.environ, env_without_override, clear=True) clears the entire environment…

Fixed in commit 224f2b6 — the default-header test now uses monkeypatch.delenv("YUNJIN_HTTP_USER_AGENT", raising=False) instead of the environment-wide patch.dict(clear=True).

[`tests/test_fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2549) > Using patch.dict(os.environ, env_without_override, clear=True) clears the entire environment… ✅ Fixed in commit `224f2b6` — the default-header test now uses `monkeypatch.delenv("YUNJIN_HTTP_USER_AGENT", raising=False)` instead of the environment-wide `patch.dict(clear=True)`.
Author
Collaborator

tests/test_fetcher.py

The assertion compares against _DEFAULT_USER_AGENT imported from the module under test, so it is self-referential…

Fixed in commit 224f2b6 — the tests now assert against an independent literal copy of the probe-verified string defined in the test class (with a comment citing the issue #2 probe), so a broken or non-browser default would fail loudly and force re-verification.

[`tests/test_fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2551) > The assertion compares against `_DEFAULT_USER_AGENT` imported from the module under test, so it is self-referential… ✅ Fixed in commit `224f2b6` — the tests now assert against an independent literal copy of the probe-verified string defined in the test class (with a comment citing the issue #2 probe), so a broken or non-browser default would fail loudly and force re-verification.
Author
Collaborator

src/yunjin/services/fetcher.py

A10: feed.url is passed directly to httpx2.get without an allow-list or SSRF protection…

⏸️ Still escalated to Marvin8 for a threat-model decision (see my reply to the earlier report of this finding): yunjin is single-operator with no untrusted feed-creation path, the surface predates this PR, and private-range blocking would break LAN feed sources. Not actioned in this PR without a product call.

[`src/yunjin/services/fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2550) > A10: feed.url is passed directly to httpx2.get without an allow-list or SSRF protection… ⏸️ Still escalated to Marvin8 for a threat-model decision (see my reply to the earlier report of this finding): yunjin is single-operator with no untrusted feed-creation path, the surface predates this PR, and private-range blocking would break LAN feed sources. Not actioned in this PR without a product call.
forgejo-actions left a comment

WuMing

Found 1 issue(s). See inline comments below.

## WuMing Found **1** issue(s). See inline comments below.
@ -43,2 +62,3 @@
logger.info(f"Fetching feed: {feed.url}")
response = httpx2.get(feed.url, timeout=30.0, follow_redirects=True)
response = httpx2.get(
feed.url,

security [HIGH]

A10: feed.url is passed directly to httpx2.get without an allow-list or SSRF guard. If feed URLs are user-controlled, an attacker can target internal services or cloud metadata endpoints. Restrict schemes and validate/resolve hosts against private, loopback, and link-local ranges.

**security** [HIGH] A10: feed.url is passed directly to httpx2.get without an allow-list or SSRF guard. If feed URLs are user-controlled, an attacker can target internal services or cloud metadata endpoints. Restrict schemes and validate/resolve hosts against private, loopback, and link-local ranges. <!-- wuming:sha256:c472f53b6741469808adbe4bd0ba96451f69e292ad42cec1eab1bc7cadf07035 -->
Author
Collaborator

src/yunjin/services/fetcher.py

A10: feed.url is passed directly to httpx2.get without an allow-list or SSRF guard…

⏸️ Same finding as my two earlier ⏸️ replies — escalated to Marvin8 for a threat-model decision (single-operator tool, LAN feed sources are a legitimate case). All other findings from waves 1–2 are fixed; this one is intentionally not actioned in this PR without a product call.

[`src/yunjin/services/fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2559) > A10: feed.url is passed directly to httpx2.get without an allow-list or SSRF guard… ⏸️ Same finding as my two earlier ⏸️ replies — escalated to Marvin8 for a threat-model decision (single-operator tool, LAN feed sources are a legitimate case). All other findings from waves 1–2 are fixed; this one is intentionally not actioned in this PR without a product call.
Block SSRF-unsafe feed URLs (schemes, private/loopback/link-local hosts)
All checks were successful
/ gitleaks (pull_request) Successful in 14s
/ checks (pull_request) Successful in 1m23s
/ pr-review (pull_request) Successful in 3m29s
4b26273f06
Author
Collaborator

src/yunjin/services/fetcher.py

A10: feed.url is passed directly to httpx2.get without an allow-list or SSRF guard…

Fixed in commit 4b26273, per the maintainer-approved approach: _validate_feed_url() now runs before every fetch — http/https scheme allow-list (always), host resolution with blocking of private, loopback, link-local, reserved, unspecified, and multicast addresses (IPv4-mapped IPv6 unwrapped, mixed results rejected, unresolvable hosts fail closed). Opt-out for LAN feed sources via YUNJIN_ALLOW_PRIVATE_FEED_HOSTS. Known limitations (documented in the docstring): redirects are followed without re-validation, and validation is TOCTOU vs. the client's own resolution; closing those needs a custom transport and is out of scope here. 18 new tests, all with mocked resolution.

[`src/yunjin/services/fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2559) > A10: feed.url is passed directly to httpx2.get without an allow-list or SSRF guard… ✅ Fixed in commit `4b26273`, per the maintainer-approved approach: `_validate_feed_url()` now runs before every fetch — `http`/`https` scheme allow-list (always), host resolution with blocking of private, loopback, link-local, reserved, unspecified, and multicast addresses (IPv4-mapped IPv6 unwrapped, mixed results rejected, unresolvable hosts fail closed). Opt-out for LAN feed sources via `YUNJIN_ALLOW_PRIVATE_FEED_HOSTS`. Known limitations (documented in the docstring): redirects are followed without re-validation, and validation is TOCTOU vs. the client's own resolution; closing those needs a custom transport and is out of scope here. 18 new tests, all with mocked resolution.
forgejo-actions left a comment

WuMing

Found 5 issue(s). See inline comments below.

## WuMing Found **5** issue(s). See inline comments below.
@ -45,0 +157,4 @@
response = httpx2.get(
feed.url,
timeout=30.0,
follow_redirects=True,

security [HIGH]

A10: Redirects are followed without re-applying the SSRF guard. An attacker-controlled feed URL can pass validation on a public host and then redirect httpx to an internal address such as 169.254.169.254 or a LAN service. Re-validate every redirect target or disable redirects and handle them manually with the same scheme and address checks.

**security** [HIGH] A10: Redirects are followed without re-applying the SSRF guard. An attacker-controlled feed URL can pass validation on a public host and then redirect httpx to an internal address such as 169.254.169.254 or a LAN service. Re-validate every redirect target or disable redirects and handle them manually with the same scheme and address checks. <!-- wuming:sha256:fa416b856e163abd6feeef29e386653c3bf6f32093bb3b4653ae40ba5034e708 -->
@ -39,3 +151,4 @@
"""
now = datetime.now()
_validate_feed_url(feed.url)

security [HIGH]

A10: The SSRF guard resolves the hostname during validation, but the actual HTTP request resolves it again separately. An attacker controlling DNS can return a public address during validation and a private/reserved address when httpx fetches it (DNS rebinding), bypassing the guard. Pin the validated IP address(es) and issue the request using those addresses.

**security** [HIGH] A10: The SSRF guard resolves the hostname during validation, but the actual HTTP request resolves it again separately. An attacker controlling DNS can return a public address during validation and a private/reserved address when httpx fetches it (DNS rebinding), bypassing the guard. Pin the validated IP address(es) and issue the request using those addresses. <!-- wuming:sha256:4a770840a80c496ef9197b92998a24a94b5de722b6c1da6b50d58c253388ff07 -->
marvin8 marked this conversation as resolved
@ -72,6 +82,111 @@ class TestFetchFeed:
saved_articles = articles.get_articles_by_feed(db_conn, feed.id)
assert len(saved_articles) == 2
def test_fetch_feed_sends_user_agent_header(self, db_conn, monkeypatch):

tests [MEDIUM]

This test (and the three User-Agent tests that follow at lines 108, 131 and 154) mock httpx2.get and feedparser.parse but never mock DNS, while fetch_feed now calls _validate_feed_url, which performs a real socket.getaddrinfo("example.com"). The tests therefore depend on external DNS/network availability and will error with FetchError: ... unresolvable host in an offline or sandboxed CI, which makes them flaky. Patch socket.getaddrinfo (as TestFeedUrlValidation does) with a public address for these cases.

**tests** [MEDIUM] This test (and the three User-Agent tests that follow at lines 108, 131 and 154) mock `httpx2.get` and `feedparser.parse` but never mock DNS, while `fetch_feed` now calls `_validate_feed_url`, which performs a real `socket.getaddrinfo("example.com")`. The tests therefore depend on external DNS/network availability and will error with `FetchError: ... unresolvable host` in an offline or sandboxed CI, which makes them flaky. Patch `socket.getaddrinfo` (as `TestFeedUrlValidation` does) with a public address for these cases. <!-- wuming:sha256:f2c097b2dafa093ba64f7abbc2dd2e8e05930e833b09c68abe95afaf54f3d848 -->
@ -299,0 +430,4 @@
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip_address, 0))]
return [(socket.AF_INET6, socket.SOCK_STREAM, 6, "", (ip_address, 0, 0, 0))]
def test_allows_public_address(self):

tests [LOW]

test_allows_public_address contains no assertion statement: it only calls _validate_feed_url and relies on the absence of an exception. The test would still pass if validation were silently turned into a no-op, so it cannot distinguish "allowed" from "not validated at all". Assert that the guard actually ran, e.g. by asserting the socket.getaddrinfo patch was called with the expected hostname, and/or assert the function returns None.

**tests** [LOW] `test_allows_public_address` contains no assertion statement: it only calls `_validate_feed_url` and relies on the absence of an exception. The test would still pass if validation were silently turned into a no-op, so it cannot distinguish "allowed" from "not validated at all". Assert that the guard actually ran, e.g. by asserting the `socket.getaddrinfo` patch was called with the expected hostname, and/or assert the function returns None. <!-- wuming:sha256:46850c32588aa9aa2b9228818c0a0c65b6075e56c8571a0d5f70f35662cf1e79 -->
@ -299,0 +479,4 @@
with pytest.raises(FetchError, match="unresolvable"):
_validate_feed_url("https://example.invalid/feed.xml")
def test_opt_out_allows_private_host(self):

tests [LOW]

test_opt_out_allows_private_host has no assertion — it only relies on _validate_feed_url not raising inside the patch contexts. Because _validate_feed_url returns None and the opt-out path returns early, the test would pass even if the opt-out were removed and the address were treated as public. Add an explicit check, e.g. assert the patched socket.getaddrinfo was called (proving the opt-out bypassed resolution) or that the call returned None.

**tests** [LOW] `test_opt_out_allows_private_host` has no assertion — it only relies on `_validate_feed_url` not raising inside the patch contexts. Because `_validate_feed_url` returns `None` and the opt-out path returns early, the test would pass even if the opt-out were removed and the address were treated as public. Add an explicit check, e.g. assert the patched `socket.getaddrinfo` was called (proving the opt-out bypassed resolution) or that the call returned `None`. <!-- wuming:sha256:8b50ed38db0d652111408f7d79ceef9e8e03e45b466882227ffa1d7785af43ab -->
Assert guard execution in allow-path tests, mock DNS in fetch tests
All checks were successful
/ gitleaks (pull_request) Successful in 14s
/ checks (pull_request) Successful in 1m23s
/ pr-review (pull_request) Successful in 3m21s
a6674a53af
Author
Collaborator

tests/test_fetcher.py

test_allows_public_address contains no assertion statement: it only calls _validate_feed_url…

Fixed in commit a6674a5 — the test now asserts socket.getaddrinfo was called once with ("example.com", None), proving the guard ran rather than silently no-oping.

[`tests/test_fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2568) > test_allows_public_address contains no assertion statement: it only calls _validate_feed_url… ✅ Fixed in commit `a6674a5` — the test now asserts `socket.getaddrinfo` was called once with `("example.com", None)`, proving the guard ran rather than silently no-oping.
Author
Collaborator

tests/test_fetcher.py

test_opt_out_allows_private_host has no assertion — it only relies on _validate_feed_url not raising…

Fixed in commit a6674a5 — with a correction to the suggested assertion: the opt-out path returns before resolution, so the meaningful check is mock_getaddrinfo.assert_not_called() — it proves the opt-out short-circuited validation instead of the host merely passing validation.

[`tests/test_fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2569) > test_opt_out_allows_private_host has no assertion — it only relies on _validate_feed_url not raising… ✅ Fixed in commit `a6674a5` — with a correction to the suggested assertion: the opt-out path returns *before* resolution, so the meaningful check is `mock_getaddrinfo.assert_not_called()` — it proves the opt-out short-circuited validation instead of the host merely passing validation.
Author
Collaborator

tests/test_fetcher.py

This test (and the three User-Agent tests that follow) mock httpx2.get and feedparser.parse but never mock DNS…

Fixed in commit a6674a5TestFetchFeed now has an autouse public_dns fixture that patches socket.getaddrinfo to a fixed public address for every test in the class (the private-URL test overrides it locally), so no fetch test touches live DNS.

[`tests/test_fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2570) > This test (and the three User-Agent tests that follow) mock httpx2.get and feedparser.parse but never mock DNS… ✅ Fixed in commit `a6674a5` — `TestFetchFeed` now has an autouse `public_dns` fixture that patches `socket.getaddrinfo` to a fixed public address for every test in the class (the private-URL test overrides it locally), so no fetch test touches live DNS.
Author
Collaborator

src/yunjin/services/fetcher.py

The SSRF guard resolves the hostname during validation, but the actual HTTP request resolves it again separately…

⏸️ Escalated to Marvin8: closing the rebinding window requires pinning the validated address at the transport layer (httpx2's top-level get() accepts no transport; this means switching fetch_feed to a Client with a custom transport that rewrites connections to the validated IP while preserving SNI/Host). This is the TOCTOU limitation documented in _validate_feed_url's docstring and in the maintainer-approved scope decision — for a single-operator aggregator the attacker must already control DNS for a feed hostname the operator chose to add. Decision pending: implement transport pinning vs. accept the documented limitation.

[`src/yunjin/services/fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2566) > The SSRF guard resolves the hostname during validation, but the actual HTTP request resolves it again separately… ⏸️ Escalated to Marvin8: closing the rebinding window requires pinning the validated address at the transport layer (httpx2's top-level `get()` accepts no transport; this means switching `fetch_feed` to a `Client` with a custom transport that rewrites connections to the validated IP while preserving SNI/Host). This is the TOCTOU limitation documented in `_validate_feed_url`'s docstring and in the maintainer-approved scope decision — for a single-operator aggregator the attacker must already control DNS for a feed hostname the operator chose to add. Decision pending: implement transport pinning vs. accept the documented limitation.
Author
Collaborator

src/yunjin/services/fetcher.py

Redirects are followed without re-applying the SSRF guard…

⏸️ Escalated to Marvin8 alongside the rebinding finding — this is the second documented limitation in _validate_feed_url's docstring. The fix shape (disable auto-redirects, loop manually with urljoin-resolved targets through the same guard) is well understood and cheaper than transport pinning; awaiting the maintainer's call on whether it lands in this PR or a follow-up.

[`src/yunjin/services/fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2567) > Redirects are followed without re-applying the SSRF guard… ⏸️ Escalated to Marvin8 alongside the rebinding finding — this is the second documented limitation in `_validate_feed_url`'s docstring. The fix shape (disable auto-redirects, loop manually with `urljoin`-resolved targets through the same guard) is well understood and cheaper than transport pinning; awaiting the maintainer's call on whether it lands in this PR or a follow-up.
Follow feed redirects manually, re-validating each hop through the SSRF guard
All checks were successful
/ gitleaks (pull_request) Successful in 17s
/ checks (pull_request) Successful in 1m25s
/ pr-review (pull_request) Successful in 3m37s
bd78da3e34
Author
Collaborator

src/yunjin/services/fetcher.py

Redirects are followed without re-applying the SSRF guard…

Fixed in commit bd78da3_fetch_feed_response() now follows redirects manually (follow_redirects=False), resolving each Location with urljoin and re-running the full _validate_feed_url guard before every hop; missing Location headers and chains beyond _MAX_FEED_REDIRECTS (5) raise FetchError. Redirect targets resolving to blocked addresses are never fetched (asserted by test).

[`src/yunjin/services/fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2567) > Redirects are followed without re-applying the SSRF guard… ✅ Fixed in commit `bd78da3` — `_fetch_feed_response()` now follows redirects manually (`follow_redirects=False`), resolving each `Location` with `urljoin` and re-running the full `_validate_feed_url` guard before every hop; missing `Location` headers and chains beyond `_MAX_FEED_REDIRECTS` (5) raise `FetchError`. Redirect targets resolving to blocked addresses are never fetched (asserted by test).
Author
Collaborator

src/yunjin/services/fetcher.py

The SSRF guard resolves the hostname during validation, but the actual HTTP request resolves it again separately…

🔴 Declined by maintainer decision — accepted as a documented limitation. Pinning the validated address requires moving fetch_feed onto a httpx2.Client with a custom transport that rewrites connections to the validated IP while preserving SNI/Host (the top-level get() accepts no transport), which is disproportionate for this project: yunjin is a single-operator aggregator, so exploiting the rebinding window requires controlling DNS for a feed hostname the operator deliberately added. The limitation and its rationale are documented in _validate_feed_url's docstring; it can be revisited in a dedicated issue if the deployment model changes.

[`src/yunjin/services/fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2566) > The SSRF guard resolves the hostname during validation, but the actual HTTP request resolves it again separately… 🔴 Declined by maintainer decision — accepted as a documented limitation. Pinning the validated address requires moving `fetch_feed` onto a `httpx2.Client` with a custom transport that rewrites connections to the validated IP while preserving SNI/Host (the top-level `get()` accepts no transport), which is disproportionate for this project: yunjin is a single-operator aggregator, so exploiting the rebinding window requires controlling DNS for a feed hostname the operator deliberately added. The limitation and its rationale are documented in `_validate_feed_url`'s docstring; it can be revisited in a dedicated issue if the deployment model changes.
forgejo-actions left a comment

WuMing

Found 1 issue(s). See inline comments below.

## WuMing Found **1** issue(s). See inline comments below.
@ -27,0 +159,4 @@
"""
headers = {"User-Agent": _user_agent()}
for _redirect_number in range(_MAX_FEED_REDIRECTS + 1):
response = httpx2.get(url, timeout=30.0, follow_redirects=False, headers=headers)

security [MEDIUM]

A10: The SSRF guard resolves the hostname in _validate_feed_url, but httpx2.get resolves it independently when opening the connection. A DNS rebinding attack can return a public address during validation and a private/link-local address during the actual request, bypassing the guard and reaching internal services or cloud metadata endpoints. Remediate by pinning the HTTP client to the validated IP addresses or using a custom transport that resolves and connects with the same result.

**security** [MEDIUM] A10: The SSRF guard resolves the hostname in _validate_feed_url, but httpx2.get resolves it independently when opening the connection. A DNS rebinding attack can return a public address during validation and a private/link-local address during the actual request, bypassing the guard and reaching internal services or cloud metadata endpoints. Remediate by pinning the HTTP client to the validated IP addresses or using a custom transport that resolves and connects with the same result. <!-- wuming:sha256:d389eaf12e025e14f00c1b703e59fa9a6be6477384e30c850a124889a819bcc9 -->
marvin8 marked this conversation as resolved
Author
Collaborator

src/yunjin/services/fetcher.py

A10: The SSRF guard resolves the hostname in _validate_feed_url, but httpx2.get resolves it independently…

⏸️ Standing maintainer decision — see the 🔴 reply to the earlier report of this finding: declined as a documented limitation (transport-level pinning is disproportionate for a single-operator aggregator; the limitation and rationale are in _validate_feed_url's docstring and can be revisited in a dedicated issue if the deployment model changes). Noting the severity was re-assessed HIGH → MEDIUM; no further action planned on this PR. This finding will re-appear on each new push since WuMing has no wontfix marker.

[`src/yunjin/services/fetcher.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/16#issuecomment-2581) > A10: The SSRF guard resolves the hostname in _validate_feed_url, but httpx2.get resolves it independently… ⏸️ Standing maintainer decision — see the 🔴 reply to the earlier report of this finding: declined as a documented limitation (transport-level pinning is disproportionate for a single-operator aggregator; the limitation and rationale are in `_validate_feed_url`'s docstring and can be revisited in a dedicated issue if the deployment model changes). Noting the severity was re-assessed HIGH → MEDIUM; no further action planned on this PR. This finding will re-appear on each new push since WuMing has no wontfix marker.
marvin8 approved these changes 2026-09-11 21:48:04 +00:00
marvin8 manually merged commit cdc5e005fe into main 2026-09-11 21:51:03 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
marvin8/yunjin!16
No description provided.