Use cURL for Bluesky API calls instead of file_get_contents #5

Merged
thatguygriff merged 1 commits from fix/curl-transport-freshrss-130 into main 2026-09-10 13:35:36 +00:00
Collaborator

Problem

Bluesky posts stopped rendering their threads and embeds after the FreshRSS 1.30.0 upgrade — e.g. https://bsky.app/profile/hockeyviz.com/post/3mv5vvctmmc2c showed only the raw RSS content.

The cause is new in 1.30.0 — lib/lib_rss.php:

function unregister_unsafe_protocols(): void {
	$registered_wrappers = stream_get_wrappers();
	$allowed_wrappers = ['file', 'php'];
	foreach ($registered_wrappers as $protocol) {
		if (!in_array($protocol, $allowed_wrappers, true)) {
			stream_wrapper_unregister($protocol);
		}
	}
}
if (!defined('WITH_COMPOSER')) { unregister_unsafe_protocols(); }

Every stream wrapper except file:// and php:// is unregistered at boot as an SSRF mitigation. This function does not exist in 1.29.1. apiGet() used file_get_contents() with a stream context, so every Bluesky API call began failing with "Unable to find the wrapper https".

Nothing changed on Bluesky's side — the live API response for the example post renders correctly through the existing renderThread().

Why it was invisible

The call site used @file_get_contents(). So apiGet() returned null, loadThread() fell through to its null fallback, and entries were persisted with unenriched content — with nothing in the logs. The sibling xExtension-BlueskyImages, which makes the same call without @, is what surfaced the actual error.

Corroborating evidence: zero writes to data/BlueskyThreads/ since the rollout (out of 813 cache files), and the first BlueskyImages warning appears shortly after it.

Changes

  • apiGet() now uses cURL. cURL doesn't go through stream wrappers — which is also why FreshRSS's own SimplePie feed fetching kept working throughout. Timeout, User-Agent and Accept header preserved; added a 5s connect timeout and explicitly set CURLOPT_FOLLOWLOCATION => false, since a public XRPC endpoint has no reason to redirect and following redirects would reopen an SSRF vector.
  • Failures are logged. Transport errors, non-2xx responses and malformed JSON are now distinguished and reported via Minz_Log::warning(), prefixed [BlueskyThreads]. Guarded with class_exists so the extension still loads standalone.
  • .gitea/workflows/ci.yml — added curl to the PHP extensions list, now a hard dependency.
  • CLAUDE.md — documented why the transport must be cURL, so it doesn't get refactored back.

Tests

25 pass, no skips.

  • testApiGetWorksWithoutHttpsStreamWrapper — unregisters the https wrapper, then calls the live API and asserts the DID comes back. This reproduces the production condition directly and fails against the old implementation.
  • testApiGetLogsWarningOnFailure — a failed call must return null and log. Passes online (bogus method → 4xx) and offline (connection error) alike.

Also verified end-to-end with all non-file/php wrappers unregistered, replicating 1.30.0's boot exactly:

wrappers now: php,file
log: (none)
enriched: YES
img tags: 1     blockquotes: 2     cdn.bsky.app refs: 2

The cache read on extension.php:151 still uses file_get_contents, but that's a local path on the retained file:// wrapper — confirmed fine by that run writing its cache entry without warnings.

Deploy notes

Entries inserted during the outage are recent, so they sit inside the 7-day freeze window and will re-fetch on next render. That happens in refreshThread on EntryBeforeDisplay, so API-only clients won't heal until the affected posts are rendered once in the web UI to trigger the DB write-back.

Unrelated to this PR but from the same upgrade: 1.30.0's other breaking change (local networks blocked by default) is breaking the in-cluster RSS-Bridge feeds; that needs INTERNAL_HOST_ALLOWLIST set on the deployment.

🤖 Generated with Claude Code

