Просмотр исходного кода

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 день назад
Родитель
Сommit
dcec27c69d
62 измененных файлов с 779 добавлено и 206 удалено
  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
 ## 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
 * 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)
 	* 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)
 	* Set limits for regex during search [#8913](https://github.com/FreshRSS/FreshRSS/pull/8913)
 * SimplePie
 * 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)
 	* 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)
 	* 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)
 	* 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
 * 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)
 	* 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)
 	* 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_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
 * `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/))
 * `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_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.
 * `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
       FRESHRSS_ENV: development
       # Optional advanced parameter controlling the internal Apache listening port
       # Optional advanced parameter controlling the internal Apache listening port
       LISTEN: 0.0.0.0:80
       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,
       # 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
       # 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
       # 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
       TZ: Europe/Paris
       CRON_MIN: '3,33'
       CRON_MIN: '3,33'
       TRUSTED_PROXY: 172.16.0.1/12 192.168.0.1/16
       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:
     # # Optional healthcheck section:
     # healthcheck:
     # healthcheck:
     #   test: ["CMD", "cli/health.php"]
     #   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 | |
 | 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) |
 | 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) |
 | Ελληνικά (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 (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) |
 | 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) |
 | 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) |
 | 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) |
 | 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) |
 | 日本語 (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) |
 | 한국어 (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) |
 | 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) |
 | 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) |
 | Українська (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-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>
 </translations>
 
 

+ 7 - 7
README.md

@@ -123,19 +123,19 @@ See the [repository dedicated to those extensions](https://github.com/FreshRSS/E
 
 
 | Language | Progress | |
 | 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) |
 | 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) |
 | Ελληνικά (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 (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) |
 | 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) |
 | 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) |
 | 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) |
 | 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) |
 | 日本語 (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) |
 | 한국어 (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) |
 | 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) |
 | 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) |
 | Українська (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-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>
 </translations>
 
 

+ 5 - 0
app/Controllers/configureController.php

@@ -656,6 +656,7 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 	 *   - user category limit (default: 16384)
 	 *   - user category limit (default: 16384)
 	 *   - user feed limit (default: 16384)
 	 *   - user feed limit (default: 16384)
 	 *   - user login duration for form auth (default: FreshRSS_Auth::DEFAULT_COOKIE_DURATION)
 	 *   - user login duration for form auth (default: FreshRSS_Auth::DEFAULT_COOKIE_DURATION)
+	 *   - internal host allowlist
 	 */
 	 */
 	public function systemAction(): void {
 	public function systemAction(): void {
 		if (!FreshRSS_Auth::hasAccess('admin')) {
 		if (!FreshRSS_Auth::hasAccess('admin')) {
@@ -671,6 +672,10 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 			FreshRSS_Context::systemConf()->limits = $limits;
 			FreshRSS_Context::systemConf()->limits = $limits;
 			FreshRSS_Context::systemConf()->title = Minz_Request::paramString('instance-name') ?: 'FreshRSS';
 			FreshRSS_Context::systemConf()->title = Minz_Request::paramString('instance-name') ?: 'FreshRSS';
 			FreshRSS_Context::systemConf()->force_email_validation = Minz_Request::paramBoolean('force-email-validation');
 			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()->closed_registration_message = Minz_Request::paramString('closed_registration_message') ?: '';
 			FreshRSS_Context::systemConf()->save();
 			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) {
 			if ($max_redirs !== 0) {
 				$opts[CURLOPT_MAXREDIRS] = $max_redirs;
 				$opts[CURLOPT_MAXREDIRS] = $max_redirs;
-				$opts[CURLOPT_FOLLOWLOCATION] = 1;
+				$opts[CURLOPT_FOLLOWLOCATION] = true;
 			}
 			}
 			if ($useragent !== '') {
 			if ($useragent !== '') {
 				$opts[CURLOPT_USERAGENT] = $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) {
 			if ($max_redirs != 0) {
 				$opts[CURLOPT_MAXREDIRS] = $max_redirs;
 				$opts[CURLOPT_MAXREDIRS] = $max_redirs;
-				$opts[CURLOPT_FOLLOWLOCATION] = 1;
+				$opts[CURLOPT_FOLLOWLOCATION] = true;
 			}
 			}
 			if ($useragent !== '') {
 			if ($useragent !== '') {
 				$opts[CURLOPT_USERAGENT] = $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;
 				$hubJson['lease_end'] = time() - 60;
 				file_put_contents($hubFilename, json_encode($hubJson));
 				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([
 				CURLOPT_POSTFIELDS => http_build_query([
 					'hub.verify' => 'sync',
 					'hub.verify' => 'sync',
 					'hub.mode' => $state ? 'subscribe' : 'unsubscribe',
 					'hub.mode' => $state ? 'subscribe' : 'unsubscribe',
 					'hub.topic' => $url,
 					'hub.topic' => $url,
 					'hub.callback' => $callbackUrl,
 					'hub.callback' => $callbackUrl,
 				]),
 				]),
-				CURLOPT_USERAGENT => FRESHRSS_USERAGENT,
 				CURLOPT_MAXREDIRS => 10,
 				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 .
 			Minz_Log::warning('WebSub ' . ($state ? 'subscribe' : 'unsubscribe') . ' to ' . $url .
 				' via hub ' . $hubJson['hub'] .
 				' 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;
 				return true;
 			} else {
 			} else {
 				$hubJson['lease_start'] = time();	//Prevent trying again too soon
 				$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);
 		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]
 	#[\Override]
 	protected function on_http_response($response, array $curl_options = []): void {
 	protected function on_http_response($response, array $curl_options = []): void {
 		if (FreshRSS_Context::systemConf()->simplepie_syslog_enabled) {
 		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 $simplepie_syslog_enabled
  * @property-read bool $suppress_csp_warning
  * @property-read bool $suppress_csp_warning
  * @property array<string> $trusted_sources
  * @property array<string> $trusted_sources
+ * @property array<string> $internal_host_allowlist
  * @property array<string,array<string,mixed>> $extensions
  * @property array<string,array<string,mixed>> $extensions
  */
  */
 final class FreshRSS_SystemConfiguration extends Minz_Configuration {
 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 {
 final class FreshRSS_http_Util {
 
 
 	private const RETRY_AFTER_PATH = DATA_PATH . '/Retry-After/';
 	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 {
 	private static function getRetryAfterFile(string $url, string $proxy): string {
 		$domain = parse_url($url, PHP_URL_HOST);
 		$domain = parse_url($url, PHP_URL_HOST);
@@ -95,7 +111,7 @@ final class FreshRSS_http_Util {
 		$safe_params = [
 		$safe_params = [
 			CURLOPT_COOKIE,
 			CURLOPT_COOKIE,
 			CURLOPT_COOKIEFILE,
 			CURLOPT_COOKIEFILE,
-			CURLOPT_FOLLOWLOCATION,
+			CURLOPT_FOLLOWLOCATION,	// We filter this value later, only allowing `false`
 			CURLOPT_HTTPHEADER,
 			CURLOPT_HTTPHEADER,
 			CURLOPT_MAXREDIRS,
 			CURLOPT_MAXREDIRS,
 			CURLOPT_POST,
 			CURLOPT_POST,
@@ -256,12 +272,145 @@ final class FreshRSS_http_Util {
 		return $html;
 		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 non-empty-string $url
 	 * @param string|null $cachePath path to cache file, or `null` to disable caching
 	 * @param string|null $cachePath path to cache file, or `null` to disable caching
 	 * @param string $type {html,ico,json,opml,xml}
 	 * @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}
 	 * @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:
 	 *   `status` is the HTTP response code (e.g. 200, 404), or a custom negative value:
 	 *   * `-200` served from local cache;
 	 *   * `-200` served from local cache;
@@ -287,12 +436,12 @@ final class FreshRSS_http_Util {
 			cleanCache(CLEANCACHE_HOURS);
 			cleanCache(CLEANCACHE_HOURS);
 		}
 		}
 
 
-		$options = [];
 		$accept = '';
 		$accept = '';
 		$proxy = is_string(FreshRSS_Context::systemConf()->curl_options[CURLOPT_PROXY] ?? null) ? FreshRSS_Context::systemConf()->curl_options[CURLOPT_PROXY] : '';
 		$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)) {
 		if (is_array($attributes['curl_params'] ?? null)) {
 			$options = self::sanitizeCurlParams($attributes['curl_params']);
 			$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)) {
 			if (is_array($options[CURLOPT_HTTPHEADER] ?? null)) {
 				// Remove headers problematic for security
 				// Remove headers problematic for security
 				$options[CURLOPT_HTTPHEADER] = array_filter($options[CURLOPT_HTTPHEADER],
 				$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) {
 		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));
 			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;
 				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) {
 		if ($cachePath !== null && file_put_contents($cachePath, $body) === false) {
 			Minz_Log::warning("Error saving cache $cachePath for $url");
 			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];
 			'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 {
 	private static function checkCIDR(string $ip, string $range): bool {
 		$binary_ip = self::ipToBits($ip);
 		$binary_ip = self::ipToBits($ip);
+		if ($binary_ip === '') {
+			return false;
+		}
 		$split = explode('/', $range);
 		$split = explode('/', $range);
 
 
 		$subnet = $split[0] ?? '';
 		$subnet = $split[0] ?? '';
@@ -474,11 +697,24 @@ final class FreshRSS_http_Util {
 			return false;
 			return false;
 		}
 		}
 		$binary_subnet = self::ipToBits($subnet);
 		$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) {
 		if ($mask_bits === 0) {
-			$mask_bits = null;
+			return true;
 		}
 		}
 
 
 		$ip_net_bits = substr($binary_ip, 0, $mask_bits);
 		$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
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Vynutit ověření e-mailové adresy',
 		'force_email_validation' => 'Vynutit ověření e-mailové adresy',
 		'instance-name' => 'Název instance',
 		'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-categories' => 'Maximální počet kategorií na uživatele',
 		'max-feeds' => 'Maximální počet kanálů 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(
 		'registration' => array(
 			'number' => 'Maximální počet účtů',
 			'number' => 'Maximální počet účtů',
 			'select' => array(
 			'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.',
 		'default_closed_registration_message' => 'Dieser Server akzeptiert momentan keine neuen Registrierungen.',
 		'force_email_validation' => 'E-Mail-Adressprüfung erzwingen',
 		'force_email_validation' => 'E-Mail-Adressprüfung erzwingen',
 		'instance-name' => 'Bezeichnung',
 		'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-categories' => 'Anzahl erlaubter Kategorien pro Benutzer',
 		'max-feeds' => 'Anzahl erlaubter Feeds 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(
 		'registration' => array(
 			'number' => 'Maximale Anzahl von Accounts',
 			'number' => 'Maximale Anzahl von Accounts',
 			'select' => array(
 			'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
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Επιβολή επιβεβαίωσης διεύθυνσης email',
 		'force_email_validation' => 'Επιβολή επιβεβαίωσης διεύθυνσης email',
 		'instance-name' => 'Instance name',	// 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-categories' => 'Μέγιστος αριθμός κατηγοριών ανά χρήστη',
 		'max-feeds' => 'Μέγιστος αριθμός τροφοδοσιών ανά χρήστη',
 		'max-feeds' => 'Μέγιστος αριθμός τροφοδοσιών ανά χρήστη',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 		'registration' => array(
 			'number' => 'Μέγιστος αριθμός λογαριασμών',
 			'number' => 'Μέγιστος αριθμός λογαριασμών',
 			'select' => array(
 			'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
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// IGNORE
 		'force_email_validation' => 'Force email address validation',	// IGNORE
 		'force_email_validation' => 'Force email address validation',	// IGNORE
 		'instance-name' => 'Instance name',	// 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-categories' => 'Max number of categories per user',	// IGNORE
 		'max-feeds' => 'Max number of feeds 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(
 		'registration' => array(
 			'number' => 'Max number of accounts',	// IGNORE
 			'number' => 'Max number of accounts',	// IGNORE
 			'select' => array(
 			'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.',
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',
 		'force_email_validation' => 'Force email address validation',
 		'force_email_validation' => 'Force email address validation',
 		'instance-name' => 'Instance name',
 		'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-categories' => 'Max number of categories per user',
 		'max-feeds' => 'Max number of feeds 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(
 		'registration' => array(
 			'number' => 'Max number of accounts',
 			'number' => 'Max number of accounts',
 			'select' => array(
 			'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.',
 		'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',
 		'force_email_validation' => 'Forzar la validación de direcciones de correo electrónico',
 		'instance-name' => 'Nombre de la fuente',
 		'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-categories' => 'Límite de categorías por usuario',
 		'max-feeds' => 'Límite de fuentes 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(
 		'registration' => array(
 			'number' => 'Número máximo de cuentas',
 			'number' => 'Número máximo de cuentas',
 			'select' => array(
 			'select' => array(

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

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'این سرور در حال حاضر ثبت‌نام جدید نمی‌پذیرد.',
 		'default_closed_registration_message' => 'این سرور در حال حاضر ثبت‌نام جدید نمی‌پذیرد.',
 		'force_email_validation' => ' اعتبارسنجی آدرس ایمیل اجباری',
 		'force_email_validation' => ' اعتبارسنجی آدرس ایمیل اجباری',
 		'instance-name' => ' نام نمونه',
 		'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-categories' => ' حداکثر تعداد دسته ها برای هر کاربر',
 		'max-feeds' => ' حداکثر تعداد فید برای هر کاربر',
 		'max-feeds' => ' حداکثر تعداد فید برای هر کاربر',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 		'registration' => array(
 			'number' => ' حداکثر تعداد حساب ها',
 			'number' => ' حداکثر تعداد حساب ها',
 			'select' => array(
 			'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
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Pakota sähköpostiosoitteen vahvistus',
 		'force_email_validation' => 'Pakota sähköpostiosoitteen vahvistus',
 		'instance-name' => 'Instanssin nimi',
 		'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-categories' => 'Luokkien enimmäismäärä käyttäjää kohti',
 		'max-feeds' => 'Syötteiden 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(
 		'registration' => array(
 			'number' => 'Tilien enimmäismäärä',
 			'number' => 'Tilien enimmäismäärä',
 			'select' => array(
 			'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.',
 		'default_closed_registration_message' => 'Ce serveur n’accepte plus de nouvelles inscriptions pour le moment.',
 		'force_email_validation' => 'Forcer la validation des adresses email',
 		'force_email_validation' => 'Forcer la validation des adresses email',
 		'instance-name' => 'Nom de l’instance',
 		'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-categories' => 'Limite de catégories par utilisateur',
 		'max-feeds' => 'Limite de flux 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(
 		'registration' => array(
 			'number' => 'Nombre max de comptes',
 			'number' => 'Nombre max de comptes',
 			'select' => array(
 			'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
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Force email address validation',	// TODO
 		'force_email_validation' => 'Force email address validation',	// TODO
 		'instance-name' => 'Instance name',	// 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-categories' => 'Max number of categories per user',	// TODO
 		'max-feeds' => 'Max number of feeds 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(
 		'registration' => array(
 			'number' => 'Max number of accounts',	// TODO
 			'number' => 'Max number of accounts',	// TODO
 			'select' => array(
 			'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.',
 		'default_closed_registration_message' => 'Ez a szerver jelenleg nem fogad új regisztrációkat.',
 		'force_email_validation' => 'Kötelező email cím visszaigazolás',
 		'force_email_validation' => 'Kötelező email cím visszaigazolás',
 		'instance-name' => 'Instance név',
 		'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-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',
 		'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(
 		'registration' => array(
 			'number' => 'Max felhasználó szám',
 			'number' => 'Max felhasználó szám',
 			'select' => array(
 			'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
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Paksa verifikasi alamat surel',
 		'force_email_validation' => 'Paksa verifikasi alamat surel',
 		'instance-name' => 'Nama peladen',
 		'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-categories' => 'Jumlah kategori maksimal per pengguna',
 		'max-feeds' => 'Jumlah umpan 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(
 		'registration' => array(
 			'number' => 'Jumlah akun maksimal',
 			'number' => 'Jumlah akun maksimal',
 			'select' => array(
 			'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.',
 		'default_closed_registration_message' => 'Questo server non accetta nuove registrazioni per il momento.',
 		'force_email_validation' => 'Forza la validazione dell’indirizzo mail',
 		'force_email_validation' => 'Forza la validazione dell’indirizzo mail',
 		'instance-name' => 'Nome istanza',
 		'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-categories' => 'Limite categorie per utente',
 		'max-feeds' => 'Limite feeds 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(
 		'registration' => array(
 			'number' => 'Numero massimo di profili',
 			'number' => 'Numero massimo di profili',
 			'select' => array(
 			'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
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Eメールアドレスの検証を強制します',
 		'force_email_validation' => 'Eメールアドレスの検証を強制します',
 		'instance-name' => 'インスタンス名',
 		'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-categories' => '1ユーザーごとのカテゴリの最大値',
 		'max-feeds' => '1ユーザーごとのフィードの最大値',
 		'max-feeds' => '1ユーザーごとのフィードの最大値',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 		'registration' => array(
 			'number' => 'アカウントの最大値',
 			'number' => 'アカウントの最大値',
 			'select' => array(
 			'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
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => '이메일 주소 확인 강제화',
 		'force_email_validation' => '이메일 주소 확인 강제화',
 		'instance-name' => '인스턴스 이름',
 		'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-categories' => '사용자별 카테고리 개수 제한',
 		'max-feeds' => '사용자별 피드 개수 제한',
 		'max-feeds' => '사용자별 피드 개수 제한',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 		'registration' => array(
 			'number' => '계정 최대 개수',
 			'number' => '계정 최대 개수',
 			'select' => array(
 			'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
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Piespiedu e-pasta adreses validēšana',
 		'force_email_validation' => 'Piespiedu e-pasta adreses validēšana',
 		'instance-name' => 'Instances nosaukums',
 		'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-categories' => 'Maksimālais kategoriju skaits vienam lietotājam',
 		'max-feeds' => 'Maksimālais barotņu 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(
 		'registration' => array(
 			'number' => 'Maksimālais kontu skaits',
 			'number' => 'Maksimālais kontu skaits',
 			'select' => array(
 			'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.',
 		'default_closed_registration_message' => 'Deze server accepteert momenteel geen nieuwe registraties.',
 		'force_email_validation' => 'Emailadresvalidatie forceren',
 		'force_email_validation' => 'Emailadresvalidatie forceren',
 		'instance-name' => 'Voorbeeld naam',
 		'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-categories' => 'Categorielimiet per gebruiker',
 		'max-feeds' => 'Feedlimiet 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(
 		'registration' => array(
 			'number' => 'Maximum aantal accounts',
 			'number' => 'Maximum aantal accounts',
 			'select' => array(
 			'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
 		'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',
 		'force_email_validation' => 'Forçar la validacion de las adreças electronicas',
 		'instance-name' => 'Nom de l’instància',
 		'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-categories' => 'Limita de categoria per utilizaire',
 		'max-feeds' => 'Limita de fluxes 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(
 		'registration' => array(
 			'number' => 'Nombre max de comptes',
 			'number' => 'Nombre max de comptes',
 			'select' => array(
 			'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.',
 		'default_closed_registration_message' => 'Ten serwer obecnie nie przyjmuje nowych rejestracji.',
 		'force_email_validation' => 'Wymuś weryfikację adresu e-mail',
 		'force_email_validation' => 'Wymuś weryfikację adresu e-mail',
 		'instance-name' => 'Nazwa instancji',
 		'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-categories' => 'Maksymalna liczba kategorii na użytkownika',
 		'max-feeds' => 'Maksymalna liczba kanałów 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(
 		'registration' => array(
 			'number' => 'Maksymalna liczba kont',
 			'number' => 'Maksymalna liczba kont',
 			'select' => array(
 			'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.',
 		'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',
 		'force_email_validation' => 'Força verificação do endereço de email',
 		'instance-name' => 'Nome da instância',
 		'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-categories' => 'Limite de categorias por usuário',
 		'max-feeds' => 'Limite de Feeds 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(
 		'registration' => array(
 			'number' => 'Máximo número de contas',
 			'number' => 'Máximo número de contas',
 			'select' => array(
 			'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
 		'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',
 		'force_email_validation' => 'Força verificação do endereço de email',
 		'instance-name' => 'Nome da instância',
 		'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-categories' => 'Limite de categorias por utilizador',
 		'max-feeds' => 'Limite de Feeds 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(
 		'registration' => array(
 			'number' => 'Máximo número de contas',
 			'number' => 'Máximo número de contas',
 			'select' => array(
 			'select' => array(

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

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'На этом сервере в данный момент регистрация новых пользователей закрыта.',
 		'default_closed_registration_message' => 'На этом сервере в данный момент регистрация новых пользователей закрыта.',
 		'force_email_validation' => 'Обязать подтверждать адрес электронной почты',
 		'force_email_validation' => 'Обязать подтверждать адрес электронной почты',
 		'instance-name' => 'Название экземпляра',
 		'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-categories' => 'Максимальное количество категорий на пользователя',
 		'max-feeds' => 'Максимальное количество лент на пользователя',
 		'max-feeds' => 'Максимальное количество лент на пользователя',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 		'registration' => array(
 			'number' => 'Максимальное количество аккаунтов',
 			'number' => 'Максимальное количество аккаунтов',
 			'select' => array(
 			'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
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => 'Vynútiť overenie e-mailovej adresy',
 		'force_email_validation' => 'Vynútiť overenie e-mailovej adresy',
 		'instance-name' => 'Názov inštancie',
 		'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-categories' => 'Limit počtu kategórií pre používateľa',
 		'max-feeds' => 'Limit počtu kanálov pre používateľov',
 		'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(
 		'registration' => array(
 			'number' => 'Maximálny počt účtov',
 			'number' => 'Maximálny počt účtov',
 			'select' => array(
 			'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
 		'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',
 		'force_email_validation' => 'E-posta adresi doğrulamasını zorunlu kıl',
 		'instance-name' => 'Örnek adı',
 		'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-categories' => 'Kullanıcı başına maksimum kategori sayısı',
 		'max-feeds' => 'Kullanıcı başına maksimum besleme 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(
 		'registration' => array(
 			'number' => 'Maksimum hesap sayısı',
 			'number' => 'Maksimum hesap sayısı',
 			'select' => array(
 			'select' => array(

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

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => 'Наразі сервер не приймає нових реєстрацій.',
 		'default_closed_registration_message' => 'Наразі сервер не приймає нових реєстрацій.',
 		'force_email_validation' => 'Підтверджувати адресу електронної пошти',
 		'force_email_validation' => 'Підтверджувати адресу електронної пошти',
 		'instance-name' => 'Назва сервера',
 		'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-categories' => 'Максимум категорій у користувача',
 		'max-feeds' => 'Максимум стрічок у користувача',
 		'max-feeds' => 'Максимум стрічок у користувача',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 		'registration' => array(
 			'number' => 'Максимум облікових записів',
 			'number' => 'Максимум облікових записів',
 			'select' => array(
 			'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
 		'default_closed_registration_message' => 'This server does not accept new registrations at the moment.',	// TODO
 		'force_email_validation' => '强制验证邮箱地址',
 		'force_email_validation' => '强制验证邮箱地址',
 		'instance-name' => '实例名称',
 		'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-categories' => '各用户分类数限制',
 		'max-feeds' => '各用户订阅源数限制',
 		'max-feeds' => '各用户订阅源数限制',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 		'registration' => array(
 			'number' => '最大用户数',
 			'number' => '最大用户数',
 			'select' => array(
 			'select' => array(

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

@@ -91,8 +91,13 @@ return array(
 		'default_closed_registration_message' => '目前此伺服器不接受新的註冊。',
 		'default_closed_registration_message' => '目前此伺服器不接受新的註冊。',
 		'force_email_validation' => '強制驗證電子郵件位址',
 		'force_email_validation' => '強制驗證電子郵件位址',
 		'instance-name' => '實例名稱',
 		'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-categories' => '每個使用者的最大類別數',
 		'max-feeds' => '每個使用者的最大訂閱源數',
 		'max-feeds' => '每個使用者的最大訂閱源數',
+		'override-by-env-var' => 'This setting is set by the environment variable <kbd>%s</kbd>.',	// TODO
 		'registration' => array(
 		'registration' => array(
 			'number' => '最大帳號數',
 			'number' => '最大帳號數',
 			'select' => array(
 			'select' => array(

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

@@ -64,6 +64,32 @@
 			</div>
 			</div>
 		</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>
 		<h2><?= _t('admin.system.registration.title') ?></h2>
 
 
 		<div class="form-group">
 		<div class="form-group">

+ 16 - 1
config.default.php

@@ -242,5 +242,20 @@ return [
 	'trusted_sources' => [
 	'trusted_sources' => [
 		'127.0.0.0/8',
 		'127.0.0.0/8',
 		'::1/128',
 		'::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).
 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.
 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.
 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)**.
 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)
 * `http://169.254.169.254` (cloud metadata services)
 * Other services not meant to be exposed externally
 * 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
 ### 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
 	 * character is used to break the text into lines. This method is well suited to use
 	 * to split textarea content.
 	 * to split textarea content.
 	 * @param bool $plaintext `true` to return special characters without any escaping (unsafe), `false` (default) to XML-encode them
 	 * @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])) {
 		if (isset(self::$params[$key]) && is_string(self::$params[$key])) {
 			$result = preg_split('/\R/u', self::$params[$key]) ?: [];
 			$result = preg_split('/\R/u', self::$params[$key]) ?: [];
 			return $plaintext ? $result : Minz_Helper::htmlspecialchars_utf8($result);
 			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 {
 	public static function defaultControllerName(): string {

+ 1 - 1
lib/composer.json

@@ -18,7 +18,7 @@
 		"marienfressinaud/lib_opml": "dev-main#f0e850b6394af90b898daf0e65fcc7363457b844",
 		"marienfressinaud/lib_opml": "dev-main#f0e850b6394af90b898daf0e65fcc7363457b844",
 		"phpgt/cssxpath": "v1.5.0",
 		"phpgt/cssxpath": "v1.5.0",
 		"phpmailer/phpmailer": "7.1.1",
 		"phpmailer/phpmailer": "7.1.1",
-		"simplepie/simplepie": "dev-freshrss#6cb0298998abae8699aa612c06097b3247c959f2"
+		"simplepie/simplepie": "dev-freshrss#2d4c5b99b8f851b1e416b84f6a9a1e85097b9012"
 	},
 	},
 	"config": {
 	"config": {
 		"sort-packages": true,
 		"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 ?array<string, string> $headers
      * @param ?string $useragent
      * @param ?string $useragent
      * @param bool $force_fsockopen
      * @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 = [])
     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 = [];
                 $headers = [];
             }
             }
             if (!$force_fsockopen && function_exists('curl_exec')) {
             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;
                 $this->method = \SimplePie\SimplePie::FILE_SOURCE_REMOTE | \SimplePie\SimplePie::FILE_SOURCE_CURL;
                 $fp = self::curlInit($url, $timeout, $headers, $useragent, $curl_options);
                 $fp = self::curlInit($url, $timeout, $headers, $useragent, $curl_options);
                 $responseHeaders = '';
                 $responseHeaders = '';
@@ -147,26 +167,91 @@ class File implements Response
                         $responseHeaders = \SimplePie\HTTP\Parser::prepareHeaders($responseHeaders);
                         $responseHeaders = \SimplePie\HTTP\Parser::prepareHeaders($responseHeaders);
                     }
                     }
                     $this->on_http_response($responseHeaders . $responseBody, $curl_options);
                     $this->on_http_response($responseHeaders . $responseBody, $curl_options);
