Jelajahi Sumber

Add SSRF mitigations using `filter_var` and `CURLOPT_RESOLVE` (#8400)

* Add SSRF mitigations using `filter_var` and `CURLOPT_RESOLVE`
The idea is to prevent FreshRSS from sending any HTTP requests to internal services, except for the ones that are explicitly allowed in the config.

Based on https://github.com/moodle/moodle/blob/6e82b46a480826d1a85394d9e5087f7d82d1dd52/lib/filelib.php#L3818 and https://github.com/symfony/symfony/blob/8.1/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php

https://github.com/FreshRSS/simplepie/pull/76
https://github.com/FreshRSS/simplepie/pull/78

* Add allowlist setting in Web UI

* make readme

* Update app/i18n/fr/admin.php

Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>

* make readme again

* make readme

* Further work

Still WIP and needs testing etc.

* Readd previous if check for domain combination allowlist

* Turn POST to GET after redirect

* Improve

* Update config.default.php

Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>

* make readme

* Skip SSRF check if `CURLOPT_PROXY` is set

* make readme

* Fix `!empty()` mistake

* Respect max redirects feed option when fetching with `httpGet()`

* Respect max redirects during SimplePie fetching + fix bypass

bypass fix: `CURLOPT_FOLLOWLOCATION` was moved below so that emulated redirects are enforced.

* Avoid FreshRSS and Minz code in SimplePie
https://github.com/FreshRSS/FreshRSS/pull/8400#discussion_r2935375980

* Corrected hook code

* phpdoc wrong return type

* Add CIDR support in allowlist

* Implement simple DNS caching

* Suppress `dns_get_record()` warnings

* A bit of proof-reading

* Minor typo

* Fix proxy logic

* Fix HTTP POST redirect logic

* Proofread checkCIDR
Add fixes for several situations

* Remove credentials from URL in logs

* Ensure `CURLOPT_FOLLOWLOCATION` is `false` by setting it at the end

* Fix codesniffer long line

* Fix potential bypass due to wrong return value

If there were no records returned by `dns_get_record()`, no overrides to `CURLOPT_RESOLVE` would get passed,
and a potential bypass could occur, when cURL would try to resolve the domain by itself.

* Put the URL at the end in logs

* Add documentation and environment variable support

* make readme

* Fix wrong behavior in case of IP

* Fix duplicate selector in CSS

* Minor type check change

* i18n fr, en

* Minor type check change

* Fix whitespace i18n fr

* make fix-all

* Fix `$ips_ok` not being returned after domain records were cached

* make readme

* PHPStan fix

* make readme

* Minor syntax in SimplePie

* Only return `null` if no allowed IPs were found

* Add wildcard *, help message

* Consistent docs with help message

* i18n: pl

* SimplePie compatibility PHP 7.2

* make fix-all

* Sync SimplePie
* https://github.com/FreshRSS/simplepie/pull/76

* 💥 Breaking change in the Changelog

* Document `INTERNAL_HOST_ALLOWLIST` in Docker docs

* Remove `Cookie` and `Authorization` headers in `httpGet()` during cross-origin redirect

* Minor whitespace
And same comment convention than below

* Remove authentication headers and change POST to GET on redirect in SimplePie

* Remove .local in Docker example

* Fill in default ports when comparing URL origins

* Remove .local from other places than the Docker example

* Rewrite WebSub subscribe to use `httpGet()`

* make fix-all

* Also unset `CURLOPT_USERPWD` during redirects

* phpcs fix

* Always unset `CURLOPT_FOLLOWLOCATION`

* Bump SimplePie
https://github.com/FreshRSS/simplepie/pull/78

* Update logic for CURLOPT_FOLLOWLOCATION

* Fix PHPStan

* Changelog fix security section

* Update most common RSS Bridge case
https://hub.docker.com/r/rssbridge/rss-bridge

* Replace misleading 127.0.0.1:8080 example for Docker
This does not make sense for a Docker container

---------

Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Inverle 1 hari lalu
induk
melakukan
dcec27c69d
62 mengubah file dengan 779 tambahan dan 206 penghapusan
  1. 11 8
      CHANGELOG.md
  2. 7 0
      Docker/README.md
  3. 5 0
      Docker/freshrss/docker-compose.yml
  4. 7 7
      README.fr.md
  5. 7 7
      README.md
  6. 5 0
      app/Controllers/configureController.php
  7. 1 1
      app/Controllers/feedController.php
  8. 1 1
      app/Controllers/subscriptionController.php
  9. 3 38
      app/Models/Feed.php
  10. 21 0
      app/Models/SimplePieFetch.php
  11. 1 0
      app/Models/SystemConfiguration.php
  12. 335 99
      app/Utils/httpUtil.php
  13. 5 0
      app/i18n/cs/admin.php
  14. 5 0
      app/i18n/de/admin.php
  15. 5 0
      app/i18n/el/admin.php
  16. 5 0
      app/i18n/en-US/admin.php
  17. 5 0
      app/i18n/en/admin.php
  18. 5 0
      app/i18n/es/admin.php
  19. 5 0
      app/i18n/fa/admin.php
  20. 5 0
      app/i18n/fi/admin.php
  21. 5 0
      app/i18n/fr/admin.php
  22. 5 0
      app/i18n/he/admin.php
  23. 5 0
      app/i18n/hu/admin.php
  24. 5 0
      app/i18n/id/admin.php
  25. 5 0
      app/i18n/it/admin.php
  26. 5 0
      app/i18n/ja/admin.php
  27. 5 0
      app/i18n/ko/admin.php
  28. 5 0
      app/i18n/lv/admin.php
  29. 5 0
      app/i18n/nl/admin.php
  30. 5 0
      app/i18n/oc/admin.php
  31. 5 0
      app/i18n/pl/admin.php
  32. 5 0
      app/i18n/pt-BR/admin.php
  33. 5 0
      app/i18n/pt-PT/admin.php
  34. 5 0
      app/i18n/ru/admin.php
  35. 5 0
      app/i18n/sk/admin.php
  36. 5 0
      app/i18n/tr/admin.php
  37. 5 0
      app/i18n/uk/admin.php
  38. 5 0
      app/i18n/zh-CN/admin.php
  39. 5 0
      app/i18n/zh-TW/admin.php
  40. 26 0
      app/views/configure/system.phtml
  41. 16 1
      config.default.php
  42. 5 2
      docs/en/admins/09_AccessControl.md
  43. 16 3
      lib/Minz/Request.php
  44. 1 1
      lib/composer.json
  45. 145 14
      lib/simplepie/simplepie/src/File.php
  46. 1 1
      p/themes/Alternative-Dark/adark.css
  47. 1 1
      p/themes/Alternative-Dark/adark.rtl.css
  48. 1 1
      p/themes/Ansum/_forms.css
  49. 1 1
      p/themes/Ansum/_forms.rtl.css
  50. 1 1
      p/themes/Dark/dark.css
  51. 1 1
      p/themes/Dark/dark.rtl.css
  52. 1 1
      p/themes/Flat/flat.css
  53. 1 1
      p/themes/Flat/flat.rtl.css
  54. 1 1
      p/themes/Nord/nord.css
  55. 1 1
      p/themes/Nord/nord.rtl.css
  56. 1 1
      p/themes/Origine/origine.css
  57. 1 1
      p/themes/Origine/origine.rtl.css
  58. 0 2
      p/themes/Swage/swage.css
  59. 0 2
      p/themes/Swage/swage.rtl.css
  60. 9 4
      p/themes/base-theme/frss.css
  61. 9 4
      p/themes/base-theme/frss.rtl.css
  62. 1 0
      phpcs.xml

+ 11 - 8
CHANGELOG.md

@@ -4,15 +4,10 @@ See also [the FreshRSS releases](https://github.com/FreshRSS/FreshRSS/releases).
 
 ## 2026-XX-XX FreshRSS 1.29.2-dev
 
-* Features
-	* New option to hide badges showing number of unread articles (*Phantom Obligation*) [#8844](https://github.com/FreshRSS/FreshRSS/pull/8844)
-* Bug fixing
-	* Fix lost elements while parsing search query [#8884](https://github.com/FreshRSS/FreshRSS/pull/8884)
-* CLI
-	* New `cli/reconfigure-user.php` to read/write per-user config attributes [#8873](https://github.com/FreshRSS/FreshRSS/pull/8873)
-* API
-	* Add a warning message to the API password section and a log warning when a client uses GET instead of recommended POST [#8845](https://github.com/FreshRSS/FreshRSS/pull/8845)
 * Security
+	* 💥 Disallow access to local networks such as `127.0.0.1` by default, for security reasons (breaking change) [#8400](https://github.com/FreshRSS/FreshRSS/pull/8400)
+		* Selected local networks can be allowed under *System configuration* or using the `INTERNAL_HOST_ALLOWLIST` environment variable
+		* Passing `*` allows all networks like before (unsafe)
 	* Fix access control in `rss` and `opml` actions [#8912](https://github.com/FreshRSS/FreshRSS/pull/8912)
 	* Set limits for regex during search [#8913](https://github.com/FreshRSS/FreshRSS/pull/8913)
 * SimplePie
@@ -20,6 +15,14 @@ See also [the FreshRSS releases](https://github.com/FreshRSS/FreshRSS/releases).
 	* Fix wrong player parent logic leading to invalid type [#8893](https://github.com/FreshRSS/FreshRSS/pull/8893), [simplepie#978](https://github.com/simplepie/simplepie/pull/978)
 	* Consistently enable `XML_OPTION_PARSE_HUGE` [#8894](https://github.com/FreshRSS/FreshRSS/pull/8894), [simplepie#977](https://github.com/simplepie/simplepie/pull/977)
 	* Fix null warning in IRI for PHP 8.5+ [#8918](https://github.com/FreshRSS/FreshRSS/pull/8918), [simplepie#979](https://github.com/simplepie/simplepie/pull/979)
+* Features
+	* New option to hide badges showing number of unread articles (*Phantom Obligation*) [#8844](https://github.com/FreshRSS/FreshRSS/pull/8844)
+* Bug fixing
+	* Fix lost elements while parsing search query [#8884](https://github.com/FreshRSS/FreshRSS/pull/8884)
+* CLI
+	* New `cli/reconfigure-user.php` to read/write per-user config attributes [#8873](https://github.com/FreshRSS/FreshRSS/pull/8873)
+* API
+	* Add a warning message to the API password section and a log warning when a client uses GET instead of recommended POST [#8845](https://github.com/FreshRSS/FreshRSS/pull/8845)
 * Deployment
 	* Docker alternative image updated to Alpine 3.24 with PHP 8.5.7 and Apache 2.4.67 [#8916](https://github.com/FreshRSS/FreshRSS/pull/8916)
 	* Apache use only `CONN_REMOTE_ADDR` in logs when `mod_remoteip` is available, for compatibility with LiteSpeed Web Server [#8890](https://github.com/FreshRSS/FreshRSS/pull/8890)

+ 7 - 0
Docker/README.md

@@ -98,6 +98,7 @@ and with newer packages in general (Apache, PHP).
 * `COPY_LOG_TO_SYSLOG`: (default is `On`) Copy all the logs to syslog
 * `COPY_SYSLOG_TO_STDERR`: (default is `On`) Copy syslog to Standard Error so that it is visible in docker logs
 * `LISTEN`: (default is `80`) Modifies the internal Apache listening address and port, e.g. `0.0.0.0:8080` (for advanced users; useful for [Docker host networking](https://docs.docker.com/network/host/))
+* `INTERNAL_HOST_ALLOWLIST`: (default is empty, can also be set in `data/config.php` or under *System configuration* in Web UI) Requests to internal hosts such as 127.0.0.1 are blocked by default; here you can add overrides for which internal hosts to allow, separated by whitespace. Each host should be described either as a `host:port` combination, CIDR notation (`0.0.0.0/0` to allow any IPv4, `::/0` to allow any IPv6) or `*` to allow all hosts (unsafe)
 * `FRESHRSS_INSTALL`: automatically pass arguments to command line `cli/do-install.php` (for advanced users; see example in Docker Compose section). Only executed at the very first run (so far), so if you make any change, you need to delete your `freshrss` service, `freshrss_data` volume, before running again.
 * `FRESHRSS_USER`: automatically pass arguments to command line `cli/create-user.php` (for advanced users; see example in Docker Compose section). Only executed at the very first run (so far), so if you make any change, you need to delete your `freshrss` service, `freshrss_data` volume, before running again.
 
@@ -342,6 +343,12 @@ services:
       FRESHRSS_ENV: development
       # Optional advanced parameter controlling the internal Apache listening port
       LISTEN: 0.0.0.0:80
+      # Optional parameter to allow sending requests to certain internal hosts, by default all internal requests are blocked
+      # Examples: 127.0.0.1:8080, rss-bridge:80, etc.
+      #   or a CIDR notation: 0.0.0.0/0 (to allow any IPv4), ::/0 (to allow any IPv6)
+      # Setting * disables this check completely, allowing any host to be accessed (unsafe)
+      #INTERNAL_HOST_ALLOWLIST: rss-bridge:80 rsshub:1200
+
       # Optional parameter, remove for automatic settings, set to 0 to disable,
       # or (if you use a proxy) to a space-separated list of trusted IP ranges
       # compatible with https://httpd.apache.org/docs/current/mod/mod_remoteip.html#remoteipinternalproxy

+ 5 - 0
Docker/freshrss/docker-compose.yml

@@ -23,6 +23,11 @@ services:
       TZ: Europe/Paris
       CRON_MIN: '3,33'
       TRUSTED_PROXY: 172.16.0.1/12 192.168.0.1/16
+      # # Optional parameter to allow sending requests to certain internal hosts. By default all internal requests are blocked
+      # # Examples: 127.0.0.1:8080, rss-bridge:80, etc.
+      # #   or a CIDR notation: 0.0.0.0/0 (to allow any IPv4), ::/0 (to allow any IPv6)
+      # # Setting * disables this check completely, allowing any host to be accessed (unsafe)
+      # INTERNAL_HOST_ALLOWLIST: rss-bridge:80 rsshub:1200
     # # Optional healthcheck section:
     # healthcheck:
     #   test: ["CMD", "cli/health.php"]

+ 7 - 7
README.fr.md

@@ -227,19 +227,19 @@ Voir le [dépôt dédié à ces extensions](https://github.com/FreshRSS/Extensio
 
 | Langage | Progression | |
 | - | - | - |
-| Čeština (cs) | ■■■■■■■■・・ 82% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fcs+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Čeština (cs) | ■■■■■■■■・・ 81% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fcs+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Deutsch (de) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fde+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Ελληνικά (el) | ■■■・・・・・・・ 38% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fel+%2F%28TODO%7CDIRTY%29%24%2F) |
 | English (en) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fen+%2F%28TODO%7CDIRTY%29%24%2F) |
 | English (United States) (en-US) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fen-US+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Español (es) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fes+%2F%28TODO%7CDIRTY%29%24%2F) |
-| فارسی (fa) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffa+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Español (es) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fes+%2F%28TODO%7CDIRTY%29%24%2F) |
+| فارسی (fa) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffa+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Suomi (fi) | ■■■■■■■■■・ 92% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffi+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Français (fr) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffr+%2F%28TODO%7CDIRTY%29%24%2F) |
-| עברית (he) | ■■■■・・・・・・ 42% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhe+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Magyar (hu) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhu+%2F%28TODO%7CDIRTY%29%24%2F) |
+| עברית (he) | ■■■■・・・・・・ 41% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhe+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Magyar (hu) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhu+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Bahasa Indonesia (id) | ■■■■■■■■・・ 89% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fid+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Italiano (it) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fit+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Italiano (it) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fit+%2F%28TODO%7CDIRTY%29%24%2F) |
 | 日本語 (ja) | ■■■■■■■■・・ 88% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fja+%2F%28TODO%7CDIRTY%29%24%2F) |
 | 한국어 (ko) | ■■■■■■■■・・ 81% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fko+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Latviešu (lv) | ■■■■■■■■・・ 82% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Flv+%2F%28TODO%7CDIRTY%29%24%2F) |
@@ -253,7 +253,7 @@ Voir le [dépôt dédié à ces extensions](https://github.com/FreshRSS/Extensio
 | Türkçe (tr) | ■■■■■■■■・・ 89% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ftr+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Українська (uk) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fuk+%2F%28TODO%7CDIRTY%29%24%2F) |
 | 简体中文 (zh-CN) | ■■■■■■■■■・ 97% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fzh-CN+%2F%28TODO%7CDIRTY%29%24%2F) |
-| 正體中文 (zh-TW) | ■■■■■■■■■・ 96% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fzh-TW+%2F%28TODO%7CDIRTY%29%24%2F) |
+| 正體中文 (zh-TW) | ■■■■■■■■■・ 95% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fzh-TW+%2F%28TODO%7CDIRTY%29%24%2F) |
 
 </translations>
 

+ 7 - 7
README.md

@@ -123,19 +123,19 @@ See the [repository dedicated to those extensions](https://github.com/FreshRSS/E
 
 | Language | Progress | |
 | - | - | - |
-| Čeština (cs) | ■■■■■■■■・・ 82% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fcs+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Čeština (cs) | ■■■■■■■■・・ 81% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fcs+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Deutsch (de) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fde+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Ελληνικά (el) | ■■■・・・・・・・ 38% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fel+%2F%28TODO%7CDIRTY%29%24%2F) |
 | English (en) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fen+%2F%28TODO%7CDIRTY%29%24%2F) |
 | English (United States) (en-US) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fen-US+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Español (es) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fes+%2F%28TODO%7CDIRTY%29%24%2F) |
-| فارسی (fa) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffa+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Español (es) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fes+%2F%28TODO%7CDIRTY%29%24%2F) |
+| فارسی (fa) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffa+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Suomi (fi) | ■■■■■■■■■・ 92% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffi+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Français (fr) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffr+%2F%28TODO%7CDIRTY%29%24%2F) |
-| עברית (he) | ■■■■・・・・・・ 42% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhe+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Magyar (hu) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhu+%2F%28TODO%7CDIRTY%29%24%2F) |
+| עברית (he) | ■■■■・・・・・・ 41% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhe+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Magyar (hu) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhu+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Bahasa Indonesia (id) | ■■■■■■■■・・ 89% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fid+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Italiano (it) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fit+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Italiano (it) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fit+%2F%28TODO%7CDIRTY%29%24%2F) |
 | 日本語 (ja) | ■■■■■■■■・・ 88% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fja+%2F%28TODO%7CDIRTY%29%24%2F) |
 | 한국어 (ko) | ■■■■■■■■・・ 81% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fko+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Latviešu (lv) | ■■■■■■■■・・ 82% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Flv+%2F%28TODO%7CDIRTY%29%24%2F) |
@@ -149,7 +149,7 @@ See the [repository dedicated to those extensions](https://github.com/FreshRSS/E
 | Türkçe (tr) | ■■■■■■■■・・ 89% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ftr+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Українська (uk) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fuk+%2F%28TODO%7CDIRTY%29%24%2F) |
 | 简体中文 (zh-CN) | ■■■■■■■■■・ 97% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fzh-CN+%2F%28TODO%7CDIRTY%29%24%2F) |
-| 正體中文 (zh-TW) | ■■■■■■■■■・ 96% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fzh-TW+%2F%28TODO%7CDIRTY%29%24%2F) |
+| 正體中文 (zh-TW) | ■■■■■■■■■・ 95% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fzh-TW+%2F%28TODO%7CDIRTY%29%24%2F) |
 
 </translations>
 