## Problem Bluesky posts stopped rendering their threads and embeds after the FreshRSS **1.30.0** upgrade — e.g. https://bsky.app/profile/hockeyviz.com/post/3mv5vvctmmc2c showed only the raw RSS content. The cause is new in 1.30.0 — `lib/lib_rss.php`: ```php function unregister_unsafe_protocols(): void { $registered_wrappers = stream_get_wrappers(); $allowed_wrappers = ['file', 'php']; foreach ($registered_wrappers as $protocol) { if (!in_array($protocol, $allowed_wrappers, true)) { stream_wrapper_unregister($protocol); } } } if (!defined('WITH_COMPOSER')) { unregister_unsafe_protocols(); } ``` Every stream wrapper except `file://` and `php://` is unregistered at boot as an SSRF mitigation. This function does not exist in 1.29.1. `apiGet()` used `file_get_contents()` with a stream context, so every Bluesky API call began failing with *"Unable to find the wrapper https"*. Nothing changed on Bluesky's side — the live API response for the example post renders correctly through the existing `renderThread()`. ### Why it was invisible The call site used `@file_get_contents()`. So `apiGet()` returned `null`, `loadThread()` fell through to its `null` fallback, and entries were persisted with unenriched content — with **nothing in the logs**. The sibling `xExtension-BlueskyImages`, which makes the same call without `@`, is what surfaced the actual error. Corroborating evidence: zero writes to `data/BlueskyThreads/` since the rollout (out of 813 cache files), and the first `BlueskyImages` warning appears shortly after it. ## Changes - **`apiGet()` now uses cURL.** cURL doesn't go through stream wrappers — which is also why FreshRSS's own SimplePie feed fetching kept working throughout. Timeout, User-Agent and `Accept` header preserved; added a 5s connect timeout and explicitly set `CURLOPT_FOLLOWLOCATION => false`, since a public XRPC endpoint has no reason to redirect and following redirects would reopen an SSRF vector. - **Failures are logged.** Transport errors, non-2xx responses and malformed JSON are now distinguished and reported via `Minz_Log::warning()`, prefixed `[BlueskyThreads]`. Guarded with `class_exists` so the extension still loads standalone. - **`.gitea/workflows/ci.yml`** — added `curl` to the PHP extensions list, now a hard dependency. - **`CLAUDE.md`** — documented why the transport must be cURL, so it doesn't get refactored back. ## Tests 25 pass, no skips. - `testApiGetWorksWithoutHttpsStreamWrapper` — unregisters the `https` wrapper, then calls the live API and asserts the DID comes back. This reproduces the production condition directly and **fails against the old implementation**. - `testApiGetLogsWarningOnFailure` — a failed call must return null *and* log. Passes online (bogus method → 4xx) and offline (connection error) alike. Also verified end-to-end with *all* non-`file`/`php` wrappers unregistered, replicating 1.30.0's boot exactly: ``` wrappers now: php,file log: (none) enriched: YES img tags: 1 blockquotes: 2 cdn.bsky.app refs: 2 ``` The cache read on `extension.php:151` still uses `file_get_contents`, but that's a local path on the retained `file://` wrapper — confirmed fine by that run writing its cache entry without warnings. ## Deploy notes Entries inserted during the outage are recent, so they sit inside the 7-day freeze window and will re-fetch on next render. That happens in `refreshThread` on `EntryBeforeDisplay`, so API-only clients won't heal until the affected posts are rendered once in the web UI to trigger the DB write-back. Unrelated to this PR but from the same upgrade: 1.30.0's other breaking change (local networks blocked by default) is breaking the in-cluster RSS-Bridge feeds; that needs `INTERNAL_HOST_ALLOWLIST` set on the deployment. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Kydoimos added 1 commit 2026-09-10 13:33:24 +00:00
FreshRSS 1.30.0 added unregister_unsafe_protocols() to lib/lib_rss.php,
which unregisters every PHP stream wrapper except file:// and php:// at
boot as an SSRF mitigation. That removed the https:// wrapper, so the
stream-context-based fetch in apiGet() stopped working entirely — every
API call failed with "Unable to find the wrapper https".

Because the call site used @file_get_contents(), the failure was silent:
apiGet() returned null, loadThread() fell through to its null fallback,
and entries were saved with their unenriched RSS content. The visible
symptom was Bluesky posts rendering with no thread and no embeds, with
nothing in the logs to explain it.

Switch apiGet() to cURL, which does not go through stream wrappers (and
is how FreshRSS fetches feeds itself, which is why feed retrieval kept
working throughout). Connect timeout added and redirect-following
explicitly disabled — a public XRPC endpoint has no reason to redirect,
and following redirects would reopen an SSRF vector.

Also stop swallowing failures: transport errors, non-2xx responses and
malformed JSON are now reported via Minz_Log::warning() so this class of
breakage is visible next time.

Tests: add testApiGetWorksWithoutHttpsStreamWrapper, which unregisters
the https wrapper before calling the API and so fails against the old
implementation, plus testApiGetLogsWarningOnFailure. Add a Minz_Log stub
and curl to the CI extension list.

Co-Authored-By: Claude Opus 5 <[email protected]>
thatguygriff merged commit 6d7410e737 into main 2026-09-10 13:35:36 +00:00
thatguygriff deleted branch fix/curl-transport-freshrss-130 2026-09-10 13:35:36 +00:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: thatguygriff/xExtension-BlueskyThreads#5