convertJsonToRss() HTML-escapes item fields before rendering the RSS,
but the is_string() guard skipped 'tags', which is always an array.
An unescaped & in a JSON category then produced invalid XML, so
SimplePie rejected the whole document and the feed showed as empty.
Minz_Helper::htmlspecialchars_utf8() already handles arrays,
so the guard is dropped. Add a test for array and string categories.
fix https://github.com/FreshRSS/FreshRSS/issues/9488
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Note: The changes in this PR are entirely AI-authored but manually reviewed and tested
## Summary
Translate the remaining French prose and French-derived identifiers in the main FreshRSS/Minz codebase to English.
Why: helps to understand the code
## Scope
- Minz comments, PHPDoc, and paginator identifiers
- Application comments and related identifiers
- Installer database identifiers
- Theme metadata descriptions and the identified CSS comments, previous discussion: https://github.com/FreshRSS/FreshRSS/pull/8241#discussion_r2553181653
- Related call sites and view helpers
External libraries and intentional i18n/docs content are excluded.
* New hook: `Minz_HookType::AutofillField` for auth views
Currently to be used in the Demo extension, because it overrides both the formLogin and reauth views (for autofilling username/password), it can be broken every time there is a slight change in core.
For example, after https://github.com/FreshRSS/FreshRSS/pull/9358.
In the future the hook can be extended to support more fields.
Demo extension PR: https://github.com/FreshRSS/xExtension-Demo/pull/6
* Suggested alternative without returning HTML code
* Preserve explicit sort overrides when searching
Closes#9466
Changes proposed in this pull request:
- Keep an explicit sort override when submitting the header search form, including `c.name`, `f.name` and `lastUserModified`.
- Continue using the already validated `FreshRSS_Context::$sort` value; omit the hidden field when no non-empty override was requested.
- Add header-rendering regression tests for all nine supported sorts, absent/empty overrides and invalid input.
* A bit more fixing
* Fix line mistake
* Forgotten plaintext
* Fix test
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Fix local DNS leak when fetching feeds through a proxy
Follow-up to #9390: `getRetryAfterFile()` passed the full URL to
`Minz_Request::serverIsPublic()`, which then resolved the target hostname
with the local resolver (`dns_get_record()`), even when a proxy was
configured. With a socks5h/socks4a proxy this leaks the target hostname
to the local DNS and defeats remote name resolution.
- `Minz_Request::serverIsPublic()`: add an `$allowDnsResolve` parameter,
and forward it to the recursive call so the guard holds for the whole
recursion.
- `FreshRSS_http_Util::getRetryAfterFile()`: do not resolve the host name
when a proxy is configured (`$proxy === ''`), since the proxy is
responsible for DNS.
---------
Co-authored-by: Inverle <inverle@proton.me>
See https://www.php.net/manual/function.version-compare.php
The extension's minimum required FreshRSS version must be compared against the currently running version, not the other way around.
Assuming the running FreshRSS version is `1.30.1`:
| Minimum required version | Expected status | Before this fix | After this fix |
|---|---|---|---|
| `1.27.0` | ✅ Compatible | ❌ Incompatible | ✅ Compatible |
| `1.30.1` | ✅ Compatible | ✅ Compatible | ✅ Compatible |
| `1.31.0` | ❌ Incompatible | ✅ Compatible | ❌ Incompatible |
* Update websub_pending entry in sub.php
Translated the websub_pending attribute entry in sub.php in german
* make fix-all
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* docs: updates turkish websub_pending entry in sub.php
Modified the websub_pending attribute entry in sub.php in turkish
* make fix-all
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
The hidden CSRF input was the first child of the form, directly before the
Mark as read button. Theme rules such as `.stick input + .btn` and
`.stick .btn:first-child` therefore treated the button as the second element
of an input + button group: no left border and no rounded left corners.
Move the input back inside the dropdown, as it was before #9205.
Fixes#9359
Changes proposed in this pull request:
- In `app/layout/nav_menu.phtml`, move the hidden `_csrf` input of the "Mark as read" form back inside the `#nav_menu_read` dropdown (where it was before #9205), instead of being the first child of the form right before the `read_all` button.
- Because of that input, many theme rules written for `input + .btn` groups (`.stick input + .btn`, and `.btn:first-child` no longer matching) removed the left border and the rounded left corners of the "Mark as read" button. This affected Alternative Dark, Origine and the themes built on them (Dark, Dark-pink, Origine-compact), Pafat, and gave a stray separator and square corners in Flat, Ansum and Mapco.
* Sanitizer: block data: URI scheme, keep data:image for images
FreshRSS_SimplePieCustom only disallowed the `javascript` scheme, so
`data:text/html,...` URIs in feed content survived
sanitization on <a href>, <iframe src>, <audio|video|source|track src>.
The default CSP neutralises these on FreshRSS pages, but clients and
deployments without that CSP (API WebViews, extensions using amendCsp(),
proxies stripping headers) could execute them. Same class as
GHSA-3fw6-j8m4-82vj / #9272.
- Sanitize::is_allowed_uri(): element/attribute-aware check that still
permits inert `data:image/...` on img[src], image[src], video[poster]
while blocking `data:` elsewhere; replace_urls() now uses it.
is_allowed_scheme() is unchanged for backward compatibility.
- SimplePieCustom: disallow_uri_schemes(['javascript','data']).
- Tests: 4 blocked-input cases, a data:image keep-alive test and an
`unsafe:` prefix test. They fail without the patch and pass with it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Sync SimplePie
https://github.com/FreshRSS/simplepie/pull/92
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Contributes to #6139 (export part; the import will follow in another PR)
Exporting favourites was collecting the whole file in memory. With many articles, it fails with the default PHP memory limit.
## Changes
- New `Minz_View::helperToFile()`, similar to `helperToString()`, writing the output of a helper into a file by chunks of 64 KiB instead of keeping it in memory.
- `FreshRSS_Export_Service`: the `generate*()` methods now return the path of a temporary file instead of the content, and `zip()` builds the archive from those files (`ZipArchive::addFile()` instead of `addFromString()`). Temporary files are created in `TMP_PATH` (already required to be writable) and deleted at the end of the script.
- The export is sent with `readfile()`: Web UI, `cli/export-zip-for-user.php`, `cli/export-opml-for-user.php` and the OPML export of the Google Reader API.
- Note: this changes what `FreshRSS_Export_Service` returns (a path instead of the content). All callers in the repository are updated, and I could not find any public extension using this service.
## Measurements
Peak PHP memory (`memory_get_peak_usage(true)`), SQLite database with synthetic starred articles:
| Scenario | Before | After |
|---|---|---|
| Web, export your favourites, 10,000 articles (69 MB JSON) | 136 MB (fails with the default `memory_limit` of 128 MB) | 6 MB |
| Web, ZIP (OPML + favourites + labelled articles + 4 feeds), 10,000 articles | 152 MB | 6 MB |
| CLI `export-zip-for-user.php`, 10,000 articles | 189 MB (fails with 128 MB) | 8 MB |
| Web, export your favourites, 30,000 articles (208 MB JSON) | 404 MB | 10 MB |
Co-authored-by: Alejandro Borrego <210245044+abp002@users.noreply.github.com>
Translate the last untranslated Japanese strings and mark the Lithuanian
endonym as IGNORE, consistent with the other language names.
Co-authored-by: akinechan <1593847+akine@users.noreply.github.com>
* Fix NAT64 feed fetching on IPv6-only hosts
Closes#9371
Regression from https://github.com/FreshRSS/FreshRSS/pull/9195
Changes proposed in this pull request:
* Fix feed fetching on IPv6-only hosts using NAT64.
* Preserve SSRF protection by validating the IPv4 address embedded in RFC 6052 NAT64 addresses before allowing it in `CURLOPT_RESOLVE`.
* Add regression tests for both public and private/reserved NAT64 addresses.
* Minor formating
* Add test tearDown
* RFC8215 64:ff9b:1::/48
Credits: @onurcangnc
---------
Co-authored-by: Viktor Platov <TapuGitHub@users.noreply.github.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Fix domain-wide Retry-After
- Fix the domain-wide HTTP `Retry-After` for public servers, which has not applied since #8195 (FreshRSS 1.28.0).
- `getRetryAfterFile()` passed the bare host name to `Minz_Request::serverIsPublic()`. That function expects a URL, so it always returned `false`.
- So a 429 or 503 paused only the URL that got it, despite the log saying "For that domain, will first retry after …", and FreshRSS went on requesting the other feeds of that server. With several Reddit feeds, for instance, each one got its own 429 in turn.
- `serverIsPublic()` now gets the URL. Its answer is kept per host for the rest of the request, because it resolves the host name (`dns_get_record()`), and a refresh may load many feeds of the same host.
- Fix the per-URL `Retry-After` of servers on a local network (#8195), which never applied to feeds whose URL, as stored, differs from the URL as requested.
- Stored URLs are HTML-encoded, e.g. `&` in an RSS-Bridge URL, and may end with `#force_feed`.
- `FreshRSS_Feed::load()` checked the stored URL, while the pause is stored for the URL as requested. It now checks the requested URL, as `loadJson()` and `loadHtmlXpath()` already do.
* Minor final
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Fix muting of feeds gone with HTTP 410
A feed answering HTTP 410 Gone is meant to be muted (#3561), but:
* Since #6037, FreshRSS_Feed_Exception extends Minz_Exception, which
resets any code other than its own error levels to 0, so the check for
410 never matched. FreshRSS_Feed_Exception now keeps the HTTP status
code it is given.
* FreshRSS_FeedDAO::mute() mutes by negating the TTL, which leaves a feed
with the default TTL (0) unmuted. Mute the feed the way the feed
settings and dynamic OPML categories do instead.
* A bit of cleaning
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Follow-up to #9370, applying @math-GH's review:
- `latest`: „Veröffentlichte Version“ instead of „Versionierte Veröffentlichung“. I didn't go back to „Stabile Version“ because #9270 moved the English away from "stable" on purpose, as @Inverle pointed out; „Veröffentlichte Version“ pairs with „Aktueller Entwicklungsstand“ for edge.
- „berücksichtigen“ instead of „einbeziehen“, in both include labels.
- `global_hint` without „Sie“, as suggested.
* auth: rotate session ID on all authenticated transitions (session fixation)
The PHP session ID was regenerated on explicit form login, reauth, logout and
password change, but NOT when access is (re)established from a remember-me
cookie, via HTTP authentication, or on self-registration auto-login (CWE-384),
so a fixed session ID could become authenticated.
Regenerate the session ID at those transitions too: in FreshRSS_Auth::init
when accessControl() first grants access (covers remember-me restore and HTTP
auth; skipped for auth_type 'none', which has no authentication boundary), and
in the self-registration auto-login path, mirroring the existing form-login
idiom. Also enable session.use_strict_mode before session_start as defence in
depth so an uninitialised, attacker-supplied session ID is not adopted.
* auth: avoid duplicate cookie during session rotation
* Code preferences, shorten some comments
---------
Co-authored-by: Inverle <inverle@proton.me>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* importExport: bound ZIP import against decompression bombs
ZIP import read every archive member into memory via getFromIndex() with no
limit on member count, per-member or total uncompressed size, or compression
ratio, so a small crafted upload could exhaust memory (CWE-409).
Enforce a member-count limit, per-member and aggregate uncompressed-size
limits and a compression-ratio ceiling from the central-directory metadata,
and additionally cap the real decompressed size while streaming each member
(the declared size is attacker-controlled and can under-report). Oversized or
over-compressed members are skipped and the loop stops once the budget is
reached; a realistic export (an OPML plus a few JSON files) stays well within
the limits.
* Fix lookup by name and total calculation
* Fix locateName regression
Duplicate entry name: keep only the first occurrence, but allow full paths
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* auth: make the login challenge nonce one-time
The per-login challenge nonce stored in the session was never consumed or
cleared, so a captured challenge stayed replayable and survived logout
(CWE-294). The client (p/scripts/extra.js) already fetches a fresh nonce for
every attempt, so consuming it does not affect legitimate retries.
Consume the nonce (unset it) as soon as it is read for verification in form
login, reauthentication, password change and account self-deletion, and clear
it in FreshRSS_Auth::removeAccess so logout invalidates any outstanding
challenge.
* auth: don't clear the nonce in removeAccess (breaks login)
removeAccess() runs on every unauthenticated request via FreshRSS_Auth::init()
-- including the login POST itself, before formLoginAction reads the nonce -- so
clearing it there rejected every login with 'Invalid session'. Thanks
@jamalkamaladdin for the measured catch. The one-time guarantee is preserved by
consuming the nonce when it is read for verification (login/reauth/password
change/self-deletion); logout no longer needs a separate clear.
* update: require POST + CSRF for updater mutations; guard post-config step
The self-update check/apply mutations were reachable via GET, which is never
anti-CSRF validated (the token is only checked on POST), and the privileged
post-configuration step (do_post_update()/PostUpdate hook, removal of the
staged update file) was additionally reauth-exempt and GET-reachable (CWE-352).
- checkAction and the apply branch now require POST, so the global anti-CSRF
token check applies; the update page's Check/Apply controls are POST forms
carrying the token.
- The post_conf finalisation is bound to a one-time session marker set by the
successful apply right before its internal redirect, so it only runs as that
continuation, not via a direct cross-site GET (independent of reauth config).
- Reauth is now required consistently, including for apply/post_conf (was
exempted); the legitimate post_conf redirect still passes on the reauth done
moments earlier by the apply request.
Status/index endpoints remain GET and read-only.
* Reduce some comments
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
The GET branch of feedController::addAction() built a FreshRSS_Feed
straight from the requested URL and loaded it, without invoking the
Minz_HookType::CheckUrlBeforeAdd hook. Only the POST branch, through
addFeed(), ran the hook.
Extensions that turn a website URL into a feed URL (e.g. RSS-Bridge)
were therefore bypassed on the confirmation page reached from the
bookmarklet: the raw website URL was probed, and since such sites do
not advertise a feed, the preview reported that no feed could be
added. Submitting the very same form then succeeded, because the POST
path did apply the hook.
Run the hook at the start of the GET branch so the preview probes the
URL that would actually be subscribed to, and reuse the existing
"not added" feedback when an extension refuses the URL, matching what
addFeed() does when the hook returns null.
The documented contract is that the hook "will be executed every time
a URL is added", so the preview should not be an exception.
Fixes#9339
TRUSTED_PROXY=192.168.1.1 is ignored and only 192.168.1.1/32 is honoured. checkCIDR() splits the range on the slash and rejects the value when the mask part is missing, so an address written without a subnet never matches.
An address without a subnet is now read as a single host: /32 for IPv4 and /128 for IPv6. That is the format docs/en/admins/09_AccessControl.md points at.
Host names such as gateway.localdomain are still not resolved.
tests/app/Utils/httpUtilTest.php gains 16 cases for checkCIDR, two of which fail without this change.
Refs #9210
Closes#9288
Changes proposed in this pull request:
The mark-as-read links contain the resolved state, so the automatic reading preference becomes an explicit `state=2` filter after the redirect. Once the last unread article is marked read, the page stays empty instead of showing all articles.
- Keep the requested state separately from the resolved state used by the action. A redirect state of `0` reapplies the reading preference; explicit filters still survive the redirect as intended by #9007.
- Carry that value through the menu, footer and “Load more” link. This matters with oldest-first pagination, where the last footer supplies the menu button's action.
- Add request-state and rendered-pagination tests, plus a changelog entry.
How to test the feature manually:
1. With both read and unread articles, choose “Show unreads if any, all articles otherwise”. Mark all as read from the footer, then repeat using the top button. The resulting page should show the read articles.
2. Set one article per page and oldest-first sorting. Load the remaining articles before marking all as read; the same fallback should work.
3. Select an explicit unread filter: marking everything read should still leave an empty unread view. Also check a favourite filter with a search term, including “Mark selection unread”; the search and favourite scope should remain intact.
Validation: `make NO_DOCKER=1 fix-all` and `make NO_DOCKER=1 test-all` pass with PHP 8.5.9 (719 PHPUnit tests, 1,378 assertions). Browser checks passed against SQLite in Chromium (desktop and mobile viewport) and Firefox. The original footer fails three of the six new rendered-pagination cases.
* Limit the length and parentheses nesting depth of a search query.
* Use exception
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Ignore security-relevant OPML attributes for dynamic OPML sources
OPML content can come from an untrusted source (a dynamic OPML category re-fetches its remote content on every refresh cycle). Such content must not be able to configure feed cURL parameters (CURLOPT_COOKIE, CURLOPT_PROXY, CURLOPT_POSTFIELDS, etc.) nor create further dynamic OPML categories. importOpml() therefore defaults to untrusted, and the local file import controller explicitly opts in.
* Review
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Log and block invalid challenge formats during login
Fixes some unexpected behaviors during a user login, including strange errors in the log related to JSON.
* Ignore JSON decode errors for URL unserializing
* Document the format a bit
* Only allow http/https URI schemes for enclosure and thumbnail URLs
Feed enclosure URLs were stored and rendered without URI scheme validation:
SimplePie's scheme check only applies to URLs inside content HTML
(Sanitize::replace_urls()), not to enclosure metadata, so a `javascript:`
URL survived the whole pipeline and was served as a clickable link in the
article view, including through the Google Reader compatible API.
Add FreshRSS_http_Util::isAllowedUrlScheme() and apply it:
- at store time, when building enclosure attributes in FreshRSS_Feed
- at render time, in FreshRSS_Entry::content(), so entries already stored
in existing databases are protected as well
https://github.com/FreshRSS/FreshRSS/security/advisories/GHSA-3fw6-j8m4-82vj
* Reuse existing SimplePie is_remote_uri() function
* Use more realistic test examples
* Add tests for protocol relative
And simplify tests further
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* SimplePie: Harden cache deserialization calls
To reduce impact if a maliciously provided cache file is somehow loaded, e.g. from a manipulated backup or some kind of external arbitrary file write vulnerability.
* Sync our SimplePie
https://github.com/FreshRSS/simplepie/pull/90
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Reword explanations for edge/latest channels
Highlight the fact that we do not backport bug fixes or security fixes to previous releases.
Simplify also a slightly obsolte MySQL doc to favour SQLite
* Merge i18n: az
* Update Polish translations to specify rolling/versioned releases
Co-authored-by: Inverle <inverle@proton.me>
* Update 05_Release_new_version.md
---------
Co-authored-by: Inverle <inverle@proton.me>