Download and store article images locally #29

Manually merged
marvin8 merged 7 commits from feat/issue-21-image-persistence into main 2026-09-12 09:09:11 +00:00
Collaborator

Implements #21: article images are downloaded at fetch time and served by yunjin itself, so the reader no longer hotlinks origins (which may 403 default clients, die, or rate-limit — see #2).

Storage

  • media.local_filename + media.downloaded_at columns (alembic migration); NULL means not downloaded
  • Files stored in a media/ directory beside the database file, named by SHA-256 of the URL (deterministic, traversal-safe), extension from the URL path or content type

Download pipeline

  • New media_downloader service, wired into both media-creation paths (feed-entry extraction and full-article extraction)
  • Browser-like User-Agent (mirrors the fetcher policy incl. YUNJIN_HTTP_USER_AGENT override — the #2 lesson), redirects followed, image content-type enforced, 20 MB per-image cap, failures tolerated per image (remote URL stays as fallback)

Serving

  • GET /media/<filename> route (send_from_directory, 1-year cache — names are URL-stable)
  • Reader featured image, aggregate thumbnails, and article detail images all prefer the local route when the file was stored; remote URL remains the fallback for failed downloads

Also included: the previously lost test for get_next_aggregate_with_unread (missed in PR #28 staging), with its user_id FK fix.

247 tests, all 9 nox sessions green.

Closes #21

Implements #21: article images are downloaded at fetch time and served by yunjin itself, so the reader no longer hotlinks origins (which may 403 default clients, die, or rate-limit — see #2). ## Storage - `media.local_filename` + `media.downloaded_at` columns (alembic migration); NULL means not downloaded - Files stored in a `media/` directory beside the database file, named by SHA-256 of the URL (deterministic, traversal-safe), extension from the URL path or content type ## Download pipeline - New `media_downloader` service, wired into both media-creation paths (feed-entry extraction and full-article extraction) - Browser-like User-Agent (mirrors the fetcher policy incl. `YUNJIN_HTTP_USER_AGENT` override — the #2 lesson), redirects followed, image content-type enforced, 20 MB per-image cap, failures tolerated per image (remote URL stays as fallback) ## Serving - `GET /media/<filename>` route (`send_from_directory`, 1-year cache — names are URL-stable) - Reader featured image, aggregate thumbnails, and article detail images all prefer the local route when the file was stored; remote URL remains the fallback for failed downloads Also included: the previously lost test for `get_next_aggregate_with_unread` (missed in PR #28 staging), with its `user_id` FK fix. 247 tests, all 9 nox sessions green. Closes #21
Serve stored media from the reader
All checks were successful
/ gitleaks (pull_request) Successful in 15s
/ checks (pull_request) Successful in 1m27s
/ pr-review (pull_request) Successful in 4m43s
ba13d816dc
forgejo-actions left a comment

WuMing

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

## WuMing Found **5** issue(s). See inline comments below.
@ -0,0 +33,4 @@
"image/gif": ".gif",
"image/webp": ".webp",
"image/avif": ".avif",
"image/svg+xml": ".svg",

security [HIGH]

A03: SVG files are allowed and stored unmodified, then served by the media route as image/svg+xml. SVG can contain scripts, so a malicious feed can create stored XSS if a user opens the local media URL. Disallow SVG, sanitize it, or force download/attachment with a strong CSP.

**security** [HIGH] A03: SVG files are allowed and stored unmodified, then served by the media route as `image/svg+xml`. SVG can contain scripts, so a malicious feed can create stored XSS if a user opens the local media URL. Disallow SVG, sanitize it, or force download/attachment with a strong CSP. <!-- wuming:sha256:3a4f6f47f89d90c9ff11713c8b0ff062a5b557cb95bd3a0446db3849519bfaa3 -->
@ -0,0 +121,4 @@
downloaded = 0
for record in records:
try:
response = httpx2.get(

security [HIGH]

A10: The downloader fetches record.url from untrusted feed metadata without validating the URL scheme, host, or resolved IP. An attacker-controlled feed can cause server-side GET requests to internal services, localhost, or cloud metadata endpoints (e.g., 169.254.169.254). Validate http/https, use an allow-list, and block redirects to private/loopback/link-local IP addresses.

**security** [HIGH] A10: The downloader fetches `record.url` from untrusted feed metadata without validating the URL scheme, host, or resolved IP. An attacker-controlled feed can cause server-side GET requests to internal services, localhost, or cloud metadata endpoints (e.g., 169.254.169.254). Validate http/https, use an allow-list, and block redirects to private/loopback/link-local IP addresses. <!-- wuming:sha256:602e30aefeba14f2e44c8c40f330d377f2ad9c9894ac05f4b6e93e83ee284887 -->
@ -0,0 +140,4 @@
logger.debug("Media download for %s is not an image (%s)", record.url, content_type)
continue
data = response.content

security [MEDIUM]

A04: The 20 MB per-image cap is enforced only after response.content has buffered the entire response body. A malicious or compromised image URL can cause excessive memory consumption despite the cap. Stream the response and abort when Content-Length or downloaded bytes exceed max_bytes.

**security** [MEDIUM] A04: The 20 MB per-image cap is enforced only after `response.content` has buffered the entire response body. A malicious or compromised image URL can cause excessive memory consumption despite the cap. Stream the response and abort when Content-Length or downloaded bytes exceed max_bytes. <!-- wuming:sha256:9c2042b331fa4593dba4af9a7445406b3c7cd309a3175da7a86e003544752d6e -->
@ -0,0 +10,4 @@
bp = Blueprint("media", __name__, url_prefix="/media")
@bp.route("/<path:filename>")

security [MEDIUM]

A01: The media serving route is not protected by any authentication or authorization check. Stored article images are served to anyone who knows the hash-based filename, even if reader content is private. Apply the same login/authorization controls used by reader and feed routes.

**security** [MEDIUM] A01: The media serving route is not protected by any authentication or authorization check. Stored article images are served to anyone who knows the hash-based filename, even if reader content is private. Apply the same login/authorization controls used by reader and feed routes. <!-- wuming:sha256:61059cf488e7d474270eba096f77b4494aa263b239d7b402dfa55e7831b60470 -->
marvin8 marked this conversation as resolved
@ -0,0 +200,4 @@
url="https://example.com/done.png",
media_type="image/png",
)
media_db.set_media_local_file(db_conn, media_id=1, local_filename="done.png")

tests [LOW]

The test discards the object returned by media_db.create_media(...) and instead hardcodes media_id=1, relying on SQLite AUTOINCREMENT starting at 1 for this fresh temp DB. This is brittle: any change to fixture setup, prior inserts, or ID assignment silently makes the test mark the wrong record as downloaded (or no record at all), so assert_not_called() would pass for the wrong reason. Capture the created media (media = media_db.create_media(...)) and pass media_id=media.id.

**tests** [LOW] The test discards the object returned by `media_db.create_media(...)` and instead hardcodes `media_id=1`, relying on SQLite AUTOINCREMENT starting at 1 for this fresh temp DB. This is brittle: any change to fixture setup, prior inserts, or ID assignment silently makes the test mark the wrong record as downloaded (or no record at all), so `assert_not_called()` would pass for the wrong reason. Capture the created media (`media = media_db.create_media(...)`) and pass `media_id=media.id`. <!-- wuming:sha256:96d736e33e2f5157f8d24469b98f6bd2f59d03d40ae3813f1f16daf82e0c5e25 -->
🐛 Extract media from feed entry summaries and content
All checks were successful
/ gitleaks (pull_request) Successful in 15s
/ checks (pull_request) Successful in 1m21s
/ pr-review (pull_request) Successful in 5m8s
fa98e83947
Author
Collaborator

RenewEconomy images: extraction fix

Found while reviewing #21 against a real feed: RenewEconomy articles never had images because extract_media_from_feed_entry only read media_content/media_thumbnail/enclosures — and RenewEconomy's WordPress feed carries its featured image as an ordinary <img> inside the entry summary (verified live: the summary contains the <img> tag, but extraction returned nothing). The stored article content had the same tags — the extractor just never looked.

  • extract_media_from_feed_entry now also scans the entry summary and content value with the existing _extract_img_tags helper (media-namespace and enclosure extraction unchanged)
  • Synergy with the new downloader in this PR: once extraction records those URLs, they are downloaded with the browser User-Agent and served locally — RenewEconomy thumbnails become immune to origin hotlink behaviour
  • 3 new tests with a RenewEconomy-shaped entry (summary-only, content-value, and media:content + summary combined)
## RenewEconomy images: extraction fix Found while reviewing #21 against a real feed: RenewEconomy articles never had images because `extract_media_from_feed_entry` only read `media_content`/`media_thumbnail`/`enclosures` — and RenewEconomy's WordPress feed carries its featured image as an ordinary `<img>` inside the entry **summary** (verified live: the summary contains the `<img>` tag, but extraction returned nothing). The stored article content had the same tags — the extractor just never looked. - `extract_media_from_feed_entry` now also scans the entry summary and content value with the existing `_extract_img_tags` helper (media-namespace and enclosure extraction unchanged) - Synergy with the new downloader in this PR: once extraction records those URLs, they are downloaded with the browser User-Agent and served locally — RenewEconomy thumbnails become immune to origin hotlink behaviour - 3 new tests with a RenewEconomy-shaped entry (summary-only, content-value, and media:content + summary combined)
forgejo-actions left a comment

WuMing

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

## WuMing Found **4** issue(s). See inline comments below.
@ -0,0 +35,4 @@
"image/avif": ".avif",
"image/svg+xml": ".svg",
}
_URL_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".svg"}

