From db092e71e4f23c6875835ca3bd7c02ca8e714cd5 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 5 Aug 2026 17:06:10 -0500 Subject: [PATCH 1/3] fix(redirects): survive FreshRSS cores older than 1.29.2 FreshRSS_http_Util::getCurlResolveInfo() was added to core after 1.29.1 and is in no tagged release, so resolving a feed's permanent redirects raised a fatal "call to undefined method" on every released FreshRSS, taking the whole add-feed request down with a 500. Guard the call. Cores without it probe without DNS pinning, which is the same exposure they already carry for every feed fetch, since their httpGet() did not pin DNS either -- the alternative, refusing to probe, would silently disable redirect resolution on every release. PHPStan calls the guard redundant whichever core it analyses, from opposite directions, so phpstan.neon carries both suppressions; reportUnmatchedIgnored- Errors lets the inapplicable one pass. Verified clean against edge, 1.29.1 and 1.29.0. Co-Authored-By: Claude Opus 5 (1M context) --- RssCloud/Redirects.php | 15 ++++++++++----- phpstan.neon | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/RssCloud/Redirects.php b/RssCloud/Redirects.php index 5fc33b6..fec1ca9 100644 --- a/RssCloud/Redirects.php +++ b/RssCloud/Redirects.php @@ -148,11 +148,16 @@ private static function permanentLocation(string $url, array $attributes): strin return false; } - // Re-checked at every hop, so a redirect cannot walk into the private network. - $resolve = FreshRSS_http_Util::getCurlResolveInfo($url); - if (!is_array($resolve)) { - // null: the host's IP is not in the allowlist. false: the host did not resolve. - return false; + // Re-checked at every hop, so a redirect cannot walk into the private network. Cores older than + // FreshRSS 1.29.2 have no such check to offer — `httpGet()` did not pin DNS there either, so the + // probe goes ahead unpinned rather than disabling redirect resolution on every released version. + $resolve = []; + if (method_exists(FreshRSS_http_Util::class, 'getCurlResolveInfo')) { + $resolve = FreshRSS_http_Util::getCurlResolveInfo($url); + if (!is_array($resolve)) { + // null: the host's IP is not in the allowlist. false: the host did not resolve. + return false; + } } $ch = curl_init(); diff --git a/phpstan.neon b/phpstan.neon index eaee51a..3adfdeb 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -23,3 +23,21 @@ parameters: scanDirectories: - ../../app - ../../lib + + # `getCurlResolveInfo()` arrived after 1.29.1, so whether it exists depends on which core is + # checked out alongside -- something PHPStan cannot model from one checkout. Whichever core it + # analyses, it calls the runtime guard redundant, just from opposite directions. Both entries + # are listed so a single config serves either; `reportUnmatchedIgnoredErrors: false` above lets + # the inapplicable one pass unmatched. Drop the guard and this whole block once 1.29.2 is the + # oldest supported release. + ignoreErrors: + - + # Against edge, which has the method. + identifier: function.alreadyNarrowedType + message: '#Call to function method_exists\(\) with .FreshRSS_http_Util. and .getCurlResolveInfo. will always evaluate to true\.#' + path: RssCloud/Redirects.php + - + # Against 1.29.1 and older, which do not. + identifier: function.impossibleType + message: '#Call to function method_exists\(\) with .FreshRSS_http_Util. and .getCurlResolveInfo. will always evaluate to false\.#' + path: RssCloud/Redirects.php From 6e05456f2724351396e3680f09e046fd19226ee1 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 5 Aug 2026 17:06:18 -0500 Subject: [PATCH 2/3] ci: analyse against the oldest supported release, not just edge Pinning the core checkout to edge is how a call to an edge-only core method reached users as a fatal error on every released FreshRSS: the one core that had the method was the only one ever analysed. Run a matrix over both ends of the supported range instead -- the development tip and 1.29.0, the oldest release this extension supports. fail-fast is off so a break on one core still reports the other. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6aeb9de..ca9aecb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,15 @@ permissions: jobs: php: - name: PHP + name: PHP (FreshRSS ${{ matrix.freshrss }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Both ends of the supported range. Analysing only the development tip is how a call to + # a core method that exists solely on edge -- FreshRSS_http_Util::getCurlResolveInfo(), + # added after 1.29.1 -- reached users as a fatal error on every released version. + freshrss: [edge, '1.29.0'] steps: # This extension cannot be analysed on its own: every class it touches # (Minz_Extension, FreshRSS_Feed, …) and the global helpers (_t, _i, _url) @@ -20,7 +27,7 @@ jobs: uses: actions/checkout@v7 with: repository: FreshRSS/FreshRSS - ref: edge + ref: ${{ matrix.freshrss }} path: FreshRSS - name: Check out this extension inside it From 802b184e4732e526ff25b9ea2cb7aaf06cb8134d Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 5 Aug 2026 17:06:18 -0500 Subject: [PATCH 3/3] docs: state the FreshRSS 1.29.0 minimum The binding constraint is Minz_HookType::FeedsListBeforeActualize, which core added in 1.29.0. Also records which behaviour degrades on cores predating getCurlResolveInfo() rather than being required outright. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 705a4d4..e8dc72a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,17 @@ push as a feed. This runs alongside core's WebSub support and shares nothing with it. A feed advertising both a hub and a cloud will be subscribed through both. +## Requirements + +**FreshRSS 1.29.0 or newer.** The binding constraint is `Minz_HookType::FeedsListBeforeActualize`, +which core added in that release. + +One behaviour is version-dependent rather than required. When walking a feed's permanent redirects, +each hop is re-checked against the internal-host allowlist through +`FreshRSS_http_Util::getCurlResolveInfo()`, which core added after 1.29.1. Cores without it skip +that check and probe without DNS pinning — the same exposure those versions already carry for every +feed fetch, since their `httpGet()` did not pin DNS either. + ## Discovery | Resource | Advertisement | Read from |