-                    if (\PHP_VERSION_ID < 80000) {
-                        curl_close($fp);
-                    }
                     $parser = new \SimplePie\HTTP\Parser($responseHeaders, true);
                     $parser = new \SimplePie\HTTP\Parser($responseHeaders, true);
                     if ($parser->parse()) {
                     if ($parser->parse()) {
                         $this->set_headers($parser->headers);
                         $this->set_headers($parser->headers);
                         $this->body = $responseBody;
                         $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;
                                 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 {
             } else {
                 $this->method = \SimplePie\SimplePie::FILE_SOURCE_REMOTE | \SimplePie\SimplePie::FILE_SOURCE_FSOCKOPEN;
                 $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->body = $parser->body;
                             $this->status_code = $parser->status_code;
                             $this->status_code = $parser->status_code;
                             $this->on_http_response($responseHeaders);
                             $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++;
                                 $this->redirects++;
                                 $location = \SimplePie\Misc::absolutize_url($locationHeader, $url);
                                 $location = \SimplePie\Misc::absolutize_url($locationHeader, $url);
                                 $this->permanentUrlMutable = $this->permanentUrlMutable && ($this->status_code == 301 || $this->status_code == 308);
                                 $this->permanentUrlMutable = $this->permanentUrlMutable && ($this->status_code == 301 || $this->status_code == 308);
@@ -239,6 +325,41 @@ class File implements Response
                                     $this->success = false;
                                     $this->success = false;
                                     return;
                                     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);
                                 $this->__construct($location, $timeout, $redirects, $headers, $useragent, $force_fsockopen, $curl_options);
                                 return;
                                 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
     public function get_permanent_uri(): string
     {
     {
         return (string) $this->permanent_url;
         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);
 	accent-color: var(--contrast-background-color-hover);
 }
 }
 
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	color: var(--font-color-dark);
 	color: var(--font-color-dark);
 	border-color: var(--border-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);
 	accent-color: var(--contrast-background-color-hover);
 }
 }
 
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	color: var(--font-color-dark);
 	color: var(--font-color-dark);
 	border-color: var(--border-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;
 	box-shadow: none;
 }
 }
 
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	color: var(--grey-medium-dark);
 	color: var(--grey-medium-dark);
 	border-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;
 	box-shadow: none;
 }
 }
 
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	color: var(--grey-medium-dark);
 	color: var(--grey-medium-dark);
 	border-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;
 	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);
 	background-color: var(--dark-background-color2);
 	color: var(--dark-font-colorA);
 	color: var(--dark-font-colorA);
 	border-style: solid;
 	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;
 	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);
 	background-color: var(--dark-background-color2);
 	color: var(--dark-font-colorA);
 	color: var(--dark-font-colorA);
 	border-style: solid;
 	border-style: solid;

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

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

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

@@ -60,7 +60,7 @@ input:invalid, select:invalid {
 	box-shadow: none;
 	box-shadow: none;
 }
 }
 
 
-input:disabled, select:disabled {
+input:disabled, select:disabled, textarea:disabled {
 	background: #eee;
 	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);
 	border-color: var(--border-elements);
 	color: var(--text-accent);
 	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);
 	border-color: var(--border-elements);
 	color: var(--text-accent);
 	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;
 	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);
 	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;
 	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);
 	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);
 		border: 2px solid var(--color-background-dark);
 		outline: none;
 		outline: none;
 	}
 	}
-}
 
 
-:is(input, select) {
 	&:invalid {
 	&:invalid {
 		padding-left: 5px;
 		padding-left: 5px;
 		color: var(--color-text-bad);
 		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);
 		border: 2px solid var(--color-background-dark);
 		outline: none;
 		outline: none;
 	}
 	}
-}
 
 
-:is(input, select) {
 	&:invalid {
 	&:invalid {
 		padding-right: 5px;
 		padding-right: 5px;
 		color: var(--color-text-bad);
 		color: var(--color-text-bad);

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

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

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

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

+ 1 - 0
phpcs.xml

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