security [HIGH]

A03: Stored XSS via SVG. Allowing .svg as a URL-derived extension means an attacker-controlled feed URL ending in .svg (returning any image/* content type) is stored locally and served inline by /media/<filename> as image/svg+xml on the application origin. A malicious SVG can execute script when opened directly, leading to session/account compromise. Disallow SVG or serve it with Content-Disposition: attachment and a sandboxing CSP.

**security** [HIGH] A03: Stored XSS via SVG. Allowing `.svg` as a URL-derived extension means an attacker-controlled feed URL ending in `.svg` (returning any `image/*` content type) is stored locally and served inline by `/media/<filename>` as `image/svg+xml` on the application origin. A malicious SVG can execute script when opened directly, leading to session/account compromise. Disallow SVG or serve it with `Content-Disposition: attachment` and a sandboxing CSP. <!-- wuming:sha256:f307869582e8559dc208c9c33667333f7d45b877b3ab3b78476d8cd41e8c9a63 -->
@ -0,0 +121,4 @@
downloaded = 0
for record in records:
try:
response = httpx2.get(

security [HIGH]

A10: SSRF via user-controlled media URLs. record.url originates from untrusted feed content and is fetched without allow-list validation, scheme restriction, or blocking of private/link-local/metadata targets. Redirects are followed, so an attacker-controlled feed can make the server request internal services or cloud metadata endpoints. Restrict to http/https, validate hosts, block private and reserved IP ranges, and revalidate redirect targets.

**security** [HIGH] A10: SSRF via user-controlled media URLs. `record.url` originates from untrusted feed content and is fetched without allow-list validation, scheme restriction, or blocking of private/link-local/metadata targets. Redirects are followed, so an attacker-controlled feed can make the server request internal services or cloud metadata endpoints. Restrict to http/https, validate hosts, block private and reserved IP ranges, and revalidate redirect targets. <!-- wuming:sha256:bfe39d19fd485f5e7a4197acd6a5b0fdeb2414d4e7b9c4b8639cef1b926733d5 -->
@ -0,0 +66,4 @@
assert downloaded == 1
mock_get.assert_called_once()
assert mock_get.call_args.kwargs["headers"]["User-Agent"].startswith("Mozilla/5.0")

tests [LOW]

This assertion pins the default User-Agent, but _user_agent() reads YUNJIN_HTTP_USER_AGENT from the environment at call time. If that variable is set in the developer's shell or CI (which is exactly what the new override is for), the test fails for an unrelated reason. Isolate the ambient environment (e.g. monkeypatch.delenv("YUNJIN_HTTP_USER_AGENT", raising=False)) in the test, or add a companion test that sets it and asserts the override is used.

**tests** [LOW] This assertion pins the default User-Agent, but `_user_agent()` reads `YUNJIN_HTTP_USER_AGENT` from the environment at call time. If that variable is set in the developer's shell or CI (which is exactly what the new override is for), the test fails for an unrelated reason. Isolate the ambient environment (e.g. `monkeypatch.delenv("YUNJIN_HTTP_USER_AGENT", raising=False)`) in the test, or add a companion test that sets it and asserts the override is used. <!-- wuming:sha256:ea17f30c6746bf4d2ae5c6cf68a521968afbeb89c4c3c20407e9937d81278113 -->
@ -0,0 +200,4 @@
url="https://example.com/done.png",
media_type="image/png",
)
media_db.set_media_local_file(db_conn, media_id=1, local_filename="done.png")

tests [LOW]

This test hardcodes media_id=1 instead of using the id of the record returned by media_db.create_media(...). The test only passes because the media row happens to be the first one in a fresh database; any change to fixture/setup ordering (e.g. an extra media row created earlier) would make the UPDATE target a non-existent row and the assertion would fail for the wrong reason. Capture the created media and pass media_id=media.id, matching the pattern used in tests/test_db.py.

**tests** [LOW] This test hardcodes `media_id=1` instead of using the id of the record returned by `media_db.create_media(...)`. The test only passes because the media row happens to be the first one in a fresh database; any change to fixture/setup ordering (e.g. an extra media row created earlier) would make the UPDATE target a non-existent row and the assertion would fail for the wrong reason. Capture the created media and pass `media_id=media.id`, matching the pattern used in tests/test_db.py. <!-- wuming:sha256:e58f3c81f5552067d6a89896d835454256a995ada6ef0f244ce61ad91b27071c -->
🔒 Guard media downloads: URL validation, SVG exclusion, streaming size cap
All checks were successful
/ gitleaks (pull_request) Successful in 13s
/ checks (pull_request) Successful in 1m27s
/ pr-review (pull_request) Successful in 4m31s
9d82ec2cd2
Author
Collaborator

src/yunjin/services/media_downloader.py

A10: The downloader fetches record.url from untrusted feed metadata without validating the URL scheme, host, or resolved IP…

Fixed — _is_safe_media_url() mirrors the feed-fetch guard: http/https only, host resolved and rejected when any address is private/loopback/link-local/reserved/unspecified/multicast, unresolvable hosts fail closed, with the existing YUNJIN_ALLOW_PRIVATE_FEED_HOSTS opt-out for LAN media sources. Tests cover non-http schemes, private literals, unresolvable hosts, and the opt-out. Known limitation documented in the module docstring (TOCTOU vs the client's own resolution; redirects not re-validated) — same accepted posture as the feed guard.

[`src/yunjin/services/media_downloader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2846) > A10: The downloader fetches `record.url` from untrusted feed metadata without validating the URL scheme, host, or resolved IP… ✅ Fixed — `_is_safe_media_url()` mirrors the feed-fetch guard: http/https only, host resolved and rejected when any address is private/loopback/link-local/reserved/unspecified/multicast, unresolvable hosts fail closed, with the existing `YUNJIN_ALLOW_PRIVATE_FEED_HOSTS` opt-out for LAN media sources. Tests cover non-http schemes, private literals, unresolvable hosts, and the opt-out. Known limitation documented in the module docstring (TOCTOU vs the client's own resolution; redirects not re-validated) — same accepted posture as the feed guard.
Author
Collaborator

src/yunjin/services/media_downloader.py

A03: SVG files are allowed and stored unmodified…

Fixed — storage is now a whitelist of raster types (jpeg/png/gif/webp/avif); image/svg+xml and .svg URLs are never stored, so no served SVG can carry script. Test asserts an SVG response is skipped and nothing is written.

[`src/yunjin/services/media_downloader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2847) > A03: SVG files are allowed and stored unmodified… ✅ Fixed — storage is now a whitelist of raster types (jpeg/png/gif/webp/avif); `image/svg+xml` and `.svg` URLs are never stored, so no served SVG can carry script. Test asserts an SVG response is skipped and nothing is written.
Author
Collaborator

src/yunjin/services/media_downloader.py

A04: The 20 MB per-image cap is enforced only after response.content has buffered the entire response…

Fixed — downloads now use httpx2.stream(): the declared Content-Length is checked before reading, and the body is consumed in 64 KB chunks with the cap enforced during the read (aborting mid-stream). Tests cover both the declared-length rejection (asserting the body is never read) and the mid-stream abort.

[`src/yunjin/services/media_downloader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2849) > A04: The 20 MB per-image cap is enforced only after `response.content` has buffered the entire response… ✅ Fixed — downloads now use `httpx2.stream()`: the declared `Content-Length` is checked before reading, and the body is consumed in 64 KB chunks with the cap enforced during the read (aborting mid-stream). Tests cover both the declared-length rejection (asserting the body is never read) and the mid-stream abort.
Author
Collaborator

src/yunjin/web/routes/media.py

A01: The media serving route is not protected by any authentication or authorization check…

🔴 Not actioned in this PR — there is no authentication layer anywhere in the app; reader, feeds and settings routes are equally open. Session-derived auth is tracked in #25 (Phase 6), which will apply the same controls project-wide.

[`src/yunjin/web/routes/media.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2848) > A01: The media serving route is not protected by any authentication or authorization check… 🔴 Not actioned in this PR — there is no authentication layer anywhere in the app; reader, feeds and settings routes are equally open. Session-derived auth is tracked in #25 (Phase 6), which will apply the same controls project-wide.
Author
Collaborator

tests/test_media_downloader.py

The test discards the object returned by create_media and instead hardcodes media_id=1…

Fixed — tests now keep the returned record and assert against its id (stored.id == record.id).

[`tests/test_media_downloader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2850) > The test discards the object returned by create_media and instead hardcodes media_id=1… ✅ Fixed — tests now keep the returned record and assert against its id (`stored.id == record.id`).
Author
Collaborator

tests/test_media_downloader.py

This assertion pins the default User-Agent, but _user_agent() reads YUNJIN_HTTP_USER_AGENT from the environment…

Fixed — the test clears the variable with monkeypatch.delenv(..., raising=False) for the default assertion and sets it for the override assertion.

[`tests/test_media_downloader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2858) > This assertion pins the default User-Agent, but `_user_agent()` reads YUNJIN_HTTP_USER_AGENT from the environment… ✅ Fixed — the test clears the variable with `monkeypatch.delenv(..., raising=False)` for the default assertion and sets it for the override assertion.
Author
Collaborator

Duplicates of wave-260 findings (all already fixed)

SSRF · SVG XSS · hardcoded media_id

Identical to the findings answered above: SSRF guard (_is_safe_media_url + private-range blocking + opt-out), SVG excluded from the raster whitelist, tests using the record returned by create_media. No further action.

### Duplicates of wave-260 findings (all already fixed) [`SSRF`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2855) · [`SVG XSS`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2856) · [`hardcoded media_id`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2857) Identical to the findings answered above: SSRF guard (`_is_safe_media_url` + private-range blocking + opt-out), SVG excluded from the raster whitelist, tests using the record returned by `create_media`. No further action.
forgejo-actions left a comment

WuMing

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

## WuMing Found **6** issue(s). See inline comments below.
@ -241,0 +295,4 @@
"""
SELECT id, article_id, url, media_type, created_at, local_filename, downloaded_at
FROM media
WHERE article_id = ? AND local_filename IS NULL

code [MEDIUM]

This query selects all undownloaded media for an article, including non-image enclosures such as audio or video. The downloader only stores raster image content types, so every such item will still be fetched remotely (up to the size cap) just to be rejected. Filter to image media or media with a NULL/unknown type, e.g. AND (media_type IS NULL OR media_type LIKE 'image/%').

**code** [MEDIUM] This query selects all undownloaded media for an article, including non-image enclosures such as audio or video. The downloader only stores raster image content types, so every such item will still be fetched remotely (up to the size cap) just to be rejected. Filter to image media or media with a NULL/unknown type, e.g. `AND (media_type IS NULL OR media_type LIKE 'image/%')`. <!-- wuming:sha256:ef3cf66ae2b84f74dca134a3968072e0f5877062873838072ad7c552cc59f7d8 -->
@ -0,0 +31,4 @@
from pathlib import Path
from urllib.parse import urlsplit
import httpx2

code [CRITICAL]

This imports a module named httpx2, but the standard HTTP client library is httpx. Unless the project intentionally depends on a custom or vendored httpx2 package, this import will fail and the media downloader will be completely broken. Change to import httpx and update all httpx2.* references accordingly.

**code** [CRITICAL] This imports a module named `httpx2`, but the standard HTTP client library is `httpx`. Unless the project intentionally depends on a custom or vendored `httpx2` package, this import will fail and the media downloader will be completely broken. Change to `import httpx` and update all `httpx2.*` references accordingly. <!-- wuming:sha256:0b8970c4bddc9f60209022b94338253f8de6188587a52ef2ea91c60f5965a9b1 -->
@ -0,0 +162,4 @@
Local file name.
"""
suffix = Path(urlsplit(url).path).suffix.lower()

code [MEDIUM]

The local filename extension is taken from the URL path first, even though the response Content-Type is known and validated. A URL ending in .jpg that actually serves image/png will be stored with a .jpg extension, and send_from_directory will serve it with image/jpeg content-type, causing broken or misleading image responses. Prefer the validated response Content-Type extension consistently, or verify that the URL suffix matches the actual content type.

**code** [MEDIUM] The local filename extension is taken from the URL path first, even though the response `Content-Type` is known and validated. A URL ending in `.jpg` that actually serves `image/png` will be stored with a `.jpg` extension, and `send_from_directory` will serve it with `image/jpeg` content-type, causing broken or misleading image responses. Prefer the validated response Content-Type extension consistently, or verify that the URL suffix matches the actual content type. <!-- wuming:sha256:993d2f9516a24e0793f5a4d869ac0c11408269d1e6b621f968f6d6d28e0d66d7 -->
@ -0,0 +185,4 @@
"GET",
record.url,
timeout=_TIMEOUT_SECONDS,
follow_redirects=True,

code [HIGH]

Redirects are followed without re-validating the redirect target. The original URL is checked by _is_safe_media_url, but a public URL can redirect to a private, loopback, or metadata-service address and bypass the SSRF guard. Revalidate every redirect target with the same host/address checks or disable automatic redirects and implement manual, validated redirect handling.

**code** [HIGH] Redirects are followed without re-validating the redirect target. The original URL is checked by `_is_safe_media_url`, but a public URL can redirect to a private, loopback, or metadata-service address and bypass the SSRF guard. Revalidate every redirect target with the same host/address checks or disable automatic redirects and implement manual, validated redirect handling. <!-- wuming:sha256:0545293b067ddcd4222db4c8e46b06bcd24fd147b0c8d7c98dd7edbdfdc64265 -->

security [HIGH]

The initial media URL is validated by _is_safe_media_url, but the HTTP client is configured with follow_redirects=True and does not re-validate redirect destinations. A malicious public URL can redirect to internal addresses (e.g., 169.254.169.254, localhost, internal services) and bypass the SSRF guard. Remediate by disabling automatic redirects and manually validating each redirect hop with the same scheme/private-address checks before following it.

**security** [HIGH] The initial media URL is validated by _is_safe_media_url, but the HTTP client is configured with follow_redirects=True and does not re-validate redirect destinations. A malicious public URL can redirect to internal addresses (e.g., 169.254.169.254, localhost, internal services) and bypass the SSRF guard. Remediate by disabling automatic redirects and manually validating each redirect hop with the same scheme/private-address checks before following it. <!-- wuming:sha256:0545293b067ddcd4222db4c8e46b06bcd24fd147b0c8d7c98dd7edbdfdc64265 -->
@ -0,0 +198,4 @@
return None
declared_length = response.headers.get("content-length")
if declared_length and int(declared_length) > max_bytes:

code [HIGH]

Content-Length is attacker-controlled. If it is missing int() is skipped, but if it is malformed/non-integer or duplicated into an unexpected string, int(declared_length) raises ValueError, which is not caught by the existing except httpx2.HTTPError. A single bad header can abort the whole article download job. Validate and parse the header defensively, or catch ValueError and treat it as a rejected response.

**code** [HIGH] `Content-Length` is attacker-controlled. If it is missing `int()` is skipped, but if it is malformed/non-integer or duplicated into an unexpected string, `int(declared_length)` raises `ValueError`, which is not caught by the existing `except httpx2.HTTPError`. A single bad header can abort the whole article download job. Validate and parse the header defensively, or catch `ValueError` and treat it as a rejected response. <!-- wuming:sha256:aa920e6c6704a04ae3f046ba085b0fda77b8de514ee0678ce51b97ee6023e429 -->
🔒 Re-validate media redirects, harden Content-Length, filter image media
All checks were successful
/ gitleaks (pull_request) Successful in 15s
/ checks (pull_request) Successful in 1m32s
/ pr-review (pull_request) Successful in 4m47s
849e162a56
Author
Collaborator

src/yunjin/services/media_downloader.py

This imports a module named httpx2, but the standard HTTP client library is httpx

🔴 Factually incorrect — httpx2 is pydantic's fork of httpx and the project's deliberate HTTP client: AGENTS.md mandates it ("use httpx2 … never requests") and it has been the direct dependency since PR #15, used by fetcher, media_extractor, and llm_aggregator. The import cannot fail; httpx is not a dependency at all. No change.

[`src/yunjin/services/media_downloader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2869) > This imports a module named `httpx2`, but the standard HTTP client library is `httpx`… 🔴 Factually incorrect — `httpx2` is pydantic's fork of httpx and **the project's deliberate HTTP client**: AGENTS.md mandates it ("use httpx2 … never requests") and it has been the direct dependency since PR #15, used by fetcher, media_extractor, and llm_aggregator. The import cannot fail; `httpx` is not a dependency at all. No change.
Author
Collaborator

src/yunjin/services/media_downloader.py

Redirects are followed without re-validating the redirect target…

Fixed — downloads now follow redirects manually (follow_redirects=False) with a 5-hop limit, re-running _is_safe_media_url (scheme + resolved-address checks) on every hop before any request is made — mirroring the feed-fetch guard from PR #28. Tests: a redirect to 169.254.169.254 is blocked before the second request (asserting a single stream call), and a redirect to a public CDN is followed and stored.

[`src/yunjin/services/media_downloader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2870) > Redirects are followed without re-validating the redirect target… ✅ Fixed — downloads now follow redirects **manually** (`follow_redirects=False`) with a 5-hop limit, re-running `_is_safe_media_url` (scheme + resolved-address checks) on every hop before any request is made — mirroring the feed-fetch guard from PR #28. Tests: a redirect to `169.254.169.254` is blocked before the second request (asserting a single stream call), and a redirect to a public CDN is followed and stored.
Author
Collaborator

src/yunjin/services/media_downloader.py

The initial media URL is validated, but the client uses follow_redirects=True…

Fixed — same change as the duplicate finding above: manual redirect handling with per-hop re-validation.

[`src/yunjin/services/media_downloader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2874) > The initial media URL is validated, but the client uses follow_redirects=True… ✅ Fixed — same change as the duplicate finding above: manual redirect handling with per-hop re-validation.
Author
Collaborator

src/yunjin/services/media_downloader.py

Content-Length is attacker-controlled. … int(declared_length) raises ValueError, which is not caught…

Fixed — new _declared_length_exceeds() parses the header defensively: absent or unparseable values return False and fall back to the streaming cap (which still applies during the chunked read). Test covers a junk content-length: abc header with a body above the cap — no exception, nothing stored.

[`src/yunjin/services/media_downloader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2871) > `Content-Length` is attacker-controlled. … `int(declared_length)` raises ValueError, which is not caught… ✅ Fixed — new `_declared_length_exceeds()` parses the header defensively: absent or unparseable values return False and fall back to the streaming cap (which still applies during the chunked read). Test covers a junk `content-length: abc` header with a body above the cap — no exception, nothing stored.
Author
Collaborator

src/yunjin/services/media_downloader.py

The local filename extension is taken from the URL path first…

Fixed — the extension now comes solely from the validated response content type, so a .jpg URL serving image/png is stored and served as .png. Test asserts the content type wins over a misleading URL suffix.

[`src/yunjin/services/media_downloader.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2872) > The local filename extension is taken from the URL path first… ✅ Fixed — the extension now comes solely from the validated response content type, so a `.jpg` URL serving `image/png` is stored and served as `.png`. Test asserts the content type wins over a misleading URL suffix.
Author
Collaborator

src/yunjin/db/media.py

This query selects all undownloaded media … including non-image enclosures…

Fixed — get_undownloaded_media_for_article now filters to image media (media_type IS NULL OR media_type LIKE 'image/%'), matching get_first_image. Test asserts a video row is not queued while an image row is.

[`src/yunjin/db/media.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/29#issuecomment-2873) > This query selects all undownloaded media … including non-image enclosures… ✅ Fixed — `get_undownloaded_media_for_article` now filters to image media (`media_type IS NULL OR media_type LIKE 'image/%'`), matching `get_first_image`. Test asserts a video row is not queued while an image row is.
marvin8 approved these changes 2026-09-12 09:07:44 +00:00
marvin8 manually merged commit 0a3d8f8771 into main 2026-09-12 09:09:11 +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!29
No description provided.