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>
Closes#9394
## Summary
The _User CSS_ and _User JS_ sections already existed in the user documentation, but they did not say where to find the settings. This explains that each extension has a gear icon (_Manage_) next to its switch, and gives the steps to use both extensions.
## Changes
- `docs/en/users/05_Configuration.md`: describe the extension list (switch, gear icon, name), add the steps for User CSS, and the same for User JS with a note that it might ask to sign in again.
- `docs/en/users/05_Configuration.md`, `docs/en/admins/11_Themes.md`, `docs/en/contributing.md`: the "CustomCSS extension" links pointed to FreshRSS/Extensions, where it is no longer available. They now point to the built-in User CSS section.
- `docs/en/admins/15_extensions.md`, `lib/core-extensions/README.md`: list the two core extensions with links to their documentation. Also fix the `.lib/core-extensions` path.
- `CREDITS.md`: add my name.
Only the English documentation is changed.
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>
Follow-up to #9369, as discussed in https://github.com/FreshRSS/FreshRSS/pull/9369#issuecomment-5954861516
Changes proposed in this pull request:
- In the reader view, the date in the line under the article title had a fixed `width: 155px`. A long date such as "28 September 2026 at 18:30" needs about 190px, so at desktop width it broke into two lines. This was already the case before #9369.
- `.content .subtitle.horizontal-list .item.date` now uses the 155px as `min-width` and has `white-space: nowrap`, so the box grows to fit the date and the date stays on one line.
- Short dates are unchanged: they keep the 155px box and the same position.
- If the feed name, authors and date do not fit on one row, the date moves to the next line as a whole (the `flex-wrap` from #9369), instead of breaking in the middle.
- In the `max-width: 840px` block the date is reset to `min-width: 0; white-space: normal`, so nothing can overflow on very narrow screens. The phone layout from #9369 is unchanged.
- `frss.rtl.css` was regenerated with `rtlcss`.
If we were to remove or rename `errorController`, an HTML injection could potentially be introduced due to the fallback code path, that directly echoes the logs without any escaping.
(but mainly removing it to reduce the amount of code, and since it's unused)
* 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.
In the reader view, the feed name, authors and date share one
horizontal-list row whose items are `flex: 0 0 auto` and whose date is
a fixed 155px wide. The row could neither wrap nor shrink, so a long
feed name or author list pushed the date past the article box, and on
a phone the whole page scrolled sideways.
Let the row wrap and let its items shrink, so a long feed name is
ellipsized and the authors wrap inside the box. On narrow screens the
date then sits on its own line, so drop its fixed width there.
Rows that already fit are laid out as before.
Closes#9360
Since #9170, opening the menu of a label in the sidebar throws
`TypeError: Cannot set properties of null (setting 'href')`, as the
menu of labels has no website link. The rest of the click handler is
then skipped, so for instance the menu does not flip upwards when there
is no room below it.
Assisted-by: Claude <noreply@anthropic.com>
* Avoid writing to log files during unit tests
* Translate PHPDoc of `record()`
* Move environment variable into `phpunit.xml` and use tabs for indents
* Slightly more robust SessionTest and cleanup
Improvement of #9311
I noticed that my `/tmp` directory got filled up with random `sess_*` files and empty `frss_test_sessions_*` directories after repeatedly running PHPUnit, which means there was no proper cleanup being done in the tests.
See the first commit for changes, since I also moved the test to a more appropriate directory name (alongside `tests/lib/Minz`, `tests/app/Minz` was newly added in #9311).
Before the PR:
* Check output of `ls -lah /tmp/{frss_test_sessions_*,sess_*}; rm -r /tmp/{frss_test_sessions_*,sess_*}` to confirm there are initially no temporary files existing.
* Run `composer run phpunit`.
* Check output of the previous command again to confirm there is a new `sess_*` file and an empty `frss_test_sessions_*` directory.
After the PR:
* Check output of `ls -lah /tmp/{frss_test_sessions_*,sess_*}; rm -r /tmp/{frss_test_sessions_*,sess_*}` to confirm that no temporary files exist initially.
* Run `composer run phpunit`.
* Check output of the previous command again to confirm there are no temporary files, since they are now being cleaned up correctly.
* Move SessionTest from tests/app/Minz to tests/lib/Minz
* Check SVG optimization in the tests CI workflow with SVGO
Previously it was only recommended by the documentation that SVG images should be optimized using [SVGOMG](https://github.com/jakearchibald/svgomg), the web GUI version of https://github.com/svg/svgo (CLI)
Now all SVG images are optimized, and optimization is mandatory enforced by the tests CI workflow.
\+ SVGs are sanitized of potentially malicious JS code using the https://svgo.dev/docs/plugins/removeScripts/ plugin
* Output prettified SVGs instead and remove unchanged files from stdout
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Use `removeScripts` SVGO plugin to reduce risk of malicious SVGs
* Move the CLI options to the JS config instead
* Disable `removeComments` plugin included in the default preset
* editorconfig, and easier config
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* 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>
* Update documentation of TRUSTED_PROXY syntax and document limitations.
Single IPs are now supported as of #9301.
Document the existing limitation of domain names, see #9210.
For IPs without `/`, I manually tested the behavior and found requests got rejected on my server before #9301. After #9301 is merged, I've tested again and found that requests are now authorized as expected. So I've updated the corresponding documentation to say "_IPs or_ ranges" where applicable.
Domain names are still silently ignored as of writing. I looked at the code, as described in #9210 investigation steps, and concluded that they are WAI (working as _implemented_ , not sure if _intended_ ). I don't particularly like the existing behavior (failures with no debuggable warnings or logs) but I try to document it as-is without judgement.
* Update TRUSTED_PROXY comments accordingly for Docker usage.
Single IPs are now supported as of #9301.
Document the existing limitation of domain names, see #9210.
* 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>