+ 5 - 0
app/Controllers/configureController.php

@@ -656,6 +656,7 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 	 *   - user category limit (default: 16384)
 	 *   - user feed limit (default: 16384)
 	 *   - user login duration for form auth (default: FreshRSS_Auth::DEFAULT_COOKIE_DURATION)
+	 *   - internal host allowlist
 	 */
 	public function systemAction(): void {
 		if (!FreshRSS_Auth::hasAccess('admin')) {
@@ -671,6 +672,10 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 			FreshRSS_Context::systemConf()->limits = $limits;
 			FreshRSS_Context::systemConf()->title = Minz_Request::paramString('instance-name') ?: 'FreshRSS';
 			FreshRSS_Context::systemConf()->force_email_validation = Minz_Request::paramBoolean('force-email-validation');
+			$internal_host_allowlist = Minz_Request::paramTextToArrayNull('internal-host-allowlist');
+			if ($internal_host_allowlist !== null) {
+				FreshRSS_Context::systemConf()->internal_host_allowlist = Minz_Request::paramTextToArray('internal-host-allowlist');
+			}
 			FreshRSS_Context::systemConf()->closed_registration_message = Minz_Request::paramString('closed_registration_message') ?: '';
 			FreshRSS_Context::systemConf()->save();
 

+ 1 - 1
app/Controllers/feedController.php

@@ -199,7 +199,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 			}
 			if ($max_redirs !== 0) {
 				$opts[CURLOPT_MAXREDIRS] = $max_redirs;
-				$opts[CURLOPT_FOLLOWLOCATION] = 1;
+				$opts[CURLOPT_FOLLOWLOCATION] = true;
 			}
 			if ($useragent !== '') {
 				$opts[CURLOPT_USERAGENT] = $useragent;

+ 1 - 1
app/Controllers/subscriptionController.php

@@ -194,7 +194,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 			}
 			if ($max_redirs != 0) {
 				$opts[CURLOPT_MAXREDIRS] = $max_redirs;
-				$opts[CURLOPT_FOLLOWLOCATION] = 1;
+				$opts[CURLOPT_FOLLOWLOCATION] = true;
 			}
 			if ($useragent !== '') {
 				$opts[CURLOPT_USERAGENT] = $useragent;

+ 3 - 38
app/Models/Feed.php

@@ -1467,56 +1467,21 @@ class FreshRSS_Feed extends Minz_Model {
 				$hubJson['lease_end'] = time() - 60;
 				file_put_contents($hubFilename, json_encode($hubJson));
 			}
-			$ch = curl_init();
-			if ($ch === false) {
-				Minz_Log::warning('curl_init() failed in ' . __METHOD__);
-				return false;
-			}
-			curl_setopt_array($ch, [
-				CURLOPT_URL => $hubJson['hub'],
-				CURLOPT_RETURNTRANSFER => true,
+			$response = FreshRSS_http_Util::httpGet($hubJson['hub'], null, 'html', [], [
 				CURLOPT_POSTFIELDS => http_build_query([
 					'hub.verify' => 'sync',
 					'hub.mode' => $state ? 'subscribe' : 'unsubscribe',
 					'hub.topic' => $url,
 					'hub.callback' => $callbackUrl,
 				]),
-				CURLOPT_USERAGENT => FRESHRSS_USERAGENT,
 				CURLOPT_MAXREDIRS => 10,
-				CURLOPT_FOLLOWLOCATION => true,
-				CURLOPT_ACCEPT_ENCODING => '',	//Enable all encodings
-				//CURLOPT_VERBOSE => 1,	// To debug sent HTTP headers
 			]);
 
-			$curl_options = [];
-			if (defined('CURLOPT_PROTOCOLS_STR') && is_int(CURLOPT_PROTOCOLS_STR)) {
-				$curl_options[CURLOPT_PROTOCOLS_STR] = 'http,https';
-				if (defined('CURLOPT_REDIR_PROTOCOLS_STR') && is_int(CURLOPT_REDIR_PROTOCOLS_STR)) {
-					$curl_options[CURLOPT_REDIR_PROTOCOLS_STR] = 'http,https';
-				}
-			} elseif (defined('CURLPROTO_HTTP') && defined('CURLPROTO_HTTPS')) {
-				// Legacy PHP 8.2-
-				if (defined('CURLOPT_PROTOCOLS')) {
-					$curl_options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
-				}
-				if (defined('CURLOPT_REDIR_PROTOCOLS')) {
-					$curl_options[CURLOPT_REDIR_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
-				}
-			}
-			curl_setopt_array($ch, $curl_options);
-
-			$response = curl_exec($ch);
-			$info = curl_getinfo($ch);
-			if (!is_array($info)) {
-				Minz_Log::warning('curl_getinfo() failed in ' . __METHOD__);
-				return false;
-			}
-
 			Minz_Log::warning('WebSub ' . ($state ? 'subscribe' : 'unsubscribe') . ' to ' . $url .
 				' via hub ' . $hubJson['hub'] .
-				' with callback ' . $callbackUrl . ': ' . $info['http_code'] . ' ' . $response, PSHB_LOG);
+				' with callback ' . $callbackUrl . ': ' . $response['status'] . ' ' . $response['body'], PSHB_LOG);
 
-			if (str_starts_with('' . $info['http_code'], '2')) {
+			if (str_starts_with('' . $response['status'], '2')) {
 				return true;
 			} else {
 				$hubJson['lease_start'] = time();	//Prevent trying again too soon

+ 21 - 0
app/Models/SimplePieFetch.php

@@ -25,9 +25,30 @@ final class FreshRSS_SimplePieFetch extends \SimplePie\File
 			}
 		}
 
+		$redirects = $curl_options[CURLOPT_MAXREDIRS] ?? null;
+		if (!is_int($redirects)) {
+			$redirects = 4;
+		} elseif ($redirects < 0) {
+			$redirects = -1;	// infinite redirects
+		}
+		if (isset($curl_options[CURLOPT_FOLLOWLOCATION])) {
+			if ($curl_options[CURLOPT_FOLLOWLOCATION] == true) {
+				unset($curl_options[CURLOPT_FOLLOWLOCATION]);	// Favour the custom SimplePie redirects for security
+			} else {
+				$curl_options[CURLOPT_FOLLOWLOCATION] = false;
+				unset($curl_options[CURLOPT_MAXREDIRS]);
+				$redirects = 0;
+			}
+		}
+
 		parent::__construct($url, $timeout, $redirects, $headers, $useragent, $force_fsockopen, $curl_options);
 	}
 
+	#[\Override]
+	protected function get_curl_resolve_info(string $url): array|null|false {
+		return FreshRSS_http_Util::getCurlResolveInfo($url);
+	}
+
 	#[\Override]
 	protected function on_http_response($response, array $curl_options = []): void {
 		if (FreshRSS_Context::systemConf()->simplepie_syslog_enabled) {

+ 1 - 0
app/Models/SystemConfiguration.php

@@ -30,6 +30,7 @@ declare(strict_types=1);
  * @property-read bool $simplepie_syslog_enabled
  * @property-read bool $suppress_csp_warning
  * @property array<string> $trusted_sources
+ * @property array<string> $internal_host_allowlist
  * @property array<string,array<string,mixed>> $extensions
  */
 final class FreshRSS_SystemConfiguration extends Minz_Configuration {

+ 335 - 99
app/Utils/httpUtil.php

@@ -4,6 +4,22 @@ declare(strict_types=1);
 final class FreshRSS_http_Util {
 
 	private const RETRY_AFTER_PATH = DATA_PATH . '/Retry-After/';
+	private const PRIVATE_SUBNETS = [
+		'127.0.0.0/8',    // RFC1700 (Loopback)
+		'10.0.0.0/8',     // RFC1918
+		'192.168.0.0/16', // RFC1918
+		'172.16.0.0/12',  // RFC1918
+		'169.254.0.0/16', // RFC3927
+		'0.0.0.0/8',      // RFC5735
+		'240.0.0.0/4',    // RFC1112
+		'::1/128',        // Loopback
+		'fc00::/7',       // Unique Local Address
+		'fe80::/10',      // Link Local Address
+		'::ffff:0:0/96',  // IPv4 translations
+		'::/128',         // Unspecified address
+	];
+	/** @var array<string, string[]> $resolve_ok */
+	private static array $resolve_ok = [];
 
 	private static function getRetryAfterFile(string $url, string $proxy): string {
 		$domain = parse_url($url, PHP_URL_HOST);
@@ -95,7 +111,7 @@ final class FreshRSS_http_Util {
 		$safe_params = [
 			CURLOPT_COOKIE,
 			CURLOPT_COOKIEFILE,
-			CURLOPT_FOLLOWLOCATION,
+			CURLOPT_FOLLOWLOCATION,	// We filter this value later, only allowing `false`
 			CURLOPT_HTTPHEADER,
 			CURLOPT_MAXREDIRS,
 			CURLOPT_POST,
@@ -256,12 +272,145 @@ final class FreshRSS_http_Util {
 		return $html;
 	}
 
+	public static function compareURLOrigins(string $url1, string $url2): bool {
+		$url1 = parse_url(strtolower($url1));
+		$url2 = parse_url(strtolower($url2));
+		if ($url1 === false || $url2 === false) {
+			return false;
+		}
+		foreach ([&$url1, &$url2] as &$url) {
+			$url['port'] ??= match ($url['scheme']) {
+				'http' => 80,
+				'https' => 443,
+				default => 0,
+			};
+		}
+		return ($url1['scheme'] ?? '') === ($url2['scheme'] ?? '') &&
+			($url1['host'] ?? '') === ($url2['host'] ?? '') &&
+			($url1['port'] ?? '') === ($url2['port'] ?? '');
+	}
+
+	/**
+	 * Returns a value for CURLOPT_RESOLVE as an array, null if no allowed IPs were found, false if the domain failed to resolve.
+	 *
+	 * @return array<string>|null|false
+	 */
+	public static function getCurlResolveInfo(string $url): array|null|false {
+		$url = strtolower($url);
+		$parsed = parse_url($url);
+		if ($parsed === false) {
+			return false;
+		}
+		$host = $parsed['host'] ?? null;
+		$scheme = $parsed['scheme'] ?? null;
+		if ($host === null || $scheme === null) {
+			return false;
+		}
+		if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
+			if (strlen($host) === 2) {
+				return false;
+			}
+			$host = substr($host, 1, strlen($host) - 2);
+		}
+
+		$internal_host_allowlist = getenv('INTERNAL_HOST_ALLOWLIST');
+		if (is_string($internal_host_allowlist) && $internal_host_allowlist !== '') {
+			$internal_host_allowlist = preg_split('/\s+/', $internal_host_allowlist, -1, PREG_SPLIT_NO_EMPTY);
+		}
+		if (!is_array($internal_host_allowlist) || empty($internal_host_allowlist)) {
+			$internal_host_allowlist = FreshRSS_Context::systemConf()->internal_host_allowlist;
+		}
+
+		if (in_array('*', $internal_host_allowlist, true)) {
+			return [];	// Disables SSRF checks entirely (unsafe)
+		}
+
+		$port = parse_url($url)['port'] ?? match ($scheme) {
+			'http' => 80,
+			'https' => 443,
+			default => 0,
+		};
+		$resolve_str = "$host:$port:";
+		$ips_ok = [];
+		$ips = [];
+		$records = [];
+		if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
+			$ips[] = $host;
+		} elseif (isset(self::$resolve_ok[$host])) {
+			$ips = self::$resolve_ok[$host];
+		} else {
+			$records = @dns_get_record($host, DNS_A + DNS_AAAA);
+			if ($records === false) {
+				return false;
+			}
+			foreach ($records as $record) {
+				$ip = $record['ip'] ?? $record['ipv6'];
+				if (is_string($ip)) {
+					$ips[] = $ip;
+				}
+			}
+			self::$resolve_ok[$host] = $ips;
+		}
+
+		$cidr_allowlist = array_filter($internal_host_allowlist, fn($v, $_) => str_contains($v, '/'), ARRAY_FILTER_USE_BOTH);
+		foreach ($ips as $ip) {
+			$allowlist_str = "$ip:$port";
+			$add_ip = $ip;
+			if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
+				$allowlist_str = "[$ip]:$port";
+				$add_ip = "[$ip]";
+			}
+			foreach ($cidr_allowlist as $cidr) {
+				if (self::checkCIDR($ip, $cidr)) {
+					$ips_ok[] = $add_ip;
+					continue 2;
+				}
+			}
+			if (in_array($allowlist_str, $internal_host_allowlist, true) ||
+				in_array("$host:$port", $internal_host_allowlist, true)) {
+				$ips_ok[] = $add_ip;
+				continue;
+			}
+
+			if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
+				continue;
+			}
+			// Extra check because the above one might not be enough: https://github.com/php/php-src/issues/16944
+			// Workaround is available by using `FILTER_FLAG_GLOBAL_RANGE` instead, but that was only added in PHP 8.2, and we need to support PHP 8.1+
+			foreach (self::PRIVATE_SUBNETS as $cidr) {
+				if (self::checkCIDR($ip, $cidr)) {
+					continue 2;
+				}
+			}
+
+			$ips_ok[] = $add_ip;
+		}
+
+		if (count($ips_ok) > 0) {
+			if (count($records) > 0 || isset(self::$resolve_ok[$host])) {
+				$resolve_str .= implode(',', $ips_ok);
+				return [$resolve_str];
+			}
+			if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
+				// No resolve overrides since the URL only contained an IP, not a domain
+				return [];
+			}
+		}
+
+		if (count($ips) === 0) {
+			return false;
+		}
+
+		return null;
+	}
+
+
 	/**
 	 * @param non-empty-string $url
 	 * @param string|null $cachePath path to cache file, or `null` to disable caching
 	 * @param string $type {html,ico,json,opml,xml}
-	 * @param array<string,mixed> $attributes
-	 * @param array<int,mixed> $curl_options
+	 * @param array<string,mixed> $attributes May contain user-defined cURL options in `$attributes['curl_params']`
+	 * @param array<int,mixed> $curl_options Internal overrides of cURL options
 	 * @return array{body:string,effective_url:string,redirect_count:int,fail:bool,status:int,error:string}
 	 *   `status` is the HTTP response code (e.g. 200, 404), or a custom negative value:
 	 *   * `-200` served from local cache;
@@ -287,12 +436,12 @@ final class FreshRSS_http_Util {
 			cleanCache(CLEANCACHE_HOURS);
 		}
 
-		$options = [];
 		$accept = '';
 		$proxy = is_string(FreshRSS_Context::systemConf()->curl_options[CURLOPT_PROXY] ?? null) ? FreshRSS_Context::systemConf()->curl_options[CURLOPT_PROXY] : '';
+		$options = [];	// User-defined cURL options
 		if (is_array($attributes['curl_params'] ?? null)) {
 			$options = self::sanitizeCurlParams($attributes['curl_params']);
-			$proxy = is_string($options[CURLOPT_PROXY] ?? null) ? $options[CURLOPT_PROXY] : '';
+			$proxy = is_string($options[CURLOPT_PROXY] ?? null) ? $options[CURLOPT_PROXY] : $proxy;
 			if (is_array($options[CURLOPT_HTTPHEADER] ?? null)) {
 				// Remove headers problematic for security
 				$options[CURLOPT_HTTPHEADER] = array_filter($options[CURLOPT_HTTPHEADER],
@@ -303,6 +452,7 @@ final class FreshRSS_http_Util {
 				}
 			}
 		}
+		$proxy = is_string($curl_options[CURLOPT_PROXY] ?? null) ? $curl_options[CURLOPT_PROXY] : $proxy;
 
 		if (($retryAfter = FreshRSS_http_Util::getRetryAfter($url, $proxy)) > 0) {
 			Minz_Log::warning('For that domain, will first retry after ' . date('c', $retryAfter) . '. ' . \SimplePie\Misc::url_remove_credentials($url));
@@ -332,115 +482,185 @@ final class FreshRSS_http_Util {
 				break;
 		}
 
-		// TODO: Implement HTTP 1.1 conditional GET If-Modified-Since
-		$ch = curl_init();
-		if ($ch === false) {
-			return ['body' => '', 'effective_url' => '', 'redirect_count' => 0, 'fail' => true, 'status' => -500, 'error' => ''];
-		}
-		curl_setopt_array($ch, [
-			CURLOPT_URL => $url,
-			CURLOPT_HTTPHEADER => ['Accept: ' . $accept],
-			CURLOPT_USERAGENT => FRESHRSS_USERAGENT,
-			CURLOPT_CONNECTTIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'],
-			CURLOPT_TIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'],
-			CURLOPT_MAXREDIRS => 4,
-			CURLOPT_RETURNTRANSFER => true,
-			CURLOPT_FOLLOWLOCATION => true,
-			CURLOPT_ACCEPT_ENCODING => '',	//Enable all encodings
-			//CURLOPT_VERBOSE => 1,	// To debug sent HTTP headers
-		]);
-
-		curl_setopt_array($ch, $options);
-		curl_setopt_array($ch, FreshRSS_Context::systemConf()->curl_options);
-
-		$responseHeaders = '';
-		curl_setopt($ch, CURLOPT_HEADERFUNCTION, function (\CurlHandle $ch, string $header) use (&$responseHeaders) {
-			$responseHeaders .= $header;
-			return strlen($header);
-		});
-
-		if (isset($attributes['ssl_verify'])) {
-			curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, empty($attributes['ssl_verify']) ? 0 : 2);
-			curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, (bool)$attributes['ssl_verify']);
-			if (empty($attributes['ssl_verify'])) {
-				curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, 'DEFAULT@SECLEVEL=1');
-			}
-		}
-
-		if (defined('CURLOPT_PROTOCOLS_STR') && is_int(CURLOPT_PROTOCOLS_STR)) {
-			$curl_options[CURLOPT_PROTOCOLS_STR] = 'http,https';
-			if (defined('CURLOPT_REDIR_PROTOCOLS_STR') && is_int(CURLOPT_REDIR_PROTOCOLS_STR)) {
-				$curl_options[CURLOPT_REDIR_PROTOCOLS_STR] = 'http,https';
+		$original_url = $url;
+		$fail = false;
+		$redirs = 0;
+		$max_redirs = $curl_options[CURLOPT_MAXREDIRS] ?? $options[CURLOPT_MAXREDIRS] ?? FreshRSS_Context::systemConf()->curl_options[CURLOPT_MAXREDIRS] ?? null;
+		if (!is_int($max_redirs)) {
+			$max_redirs = 4;
+		}
+		while (true) {
+			$url = is_string($url) ? $url : '';
+			$resolve = [];
+			if ($proxy === '') {
+				$resolve = self::getCurlResolveInfo($url);
+				if ($resolve === null) {
+					Minz_Log::warning('Fetching this URL is not allowed, because the host’s IP is not in the allowlist [' .
+						\SimplePie\Misc::url_remove_credentials($url) . ']');
+					return ['body' => '', 'effective_url' => '', 'redirect_count' => 0, 'fail' => true, 'status' => -500, 'error' => ''];
+				} elseif ($resolve === false) {
+					return ['body' => '', 'effective_url' => '', 'redirect_count' => 0, 'fail' => true, 'status' => -500, 'error' => ''];
+				}
+				if (!empty($resolve)) {
+					$curl_options[CURLOPT_RESOLVE] = $resolve;	// Prevent DNS rebinding
+				}
 			}
-		} elseif (defined('CURLPROTO_HTTP') && defined('CURLPROTO_HTTPS')) {
-			// Legacy PHP 8.2-
-			if (defined('CURLOPT_PROTOCOLS')) {
-				$curl_options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
+			// TODO: Implement HTTP 1.1 conditional GET If-Modified-Since
+			$ch = curl_init();
+			if ($ch === false || $url === '') {
+				return ['body' => '', 'effective_url' => '', 'redirect_count' => 0, 'fail' => true, 'status' => -500, 'error' => ''];
 			}
-			if (defined('CURLOPT_REDIR_PROTOCOLS')) {
-				$curl_options[CURLOPT_REDIR_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
+			curl_setopt_array($ch, [
+				CURLOPT_URL => $url,
+				CURLOPT_HTTPHEADER => ['Accept: ' . $accept],
+				CURLOPT_USERAGENT => FRESHRSS_USERAGENT,
+				CURLOPT_CONNECTTIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'],
+				CURLOPT_TIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'],
+				CURLOPT_RETURNTRANSFER => true,
+				CURLOPT_ACCEPT_ENCODING => '',	//Enable all encodings
+				//CURLOPT_VERBOSE => 1,	// To debug sent HTTP headers
+			]);
+
+			curl_setopt_array($ch, $options);
+			curl_setopt_array($ch, FreshRSS_Context::systemConf()->curl_options);
+
+			$responseHeaders = '';
+			curl_setopt($ch, CURLOPT_HEADERFUNCTION, function (\CurlHandle $ch, string $header) use (&$responseHeaders) {
+				if (trim($header) !== '') {	// Skip e.g. separation with trailer headers
+					$responseHeaders .= $header;
+				}
+				return strlen($header);
+			});
+
+			if (isset($attributes['ssl_verify'])) {
+				curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, empty($attributes['ssl_verify']) ? 0 : 2);
+				curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, (bool)$attributes['ssl_verify']);
+				if (empty($attributes['ssl_verify'])) {
+					curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, 'DEFAULT@SECLEVEL=1');
+				}
 			}
-		}
-
-		curl_setopt_array($ch, $curl_options);
 
-		$body = curl_exec($ch);
-		$c_status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
-		$c_content_type = '' . curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
-		$c_effective_url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
-		$c_redirect_count = curl_getinfo($ch, CURLINFO_REDIRECT_COUNT);
-		$c_error = curl_error($ch);
+			if (defined('CURLOPT_PROTOCOLS_STR') && is_int(CURLOPT_PROTOCOLS_STR)) {
+				$curl_options[CURLOPT_PROTOCOLS_STR] = 'http,https';
+				if (defined('CURLOPT_REDIR_PROTOCOLS_STR') && is_int(CURLOPT_REDIR_PROTOCOLS_STR)) {
+					$curl_options[CURLOPT_REDIR_PROTOCOLS_STR] = 'http,https';
+				}
+			} elseif (defined('CURLPROTO_HTTP') && defined('CURLPROTO_HTTPS')) {
+				// Legacy PHP 8.2-
+				if (defined('CURLOPT_PROTOCOLS')) {
+					$curl_options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
+				}
+				if (defined('CURLOPT_REDIR_PROTOCOLS')) {
+					$curl_options[CURLOPT_REDIR_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
+				}
+			}
 
-		$headers = [];
-		if ($body !== false) {
-			assert($c_redirect_count >= 0);
-			$responseHeaders = \SimplePie\HTTP\Parser::prepareHeaders($responseHeaders, $c_redirect_count + 1);
-			$parser = new \SimplePie\HTTP\Parser($responseHeaders);
-			if ($parser->parse()) {
-				$headers = $parser->headers;
+			curl_setopt_array($ch, $curl_options);
+			curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);	// We handle HTTP redirections manually for security
+
+			$body = curl_exec($ch);
+			$c_status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+			$c_content_type = '' . curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
+			$c_effective_url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
+			$c_error = curl_error($ch);
+
+			$headers = [];
+			if ($body !== false) {
+				$responseHeaders .= "\r\n";
+				$responseHeaders = \SimplePie\HTTP\Parser::prepareHeaders($responseHeaders);
+				$parser = new \SimplePie\HTTP\Parser($responseHeaders);
+				if ($parser->parse()) {
+					$headers = $parser->headers;
+				}
 			}
-		}
 
-		$fail = $c_status != 200 || $c_error != '' || $body === false;
-		if ($fail) {
-			$body = '';
-			Minz_Log::warning('Error fetching content: HTTP code ' . $c_status . ': ' . $c_error . ' ' . $url);
-			if (in_array($c_status, [429, 503], true)) {
-				$retryAfter = FreshRSS_http_Util::setRetryAfter($url, $proxy, $headers['retry-after'] ?? '');
-				if ($c_status === 429) {
-					$errorMessage = 'HTTP 429 Too Many Requests! [' . \SimplePie\Misc::url_remove_credentials($url) . ']';
-				} elseif ($c_status === 503) {
-					$errorMessage = 'HTTP 503 Service Unavailable! [' . \SimplePie\Misc::url_remove_credentials($url) . ']';
+			if (in_array($c_status, [301, 302, 303, 307, 308], true)) {
+				// Handle the redirect by making another request
+				$location = \SimplePie\Misc::absolutize_url($headers['location'] ?? $url, $url);
+				if ($location === false) {
+					$location = $url;
 				}
-				if ($retryAfter > 0) {
-					$errorMessage .= ' We may retry after ' . date('c', $retryAfter);
+				if (!self::compareURLOrigins($url, $location)) {
+					unset($curl_options[CURLOPT_COOKIE]);
+					unset($curl_options[CURLOPT_USERPWD]);
+					unset($options[CURLOPT_COOKIE]);
+					unset($options[CURLOPT_USERPWD]);
+					if (is_array($options[CURLOPT_HTTPHEADER] ?? null)) {
+						$options[CURLOPT_HTTPHEADER] = array_filter($options[CURLOPT_HTTPHEADER], fn(mixed $header): bool =>
+							is_string($header) && !preg_match('/^(Cookie|Authorization)\\s*:/i', $header));
+					}
+					if (is_array($curl_options[CURLOPT_HTTPHEADER] ?? null)) {
+						$curl_options[CURLOPT_HTTPHEADER] = array_filter($curl_options[CURLOPT_HTTPHEADER], fn(mixed $header): bool =>
+							is_string($header) && !preg_match('/^(Cookie|Authorization)\\s*:/i', $header));
+					}
 				}
+				if ($max_redirs >= 0) {
+					$redirs++;
+				}
+				if ($redirs > $max_redirs) {
+					Minz_Log::warning('Error fetching content: Too many redirects were hit [' . \SimplePie\Misc::url_remove_credentials($original_url) . ']');
+					break;
+				}
+				if ((isset($options[CURLOPT_POST]) || isset($curl_options[CURLOPT_POST])) &&
+					in_array($c_status, [301, 302, 303], true)) {	// Not for 307 and 308, which must not change the HTTP method
+					unset($curl_options[CURLOPT_POST]);
+					unset($curl_options[CURLOPT_POSTFIELDS]);
+					unset($options[CURLOPT_POST]);
+					unset($options[CURLOPT_POSTFIELDS]);
+					if (is_array($options[CURLOPT_HTTPHEADER] ?? null)) {
+						$options[CURLOPT_HTTPHEADER] = array_filter($options[CURLOPT_HTTPHEADER], fn(mixed $header): bool =>
+							is_string($header) && !str_starts_with(strtolower(trim($header)), 'content-type:'));
+					}
+					if (is_array($curl_options[CURLOPT_HTTPHEADER] ?? null)) {
+						$curl_options[CURLOPT_HTTPHEADER] = array_filter($curl_options[CURLOPT_HTTPHEADER], fn(mixed $header): bool =>
+							is_string($header) && !str_starts_with(strtolower(trim($header)), 'content-type:'));
+					}
+				}
+				$url = $location;
+				continue;
 			}
-			// TODO: Implement HTTP 410 Gone
-		} elseif (!is_string($body) || strlen($body) === 0) {
-			$body = '';
-		} else {
-			if (in_array($type, ['html', 'json', 'opml', 'xml'], true)) {
-				$body = trim($body, " \n\r\t\v");	// Do not trim \x00 to avoid breaking a BOM
-			}
-			if (in_array($type, ['html', 'xml', 'opml'], true)) {
-				$body = self::enforceHttpEncoding($body, $c_content_type);
-			}
-			if (in_array($type, ['html'], true)) {
-				if (stripos($c_content_type, 'text/plain') !== false) {
-					// Plain text to be displayed as preformatted text. Prefixed with UTF-8 BOM
-					$body = "\xEF\xBB\xBF" . '<pre class="text-plain">' . htmlspecialchars($body, ENT_NOQUOTES, 'UTF-8') . '</pre>';
-				} else {
-					$body = self::enforceHtmlBase($body, $c_effective_url);
+
+			$fail = $c_status != 200 || $c_error != '' || $body === false;
+			if ($fail) {
+				$body = '';
+				Minz_Log::warning('Error fetching content: HTTP code ' . $c_status . ': ' . $c_error . ' ' . $url);
+				if (in_array($c_status, [429, 503], true)) {
+					$retryAfter = FreshRSS_http_Util::setRetryAfter($url, $proxy, $headers['retry-after'] ?? '');
+					if ($c_status === 429) {
+						$errorMessage = 'HTTP 429 Too Many Requests! [' . \SimplePie\Misc::url_remove_credentials($url) . ']';
+					} elseif ($c_status === 503) {
+						$errorMessage = 'HTTP 503 Service Unavailable! [' . \SimplePie\Misc::url_remove_credentials($url) . ']';
+					}
+					if ($retryAfter > 0) {
+						$errorMessage .= ' We may retry after ' . date('c', $retryAfter);
+					}
+				}
+			} elseif (!is_string($body) || strlen($body) === 0) { // TODO: Implement HTTP 410 Gone
+				$body = '';
+			} else {
+				if (in_array($type, ['html', 'json', 'opml', 'xml'], true)) {
+					$body = trim($body, " \n\r\t\v");	// Do not trim \x00 to avoid breaking a BOM
+				}
+				if (in_array($type, ['html', 'xml', 'opml'], true)) {
+					$body = self::enforceHttpEncoding($body, $c_content_type);
+				}
+				if (in_array($type, ['html'], true)) {
+					if (stripos($c_content_type, 'text/plain') !== false) {
+						// Plain text to be displayed as preformatted text. Prefixed with UTF-8 BOM
+						$body = "\xEF\xBB\xBF" . '<pre class="text-plain">' . htmlspecialchars($body, ENT_NOQUOTES, 'UTF-8') . '</pre>';
+					} else {
+						$body = self::enforceHtmlBase($body, $c_effective_url);
+					}
 				}
 			}
+			break;
 		}
 
 		if ($cachePath !== null && file_put_contents($cachePath, $body) === false) {
 			Minz_Log::warning("Error saving cache $cachePath for $url");
 		}
 
-		return ['body' => $body, 'effective_url' => $c_effective_url, 'redirect_count' => $c_redirect_count,
+		return ['body' => is_string($body) ? $body : '', 'effective_url' => $c_effective_url, 'redirect_count' => $redirs,
 			'fail' => $fail, 'status' => $c_status, 'error' => $c_error];
 	}
 
@@ -467,6 +687,9 @@ final class FreshRSS_http_Util {
 	 */
 	private static function checkCIDR(string $ip, string $range): bool {
 		$binary_ip = self::ipToBits($ip);
+		if ($binary_ip === '') {
+			return false;
+		}
 		$split = explode('/', $range);
 
 		$subnet = $split[0] ?? '';
@@ -474,11 +697,24 @@ final class FreshRSS_http_Util {
 			return false;
 		}
 		$binary_subnet = self::ipToBits($subnet);
+		if ($binary_subnet === '') {
+			return false;
+		}
+		if (strlen($binary_ip) !== strlen($binary_subnet)) {
+			return false;	// Do not mix IPv4 and IPv6
+		}
 
-		$mask_bits = $split[1] ?? '';
-		$mask_bits = (int)$mask_bits;
+		$mask_bits_str = $split[1] ?? '';
+		if (!ctype_digit($mask_bits_str)) {
+			return false;
+		}
+		$mask_bits = (int)$mask_bits_str;
+		$max_mask_bits = str_contains($ip, ':') ? 128 : 32;
+		if ($mask_bits < 0 || $mask_bits > $max_mask_bits) {
+			return false;	// Reject invalid mask bits lengths
+		}
 		if ($mask_bits === 0) {
-			$mask_bits = null;
+			return true;
 		}
 
 		$ip_net_bits = substr($binary_ip, 0, $mask_bits);

+ 5 - 0
app/i18n/cs/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Vynutit ověření e-mailové adresy',
 		'instance-name' => 'Název instance',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Maximální počet kategorií na uživatele',
 		'max-feeds' => 'Maximální počet kanálů na uživatele',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Maximální počet účtů',
 			'select' => array(

+ 5 - 0
app/i18n/de/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'Dieser Server akzeptiert momentan keine neuen Registrierungen.',
 		'force_email_validation' => 'E-Mail-Adressprüfung erzwingen',
 		'instance-name' => 'Bezeichnung',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Anzahl erlaubter Kategorien pro Benutzer',
 		'max-feeds' => 'Anzahl erlaubter Feeds pro Benutzer',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Maximale Anzahl von Accounts',
 			'select' => array(

+ 5 - 0
app/i18n/el/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Επιβολή επιβεβαίωσης διεύθυνσης email',
 		'instance-name' => 'Instance name',	// TODO
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Μέγιστος αριθμός κατηγοριών ανά χρήστη',
 		'max-feeds' => 'Μέγιστος αριθμός τροφοδοσιών ανά χρήστη',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Μέγιστος αριθμός λογαριασμών',
 			'select' => array(

+ 5 - 0
app/i18n/en-US/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// IGNORE
 		'force_email_validation' => 'Force email address validation',	// IGNORE
 		'instance-name' => 'Instance name',	// IGNORE
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// IGNORE
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// IGNORE
+		),
 		'max-categories' => 'Max number of categories per user',	// IGNORE
 		'max-feeds' => 'Max number of feeds per user',	// IGNORE
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// IGNORE
 		'registration' => array(
 			'number' => 'Max number of accounts',	// IGNORE
 			'select' => array(

+ 5 - 0
app/i18n/en/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',
 		'force_email_validation' => 'Force email address validation',
 		'instance-name' => 'Instance name',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',
+		),
 		'max-categories' => 'Max number of categories per user',
 		'max-feeds' => 'Max number of feeds per user',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',
 		'registration' => array(
 			'number' => 'Max number of accounts',
 			'select' => array(

+ 5 - 0
app/i18n/es/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'Este servidor no acepta nuevos registros en este momento.',
 		'force_email_validation' => 'Forzar la validación de direcciones de correo electrónico',
 		'instance-name' => 'Nombre de la fuente',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Límite de categorías por usuario',
 		'max-feeds' => 'Límite de fuentes por usuario',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Número máximo de cuentas',
 			'select' => array(

+ 5 - 0
app/i18n/fa/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'این سرور در حال حاضر ثبت‌نام جدید نمی‌پذیرد.',
 		'force_email_validation' => ' اعتبارسنجی آدرس ایمیل اجباری',
 		'instance-name' => ' نام نمونه',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => ' حداکثر تعداد دسته ها برای هر کاربر',
 		'max-feeds' => ' حداکثر تعداد فید برای هر کاربر',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => ' حداکثر تعداد حساب ها',
 			'select' => array(

+ 5 - 0
app/i18n/fi/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Pakota sähköpostiosoitteen vahvistus',
 		'instance-name' => 'Instanssin nimi',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Luokkien enimmäismäärä käyttäjää kohti',
 		'max-feeds' => 'Syötteiden enimmäismäärä käyttäjää kohti',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Tilien enimmäismäärä',
 			'select' => array(

+ 5 - 0
app/i18n/fr/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'Ce serveur n’accepte plus de nouvelles inscriptions pour le moment.',
 		'force_email_validation' => 'Forcer la validation des adresses email',
 		'instance-name' => 'Nom de l’instance',
+		'internal-host-allowlist' => array(
+			'_' => 'Liste d’adresses internes autorisées',
+			'help' => 'Une entrée par ligne :<ul><li>Un <code>host:port</code>. Par exemple <code>127.0.0.1:8080</code> ou <code>rss-bridge:80</code></li><li>Une notation CIDR. Par exemple <code>0.0.0.0/0</code> pour autoriser tout IPv4, <code>::/0</code> pour autoriser tout IPv6</li><li>Un <code>*</code> pour autoriser tout hôte (dangereux)</li></ul>',
+		),
 		'max-categories' => 'Limite de catégories par utilisateur',
 		'max-feeds' => 'Limite de flux par utilisateur',
+		'override-by-env-var' => 'Cette option est définie par la variable d’environnement <kbd>%s</kbd>.',
 		'registration' => array(
 			'number' => 'Nombre max de comptes',
 			'select' => array(

+ 5 - 0
app/i18n/he/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Force email address validation',	// TODO
 		'instance-name' => 'Instance name',	// TODO
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Max number of categories per user',	// TODO
 		'max-feeds' => 'Max number of feeds per user',	// TODO
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Max number of accounts',	// TODO
 			'select' => array(

+ 5 - 0
app/i18n/hu/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'Ez a szerver jelenleg nem fogad új regisztrációkat.',
 		'force_email_validation' => 'Kötelező email cím visszaigazolás',
 		'instance-name' => 'Instance név',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Maximális kategóriák száma felhasználónkét',
 		'max-feeds' => 'Maximális hírforrások száma felhasználónként',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Max felhasználó szám',
 			'select' => array(

+ 5 - 0
app/i18n/id/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Paksa verifikasi alamat surel',
 		'instance-name' => 'Nama peladen',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Jumlah kategori maksimal per pengguna',
 		'max-feeds' => 'Jumlah umpan maksimal per pengguna',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Jumlah akun maksimal',
 			'select' => array(

+ 5 - 0
app/i18n/it/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'Questo server non accetta nuove registrazioni per il momento.',
 		'force_email_validation' => 'Forza la validazione dell’indirizzo mail',
 		'instance-name' => 'Nome istanza',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Limite categorie per utente',
 		'max-feeds' => 'Limite feeds per utente',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Numero massimo di profili',
 			'select' => array(

+ 5 - 0
app/i18n/ja/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Eメールアドレスの検証を強制します',
 		'instance-name' => 'インスタンス名',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => '1ユーザーごとのカテゴリの最大値',
 		'max-feeds' => '1ユーザーごとのフィードの最大値',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'アカウントの最大値',
 			'select' => array(

+ 5 - 0
app/i18n/ko/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => '이메일 주소 확인 강제화',
 		'instance-name' => '인스턴스 이름',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => '사용자별 카테고리 개수 제한',
 		'max-feeds' => '사용자별 피드 개수 제한',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => '계정 최대 개수',
 			'select' => array(

+ 5 - 0
app/i18n/lv/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Piespiedu e-pasta adreses validēšana',
 		'instance-name' => 'Instances nosaukums',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Maksimālais kategoriju skaits vienam lietotājam',
 		'max-feeds' => 'Maksimālais barotņu skaits vienam lietotājam',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Maksimālais kontu skaits',
 			'select' => array(

+ 5 - 0
app/i18n/nl/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'Deze server accepteert momenteel geen nieuwe registraties.',
 		'force_email_validation' => 'Emailadresvalidatie forceren',
 		'instance-name' => 'Voorbeeld naam',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Categorielimiet per gebruiker',
 		'max-feeds' => 'Feedlimiet per gebruiker',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Maximum aantal accounts',
 			'select' => array(

+ 5 - 0
app/i18n/oc/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Forçar la validacion de las adreças electronicas',
 		'instance-name' => 'Nom de l’instància',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Limita de categoria per utilizaire',
 		'max-feeds' => 'Limita de fluxes per utilizaire',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Nombre max de comptes',
 			'select' => array(

+ 5 - 0
app/i18n/pl/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'Ten serwer obecnie nie przyjmuje nowych rejestracji.',
 		'force_email_validation' => 'Wymuś weryfikację adresu e-mail',
 		'instance-name' => 'Nazwa instancji',
+		'internal-host-allowlist' => array(
+			'_' => 'Lista dozwolonych hostów wewnętrznych',
+			'help' => 'Jeden wpis na linię:<ul><li>Kombinacja <code>host:port</code>. Na przykład <code>127.0.0.1:8080</code> lub <code>rss-bridge:80</code></li><li>Notacja CIDR. Na przykład <code>0.0.0.0/0</code>, by zezwolić na dowolny IPv4, <code>::/0</code>, by zezwolić na dowolny IPv6</li><li><code>*</code>, by zezwolić na dowolny host (niebezpieczne)</li></ul>',
+		),
 		'max-categories' => 'Maksymalna liczba kategorii na użytkownika',
 		'max-feeds' => 'Maksymalna liczba kanałów na użytkownika',
+		'override-by-env-var' => 'To ustawienie jest ustawione przez zmienną środowiskową <kbd>%s</kbd>.',
 		'registration' => array(
 			'number' => 'Maksymalna liczba kont',
 			'select' => array(

+ 5 - 0
app/i18n/pt-BR/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'Este servidor não aceita novas inscrições no momento.',
 		'force_email_validation' => 'Força verificação do endereço de email',
 		'instance-name' => 'Nome da instância',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Limite de categorias por usuário',
 		'max-feeds' => 'Limite de Feeds por usuário',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Máximo número de contas',
 			'select' => array(

+ 5 - 0
app/i18n/pt-PT/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Força verificação do endereço de email',
 		'instance-name' => 'Nome da instância',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Limite de categorias por utilizador',
 		'max-feeds' => 'Limite de Feeds por utilizador',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Máximo número de contas',
 			'select' => array(

+ 5 - 0
app/i18n/ru/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'На этом сервере в данный момент регистрация новых пользователей закрыта.',
 		'force_email_validation' => 'Обязать подтверждать адрес электронной почты',
 		'instance-name' => 'Название экземпляра',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Максимальное количество категорий на пользователя',
 		'max-feeds' => 'Максимальное количество лент на пользователя',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Максимальное количество аккаунтов',
 			'select' => array(

+ 5 - 0
app/i18n/sk/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Vynútiť overenie e-mailovej adresy',
 		'instance-name' => 'Názov inštancie',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Limit počtu kategórií pre používateľa',
 		'max-feeds' => 'Limit počtu kanálov pre používateľov',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Maximálny počt účtov',
 			'select' => array(

+ 5 - 0
app/i18n/tr/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'E-posta adresi doğrulamasını zorunlu kıl',
 		'instance-name' => 'Örnek adı',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Kullanıcı başına maksimum kategori sayısı',
 		'max-feeds' => 'Kullanıcı başına maksimum besleme sayısı',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Maksimum hesap sayısı',
 			'select' => array(

+ 5 - 0
app/i18n/uk/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'Наразі сервер не приймає нових реєстрацій.',
 		'force_email_validation' => 'Підтверджувати адресу електронної пошти',
 		'instance-name' => 'Назва сервера',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => 'Максимум категорій у користувача',
 		'max-feeds' => 'Максимум стрічок у користувача',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => 'Максимум облікових записів',
 			'select' => array(

+ 5 - 0
app/i18n/zh-CN/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => '强制验证邮箱地址',
 		'instance-name' => '实例名称',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => '各用户分类数限制',
 		'max-feeds' => '各用户订阅源数限制',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => '最大用户数',
 			'select' => array(

+ 5 - 0
app/i18n/zh-TW/admin.php

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => '目前此伺服器不接受新的註冊。',
 		'force_email_validation' => '強制驗證電子郵件位址',
 		'instance-name' => '實例名稱',
+		'internal-host-allowlist' => array(
+			'_' => 'Internal host allowlist',	// TODO
+			'help' => 'One entry per line:<ul><li>A <code>host:port</code>. For instance <code>127.0.0.1:8080</code> or <code>rss-bridge:80</code></li><li>A CIDR notation. For instance <code>0.0.0.0/0</code> to allow any IPv4, <code>::/0</code> to allow any IPv6</li><li>A <code>*</code> to allow any host (unsafe)</li></ul>',	// TODO
+		),
 		'max-categories' => '每個使用者的最大類別數',
 		'max-feeds' => '每個使用者的最大訂閱源數',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 			'number' => '最大帳號數',
 			'select' => array(

+ 26 - 0
app/views/configure/system.phtml

@@ -64,6 +64,32 @@
 			</div>
 		</div>
 
+		<?php
+			$internal_host_allowlist = getenv('INTERNAL_HOST_ALLOWLIST');
+			if (is_string($internal_host_allowlist) && $internal_host_allowlist !== '') {
+				$internal_host_allowlist = Minz_Helper::htmlspecialchars_utf8(preg_split('/\s+/', $internal_host_allowlist, -1, PREG_SPLIT_NO_EMPTY));
+			}
+			$internal_host_allowlist_fromenv = true;
+			if (!is_array($internal_host_allowlist) || empty($internal_host_allowlist)) {
+				$internal_host_allowlist = FreshRSS_Context::systemConf()->internal_host_allowlist;
+				$internal_host_allowlist_fromenv = false;
+			}
+		?>
+
+		<div class="form-group">
+			<label class="group-name" for="internal-host-allowlist"><?= _t('admin.system.internal-host-allowlist') ?></label>
+			<div class="group-controls">
+				<textarea
+					<?= $internal_host_allowlist_fromenv ? '' : 'id="internal-host-allowlist"' ?>
+					name="internal-host-allowlist"
+					<?= $internal_host_allowlist_fromenv ? ' disabled="disabled"' : ''?>><?= implode("\n", $internal_host_allowlist) ?></textarea>
+				<?php if ($internal_host_allowlist_fromenv): ?>
+					<p class="help"><?= _i('help') ?> <?= _t('admin.system.override-by-env-var', 'INTERNAL_HOST_ALLOWLIST') ?></p>
+				<?php endif ?>
+					<div class="help"><?= _i('help') ?> <?= _t('admin.system.internal-host-allowlist.help') ?></div>
+			</div>
+		</div>
+
 		<h2><?= _t('admin.system.registration.title') ?></h2>
 
 		<div class="form-group">

+ 16 - 1
config.default.php

@@ -242,5 +242,20 @@ return [
 	'trusted_sources' => [
 		'127.0.0.0/8',
 		'::1/128',
-	]
+	],
+
+	# Requests to internal hosts such as 127.0.0.1 are blocked by default
+	# Blocked ranges include:
+	# - 10.0.0.0/8
+	# - 172.16.0.0/12
+	# - 192.168.0.0/16
+	#
+	# Here you can add overrides for particular IP/domain:port combinations
+	# Examples: 127.0.0.1:8080, rss-bridge:80, etc.
+	#
+	# CIDR is permitted too
+	# Examples: 0.0.0.0/0, ::/0 (to allow any IPv4 or any IPv6)
+	#
+	# Setting * disables this check completely, allowing any host to be accessed (unsafe)
+	'internal_host_allowlist' => [],
 ];

+ 5 - 2
docs/en/admins/09_AccessControl.md

@@ -6,7 +6,7 @@ FreshRSS offers three methods of Access control: Form Authentication using JavaS
 
 FreshRSS fetches RSS feeds using server-side HTTP requests (via the cURL library).
 This design allows users to subscribe to feeds hosted not just on the public internet, but also on internal or private networks.
-For example, many users connect FreshRSS to tools like RSS-Bridge, cron jobs, or local automation services such as Node-RED all of which may run on `localhost` or internal IPs.
+For example, many users connect FreshRSS to tools like RSS-Bridge, cron jobs, or local automation services such as Node-RED, all of which may run on `localhost` or internal IPs.
 
 In self-hosted, single-user setups, this behaviour is expected and usually safe.
 However, in **multi-user or public-facing instances**, this same functionality can introduce a potential security risk known as **Server-Side Request Forgery (SSRF)**.
@@ -17,7 +17,10 @@ In an SSRF scenario, a malicious user could submit a feed URL that points to int
 * `http://169.254.169.254` (cloud metadata services)
 * Other services not meant to be exposed externally
 
-While FreshRSS does not treat these requests as unsafe by default — since many legitimate use cases depend on them — it’s important to understand the implications if your instance is shared, exposed on the internet, or co-hosted with other services.
+FreshRSS blocks these unsafe requests by default, due to the security risks written above, though certain hosts can be excluded from the block by going to `Settings > System configuration` and making changes to the internal host allowlist.
+Entries are separated by newlines, and must be a `host:port` combination, for example `127.0.0.1:8080`, `rss-bridge:80` or a CIDR notation ('0.0.0.0/0' to allow any IPv4, `::/0` to allow any IPv6).
+Another option is to set an `INTERNAL_HOST_ALLOWLIST` environment variable (e.g. in your docker-compose file). The entries there are separated by whitespace instead.
+Adding `*` disables the SSRF check completely (unsafe).
 
 ### Recommended mitigations for shared/public setups
 

+ 16 - 3
lib/Minz/Request.php

@@ -174,14 +174,27 @@ class Minz_Request {
 	 * character is used to break the text into lines. This method is well suited to use
 	 * to split textarea content.
 	 * @param bool $plaintext `true` to return special characters without any escaping (unsafe), `false` (default) to XML-encode them
-	 * @return list<string>
+	 * @return list<string>|null
 	 */
-	public static function paramTextToArray(string $key, bool $plaintext = false): array {
+	public static function paramTextToArrayNull(string $key, bool $plaintext = false): ?array {
 		if (isset(self::$params[$key]) && is_string(self::$params[$key])) {
 			$result = preg_split('/\R/u', self::$params[$key]) ?: [];
 			return $plaintext ? $result : Minz_Helper::htmlspecialchars_utf8($result);
 		}
-		return [];
+		return null;
+	}
+
+	/**
+	 * Extract text lines to array.
+	 *
+	 * It will return an array where each cell contains one line of a text. The new line
+	 * character is used to break the text into lines. This method is well suited to use
+	 * to split textarea content.
+	 * @param bool $plaintext `true` to return special characters without any escaping (unsafe), `false` (default) to XML-encode them
+	 * @return list<string>
+	 */
+	public static function paramTextToArray(string $key, bool $plaintext = false): array {
+		return self::paramTextToArrayNull($key, $plaintext) ?? [];
 	}
 
 	public static function defaultControllerName(): string {

+ 1 - 1
lib/composer.json

@@ -18,7 +18,7 @@
 		"marienfressinaud/lib_opml": "dev-main#f0e850b6394af90b898daf0e65fcc7363457b844",
 		"phpgt/cssxpath": "v1.5.0",
 		"phpmailer/phpmailer": "7.1.1",
-		"simplepie/simplepie": "dev-freshrss#6cb0298998abae8699aa612c06097b3247c959f2"
+		"simplepie/simplepie": "dev-freshrss#2d4c5b99b8f851b1e416b84f6a9a1e85097b9012"
 	},
 	"config": {
 		"sort-packages": true,

+ 145 - 14
lib/simplepie/simplepie/src/File.php

@@ -84,7 +84,11 @@ class File implements Response
      * @param ?array<string, string> $headers
      * @param ?string $useragent
      * @param bool $force_fsockopen
-     * @param array<int, mixed> $curl_options
+     * @param array<int, mixed> $curl_options Specify cURL options.
+     *  Special case for HTTP redirect handling:
+     *   * CURLOPT_FOLLOWLOCATION not set or null: SimplePie PHP handling of HTTP redirections with security checks (default, recommended).
+     *   * CURLOPT_FOLLOWLOCATION = truthy: Native cURL handling of HTTP redirections _without_ SimplePie security checks;
+     *   * CURLOPT_FOLLOWLOCATION = falsy: No HTTP redirections at all;
      */
     public function __construct(string $url, int $timeout = 10, int $redirects = 5, ?array $headers = null, ?string $useragent = null, bool $force_fsockopen = false, array $curl_options = [])
     {
@@ -109,6 +113,22 @@ class File implements Response
                 $headers = [];
             }
             if (!$force_fsockopen && function_exists('curl_exec')) {
+                $resolve = false; // FreshRSS
+                if (empty($curl_options[CURLOPT_PROXY] ?? null)) { // FreshRSS
+                    $resolve = $this->get_curl_resolve_info($url);
+                    if ($resolve === null) {
+                        $this->error = 'URL is not allowed to be resolved: ' . \SimplePie\Misc::url_remove_credentials($url);
+                        $this->success = false;
+                        return;
+                    } elseif ($resolve === false) {
+                        $this->error = 'Failed to resolve domain: ' . \SimplePie\Misc::url_remove_credentials($url);
+                        $this->success = false;
+                        return;
+                    }
+                    if (!empty($resolve)) {
+                        $curl_options[CURLOPT_RESOLVE] = $resolve; // Prevent DNS rebinding
+                    }
+                }
                 $this->method = \SimplePie\SimplePie::FILE_SOURCE_REMOTE | \SimplePie\SimplePie::FILE_SOURCE_CURL;
                 $fp = self::curlInit($url, $timeout, $headers, $useragent, $curl_options);
                 $responseHeaders = '';
@@ -147,26 +167,91 @@ class File implements Response
                         $responseHeaders = \SimplePie\HTTP\Parser::prepareHeaders($responseHeaders);
                     }
                     $this->on_http_response($responseHeaders . $responseBody, $curl_options);
-                    if (\PHP_VERSION_ID < 80000) {
-                        curl_close($fp);
-                    }
                     $parser = new \SimplePie\HTTP\Parser($responseHeaders, true);
                     if ($parser->parse()) {
                         $this->set_headers($parser->headers);
                         $this->body = $responseBody;
-                        if ((in_array($this->status_code, [300, 301, 302, 303, 307]) || $this->status_code > 307 && $this->status_code < 400) && ($locationHeader = $this->get_header_line('location')) !== '' && $this->redirects < $redirects) {
-                            $this->redirects++;
-                            $location = \SimplePie\Misc::absolutize_url($locationHeader, $url);
-                            if ($location === false) {
-                                $this->error = "Invalid redirect location, trying to base “{$locationHeader}” onto “{$url}”";
-                                $this->success = false;
+                        // Handling of HTTP redirections:
+                        $followLocation = $curl_options[CURLOPT_FOLLOWLOCATION] ?? null;
+
+                        if ($followLocation) {
+                            // Native cURL handling of HTTP redirections
+                            $finalUrl = curl_getinfo($fp, CURLINFO_EFFECTIVE_URL);
+                            if (is_string($finalUrl) && $finalUrl !== '') {
+                                $this->url = $finalUrl;
+                            }
+                        } elseif ($followLocation === null) {
+                            // SimplePie PHP handling of HTTP redirections (default)
+                            if ((in_array($this->status_code, [300, 301, 302, 303, 307]) || $this->status_code > 307 && $this->status_code < 400) &&
+                                ($locationHeader = $this->get_header_line('location')) !== '' && ($this->redirects < $redirects || $redirects === -1)) { // FreshRSS: added infinite redirects for -1
+                                $this->redirects++;
+                                $location = \SimplePie\Misc::absolutize_url($locationHeader, $url);
+                                if ($location === false) {
+                                    $this->error = "Invalid redirect location, trying to base “{$locationHeader}” onto “{$url}”";
+                                    $this->success = false;
+                                    return;
+                                }
+
+                                // FreshRSS: POST to GET on redirect
+                                if (isset($curl_options[CURLOPT_POST]) && in_array($this->status_code, [301, 302, 303], true)) {	// Not for 307 and 308, which must not change the HTTP method
+                                    unset($curl_options[CURLOPT_POST]);
+                                    unset($curl_options[CURLOPT_POSTFIELDS]);
+                                    if (is_array($curl_options[CURLOPT_HTTPHEADER] ?? null)) {
+                                        $curl_options[CURLOPT_HTTPHEADER] = array_filter(
+                                            $curl_options[CURLOPT_HTTPHEADER],
+                                            function ($header) {
+                                                return is_string($header) && substr(strtolower(trim($header)), 0, 13) !== 'content-type:';
+                                            }
+                                        );
+                                    }
+                                }
+                                // FreshRSS: cross-origin authentication headers removal
+                                if (($url_parts_from = parse_url(strtolower($url))) === false) {
+                                    throw new \InvalidArgumentException('Malformed URL: ' . $url);
+                                }
+                                if (($url_parts_to = parse_url(strtolower($location))) === false) {
+                                    $this->error = "Invalid redirect location: malformed URL “{$location}”";
+                                    $this->success = false;
+                                    return;
+                                }
+                                foreach ([&$url_parts_from, &$url_parts_to] as &$url_parts) {
+                                    if (!isset($url_parts['port']) && isset($url_parts['scheme'])) {
+                                        if ($url_parts['scheme'] === 'http') {
+                                            $url_parts['port'] = 80;
+                                        } elseif ($url_parts['scheme'] === 'https') {
+                                            $url_parts['port'] = 443;
+                                        }
+                                    }
+                                }
+                                unset($url_parts);
+                                $sameOriginRedirect =
+                                    ($url_parts_from['scheme'] ?? '') === ($url_parts_to['scheme'] ?? '') &&
+                                    ($url_parts_from['host'] ?? '') === ($url_parts_to['host'] ?? '') &&
+                                    ($url_parts_from['port'] ?? '') === ($url_parts_to['port'] ?? '');
+                                if (!$sameOriginRedirect) {
+                                    unset($curl_options[CURLOPT_COOKIE]);
+                                    unset($curl_options[CURLOPT_USERPWD]);
+                                    if (is_array($curl_options[CURLOPT_HTTPHEADER] ?? null)) {
+                                        $curl_options[CURLOPT_HTTPHEADER] = array_filter(
+                                            $curl_options[CURLOPT_HTTPHEADER],
+                                            function ($header) {
+                                                return is_string($header) && !preg_match('/^(Cookie|Authorization)\s*:/i', $header);
+                                            }
+                                        );
+                                    }
+                                }
+
+                                $this->permanentUrlMutable = $this->permanentUrlMutable && ($this->status_code == 301 || $this->status_code == 308);
+                                $this->__construct($location, $timeout, $redirects, $headers, $useragent, $force_fsockopen, $curl_options);
                                 return;
                             }
-                            $this->permanentUrlMutable = $this->permanentUrlMutable && ($this->status_code == 301 || $this->status_code == 308);
-                            $this->__construct($location, $timeout, $redirects, $headers, $useragent, $force_fsockopen, $curl_options);
-                            return;
+                            // } elseif ($followLocation == false) {
+                            // No HTTP redirections at all
                         }
                     }
+                    if (\PHP_VERSION_ID < 80000) {
+                        curl_close($fp);
+                    }
                 }
             } else {
                 $this->method = \SimplePie\SimplePie::FILE_SOURCE_REMOTE | \SimplePie\SimplePie::FILE_SOURCE_FSOCKOPEN;
@@ -230,7 +315,8 @@ class File implements Response
                             $this->body = $parser->body;
                             $this->status_code = $parser->status_code;
                             $this->on_http_response($responseHeaders);
-                            if ((in_array($this->status_code, [300, 301, 302, 303, 307]) || $this->status_code > 307 && $this->status_code < 400) && ($locationHeader = $this->get_header_line('location')) !== '' && $this->redirects < $redirects) {
+                            if ((in_array($this->status_code, [300, 301, 302, 303, 307]) || $this->status_code > 307 && $this->status_code < 400) &&
+                                ($locationHeader = $this->get_header_line('location')) !== '' && ($this->redirects < $redirects || $redirects === -1)) { // FreshRSS: added infinite redirects for -1
                                 $this->redirects++;
                                 $location = \SimplePie\Misc::absolutize_url($locationHeader, $url);
                                 $this->permanentUrlMutable = $this->permanentUrlMutable && ($this->status_code == 301 || $this->status_code == 308);
@@ -239,6 +325,41 @@ class File implements Response
                                     $this->success = false;
                                     return;
                                 }
+
+                                // FreshRSS: POST to GET on redirect is not applicable here as fsockopen only ever performs GET requests.
+                                // FreshRSS: cross-origin authentication headers removal
+                                if (($url_parts_from = parse_url(strtolower($url))) === false) {
+                                    throw new \InvalidArgumentException('Malformed URL: ' . $url);
+                                }
+                                if (($url_parts_to = parse_url(strtolower($location))) === false) {
+                                    $this->error = "Invalid redirect location: malformed URL “{$location}”";
+                                    $this->success = false;
+                                    return;
+                                }
+                                foreach ([&$url_parts_from, &$url_parts_to] as &$url_parts) {
+                                    if (!isset($url_parts['port']) && isset($url_parts['scheme'])) {
+                                        if ($url_parts['scheme'] === 'http') {
+                                            $url_parts['port'] = 80;
+                                        } elseif ($url_parts['scheme'] === 'https') {
+                                            $url_parts['port'] = 443;
+                                        }
+                                    }
+                                }
+                                unset($url_parts);
+                                $sameOriginRedirect =
+                                    ($url_parts_from['scheme'] ?? '') === ($url_parts_to['scheme'] ?? '') &&
+                                    ($url_parts_from['host'] ?? '') === ($url_parts_to['host'] ?? '') &&
+                                    ($url_parts_from['port'] ?? '') === ($url_parts_to['port'] ?? '');
+                                if (!$sameOriginRedirect) {
+                                    $headers = array_filter(
+                                        $headers,
+                                        function (string $key) {
+                                            return !preg_match('/^(Cookie|Authorization)$/i', $key);
+                                        },
+                                        ARRAY_FILTER_USE_KEY
+                                    );
+                                }
+
                                 $this->__construct($location, $timeout, $redirects, $headers, $useragent, $force_fsockopen, $curl_options);
                                 return;
                             }
@@ -320,6 +441,16 @@ class File implements Response
     {
     }
 
+    /**
+     * Event to allow inheriting classes to control fetching certain URLs.
+     * @param string $url
+     * @return array<string>|null|false Returns a value for CURLOPT_RESOLVE as an array, null if no allowed IPs were found, false if the domain failed to resolve.
+     */
+    protected function get_curl_resolve_info(string $url)
+    {
+        return [];
+    }
+
     public function get_permanent_uri(): string
     {
         return (string) $this->permanent_url;

+ 1 - 1
p/themes/Alternative-Dark/adark.css

@@ -107,7 +107,7 @@ input[type="radio"]:hover {
 	accent-color: var(--contrast-background-color-hover);
 }
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	color: var(--font-color-dark);
 	border-color: var(--border-color-dark);
 }

+ 1 - 1
p/themes/Alternative-Dark/adark.rtl.css

@@ -107,7 +107,7 @@ input[type="radio"]:hover {
 	accent-color: var(--contrast-background-color-hover);
 }
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	color: var(--font-color-dark);
 	border-color: var(--border-color-dark);
 }

+ 1 - 1
p/themes/Ansum/_forms.css

@@ -100,7 +100,7 @@ input:invalid, select:invalid {
 	box-shadow: none;
 }
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	color: var(--grey-medium-dark);
 	border-color: var(--grey-medium-dark);
 }

+ 1 - 1
p/themes/Ansum/_forms.rtl.css

@@ -100,7 +100,7 @@ input:invalid, select:invalid {
 	box-shadow: none;
 }
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	color: var(--grey-medium-dark);
 	border-color: var(--grey-medium-dark);
 }

+ 1 - 1
p/themes/Dark/dark.css

@@ -142,7 +142,7 @@ input, select, textarea {
 	box-shadow: 0 2px 2px var(--dark-form-element-box-shadow-inset) inset;
 }
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	background-color: var(--dark-background-color2);
 	color: var(--dark-font-colorA);
 	border-style: solid;

+ 1 - 1
p/themes/Dark/dark.rtl.css

@@ -142,7 +142,7 @@ input, select, textarea {
 	box-shadow: 0 2px 2px var(--dark-form-element-box-shadow-inset) inset;
 }
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	background-color: var(--dark-background-color2);
 	color: var(--dark-font-colorA);
 	border-style: solid;

+ 1 - 1
p/themes/Flat/flat.css

@@ -60,7 +60,7 @@ input:invalid, select:invalid {
 	box-shadow: none;
 }
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	background: #eee;
 }
 

+ 1 - 1
p/themes/Flat/flat.rtl.css

@@ -60,7 +60,7 @@ input:invalid, select:invalid {
 	box-shadow: none;
 }
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	background: #eee;
 }
 

+ 1 - 1
p/themes/Nord/nord.css

@@ -155,7 +155,7 @@ textarea:focus {
 
 
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	border-color: var(--border-elements);
 	color: var(--text-accent);
 }

+ 1 - 1
p/themes/Nord/nord.rtl.css

@@ -155,7 +155,7 @@ textarea:focus {
 
 
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	border-color: var(--border-elements);
 	color: var(--text-accent);
 }

+ 1 - 1
p/themes/Origine/origine.css

@@ -143,7 +143,7 @@ input:invalid, select:invalid {
 	box-shadow: 0 0 2px 2px var(--form-element-invalid-box-shadow-color-inset) inset;
 }
 
-input:disabled, select:disabled {
+input:disabled, textarea:disabled, select:disabled {
 	background-color: var(--background-color-light-shadowed);
 }
 

+ 1 - 1
p/themes/Origine/origine.rtl.css

@@ -143,7 +143,7 @@ input:invalid, select:invalid {
 	box-shadow: 0 0 2px 2px var(--form-element-invalid-box-shadow-color-inset) inset;
 }
 
-input:disabled, select:disabled {
+input:disabled, textarea:disabled, select:disabled {
 	background-color: var(--background-color-light-shadowed);
 }
 

+ 0 - 2
p/themes/Swage/swage.css

@@ -169,9 +169,7 @@ label {
 		border: 2px solid var(--color-background-dark);
 		outline: none;
 	}
-}
 
-:is(input, select) {
 	&:invalid {
 		padding-left: 5px;
 		color: var(--color-text-bad);

+ 0 - 2
p/themes/Swage/swage.rtl.css

@@ -169,9 +169,7 @@ label {
 		border: 2px solid var(--color-background-dark);
 		outline: none;
 	}
-}
 
-:is(input, select) {
 	&:invalid {
 		padding-right: 5px;
 		color: var(--color-text-bad);

+ 9 - 4
p/themes/base-theme/frss.css

@@ -194,15 +194,19 @@ p#favicon-ext {
 	text-decoration: underline;
 }
 
-p.help, .prompt p.help {
+.help, .prompt .help {
 	margin: 0.25rem 0 0.5rem;
 	text-align: left;
 }
 
-p.help .icon {
+.help .icon {
 	filter: brightness(2);
 }
 
+.help > ul {
+	padding-left: 1rem;
+}
+
 kbd {
 	background-color: var(--frss-background-color-middle);
 	padding: 0 0.5rem 0 0.5rem;
@@ -376,6 +380,7 @@ textarea:invalid {
 }
 
 input:disabled,
+textarea:disabled,
 select:disabled {
 	background-color: transparent;
 	min-width: 75px;
@@ -559,12 +564,12 @@ input.ignore-auto-complete {
 }
 
 /*=== Buttons */
-button[disabled] {
+button:disabled {
 	opacity: 0.5;
 	color: var(--frss-font-color-disabled);
 }
 
-button[disabled]:hover, input[disabled]:hover {
+button:disabled:hover, textarea:disabled:hover, input:disabled:hover {
 	cursor: not-allowed;
 }
 

+ 9 - 4
p/themes/base-theme/frss.rtl.css

@@ -194,15 +194,19 @@ p#favicon-ext {
 	text-decoration: underline;
 }
 
-p.help, .prompt p.help {
+.help, .prompt .help {
 	margin: 0.25rem 0 0.5rem;
 	text-align: right;
 }
 
-p.help .icon {
+.help .icon {
 	filter: brightness(2);
 }
 
+.help > ul {
+	padding-right: 1rem;
+}
+
 kbd {
 	background-color: var(--frss-background-color-middle);
 	padding: 0 0.5rem 0 0.5rem;
@@ -376,6 +380,7 @@ textarea:invalid {
 }
 
 input:disabled,
+textarea:disabled,
 select:disabled {
 	background-color: transparent;
 	min-width: 75px;
@@ -559,12 +564,12 @@ input.ignore-auto-complete {
 }
 
 /*=== Buttons */
-button[disabled] {
+button:disabled {
 	opacity: 0.5;
 	color: var(--frss-font-color-disabled);
 }
 
-button[disabled]:hover, input[disabled]:hover {
+button:disabled:hover, textarea:disabled:hover, input:disabled:hover {
 	cursor: not-allowed;
 }
 

+ 1 - 0
phpcs.xml

@@ -56,6 +56,7 @@
 		<exclude name="Squiz.Functions.MultiLineFunctionDeclaration.Indent"/>
 		<exclude name="Squiz.Functions.MultiLineFunctionDeclaration.OneParamPerLine"/>
 		<exclude name="Squiz.WhiteSpace.ScopeClosingBrace.ContentBefore"/>
+		<exclude name="Generic.CodeAnalysis.EmptyStatement.DetectedIf"/>
 	</rule>
 	<rule ref="Generic.Classes.DuplicateClassName"/>
 	<rule ref="Generic.CodeAnalysis.EmptyStatement"/>