e107 v2.3.10
Caution
v2.3.10 is a security release for sites on v2.3.9 or earlier.
Upgrade from any 2.x at or below v2.3.9. If your site tracks the master branch, you are already past v2.3.10, so installing it would be a downgrade. v2.4.x is planned to be the next forward step.
Important
Upgrade promptly. v2.3.10 closes an unauthenticated arbitrary file read that hands out e107_config.php, and with it your database credentials, on a default install. It also closes a cross-site request forgery hole that made every form on the site forgeable, and replaces the guessable random numbers behind password reset codes, session tokens, and activation keys.
- Unauthenticated arbitrary file read in the download handler (GHSA-87hm-vh32-7c3r, CVSS 7.5). A crafted download address read any file the web server could reach, with no login and no user interaction. On a default install that includes
e107_config.php. Sites on PHP 8.0 or later are affected; the containment check that was meant to stop this held on PHP 7.4 and earlier. (1fa51ecb56) - Cross-site request forgery: the security token check failed open (GHSA-72q5-94gw-prww, CVSS 6.5). e107 rejected a security token that was present and wrong, but let through a request that carried no token at all, so a forged form needed only to leave the field out. A visitor who was logged in to your site could be made to submit anything their account could submit, including administrative actions. (#5856)
- Password reset codes and other secrets were guessable (GHSA-gm6q-rqm6-p9m4, CVSS 7.4). Reset codes, session and security tokens, activation keys, CAPTCHA answers, and auto-generated passwords came from PHP's ordinary random number functions, several of them seeded from the clock, so a secret could be narrowed down by anyone who knew roughly when it was issued. (#5856)
You must upgrade to mitigate these risks.
Warning
The new cross-site request forgery protection can break form submissions, and we want to hear about it when it does.
Refusing a POST that arrives without a security token changes how every form on the site is accepted, on the front end as well as in the admin area. Getting that right across two decades of core, themes, and third-party plugins was the hardest part of this release, and some setups will hit a case we did not anticipate.
If a form stops working after upgrading, go to Admin Area » Site Preferences » Security & Protection and set Requests without a security token to Allow and log. Affected requests go through instead of being refused, and each one is written to the admin log with the names of the fields it posted, so you can see what is failing without leaving your site broken.
Then please open an issue with what the log shows. If you believe e107's cross-site request forgery protection should support your use case, that is a gap we want to close in the core. The preference is there to keep you running while we do, not as the answer.
Highlights
- [Security] Anyone could download any file on your server (GHSA-87hm-vh32-7c3r, CVSS 7.5). A specially written download link walked out of the downloads folder and fetched whatever it liked, your configuration file and its database password included. (1fa51ecb56)
- [Security] Forms now have to prove they came from your site (GHSA-72q5-94gw-prww, CVSS 6.5). A form submitted from somebody else's website used to be accepted as long as it left the security field out entirely, which meant a logged-in visitor could be tricked into acting on your site without knowing. (#5856)
- [Security] Password reset codes can no longer be guessed (GHSA-gm6q-rqm6-p9m4, CVSS 7.4). Reset codes, login sessions, and activation keys were built from numbers that were never meant to be unpredictable. They now come from the operating system's proper source of randomness. (#5856)
- [Security] Members-only downloads are members-only again. Asking for a download by its file name skipped every permission check, so a file restricted to a user class could be fetched by anyone who knew what it was called. (1fa51ecb56)
- The sign-in form fits on the screen. On a wide window the guest sign-in box hung off the right-hand edge, so part of it could not be reached. (#5844)
- Old themes and missing plugins no longer take a page down with them. Several places in the core loaded files that were removed from e107 years ago, which turned an ordinary page into a blank white screen. (#5854)
- Nearly ten thousand lines of code that nothing could run are gone. Dead files, unreachable endpoints, and long blocks of commented-out code have been cleared out, which makes the core easier to audit and to trust. (#5853)
For Administrators
Added
- A new setting: "Requests without a security token", under Admin, Preferences. It offers Refuse (recommended), Allow and log, and Allow, and it ships set to Refuse. Allow-and-log is the measurement mode: the request goes through and the admin log records the address and the names of the posted fields, never their values, so a sign-in form submitted without a token cannot put a password in your log. A refused request writes nothing at all, because one log row per refusal would give anyone who can reach your site an unlimited way to fill that table. (#5856)
Changed
- Cross-site request forgery protection changes how every form on your site is accepted. The rule covers the front end as well as the admin area, so there are a few things worth knowing before you upgrade. (#5856)
- Every page you serve gains two small pieces of markup. Each same-origin
POSTform gets a hiddene-tokeninput as its first child, and every HTML page gets a<meta name="e-token">before</head>. This is the part of the release most likely to surprise a theme: a stylesheet or script that targetsform > :first-childwill now select the hidden input instead of whatever it selected before. It is worth loading a page or two on any theme that you are using. There is no setting to stop e107 adding them: withe_form::token()deprecated and no longer called anywhere in the core, a site that switched injection off would only end up refusing its own forms. If the markup disturbs your theme, the fix is the theme's selector, and the setting above is what keeps you running in the meantime. - A refused request now answers 403 Forbidden. It used to answer 200 with the words "Unauthorized access!" in the body, which meant monitoring, log analysis, and web application firewalls could not tell a refusal from a normal page. Requests made by JavaScript get the same JSON error envelope that the core's own endpoints already use.
- Clear the system cache and force-refresh your browser after upgrading. Changing how tokens are generated invalidates every token that was already in flight, so anyone partway through a form at the moment you upgrade gets one refusal and needs to submit again.
- If you cache HTML in front of e107, check that first. e107 has no full-page cache of its own, and it already asks shared caches not to store HTML, so a conforming reverse proxy or content delivery network is unaffected. If you have deliberately overridden that to cache pages upstream, stop: those pages now carry a per-visitor token, and you would be handing one visitor's token to another.
- Requests that carry no cookies are left alone on purpose. A payment gateway's callback, a single sign-on assertion, and anything else machine-to-machine has no browser session to borrow, so refusing it would buy nothing and break a working integration. Those keep working exactly as before.
- Every page you serve gains two small pieces of markup. Each same-origin
- Downloads requested by file name are resolved through the downloads database. They used to be read straight off the disk, which skipped the user class check, the "download active" switch, the download limits, and the request log. A request by name now goes through the same checks as a request by number. The one thing this no longer does is serve a file that sits in the downloads folder without a matching entry in Downloads; those were never covered by any permission setting and need an entry added. (1fa51ecb56)
- The File Inspector now flags four leftover files. If your site has been upgraded in place over the years it may still carry
e107_files/e_ajax.php,e107_files/e_js.php,e107_web/js/e_ajax.php, ore107_web/js/e_js.php. None of them have done anything since 2012 and the newest e107 no longer ships them, but an upgrade never deletes files, so the File Inspector will now tell you to remove any copies you still have. (#5853)
Fixed
- Anyone could read any file on the server through the download handler. The handler pasted the raw address onto the downloads folder and served whatever came back, and the containment check meant to stop it had quietly collapsed: it tested against a folder,
e107_files/public/, that no version 2 install has ever created, and on PHP 8.0 and later that turned the whole check into "allow anything". A default install on PHP 8 gave upe107_config.php, database password included, to an unauthenticated request. Containment is now done properly, comparing full resolved paths and refusing outright if it cannot work out where it is allowed to serve from. (1fa51ecb56) - A password reset code could be used more than once. The code was never removed when it was redeemed, so anyone still holding the emailed link, a corporate mail scanner or a shared mailbox included, could keep resetting the account until the code expired. Redeeming a code now spends it. (#5856)
- An expired password reset code still worked. The expiry check deleted the row and then went ahead and honored it anyway, and a separate hourly clean-up gave a further five minutes of grace, so a code meant to die after ten minutes stayed usable for fifteen. Expiry is now enforced where it is checked. (#5856)
- The guest sign-in form ran off the right-hand edge of the window. The sign-in drop-down is anchored to a button at the far right of the navigation bar, and without an alignment class Bootstrap lines the panel up with the button's left edge, pushing 250 pixels of form past the edge of the screen on any window wider than 992 pixels. The signed-in avatar menu directly below it has carried the fix since 2020; the guest block was missed. Narrow windows were never affected, which is why this survived five years. (#5844)
- A theme with
LINKDISPLAYset to 4 took down every page. That setting loads a slide-out menu plugin that is no longer shipped with the core, and the core loaded it without checking it was there, so every page carrying the site links shortcode died. Sites that still have the plugin behave exactly as before; sites without it fall through to ordinary site links. (#5854) - The RSS page could die instead of rendering. When neither the requested template nor a theme override resolved,
rss.phpfell back to a file that moved in v2.3.1, which was a guaranteed fatal error. The fallback is now guarded rather than repointed, because a site upgraded from v2.3.0 or earlier still carries the old file and still renders from it. (#5854) - Every label was blank on the user menu's theme settings page. It loaded its wording from a folder that stopped existing when the
user_menuplugin was folded intouser, so the page came up with empty labels. (#5854) - The newsletter's mail-out screen had lost its wording. It loaded a language file that was renamed years ago without the caller following, and one of the two constants it wanted is a plugin name in a class property, which on PHP 8 stops the screen from opening at all rather than merely printing nothing. (#5854)
- Every admin create, edit, and list page requested two files that do not exist.
admin.jsand the wholescriptaculousfolder were deleted from the core in 2013, but three places still asked the browser to load them, producing a pair of 404s on nearly every admin page on any site that has not switched the deprecated Prototype library setting to "none". (#5854) - Fresh installs wrote a menu entry for a plugin that is not there. The default install data pointed a row at
e107_plugins/clock_menu/, which was removed from the core long ago, so every new site started life with a broken menu record. (#5854) - The Voux theme offered a menu that has never existed. Its install data seeded a row for
social/instafeed_menu.php, a file with no history in this repository at all, so Menu Manager listed a menu that would have failed the moment anyone switched it on. (#5854) - The
logandmetaweblogplugins were treated as part of the core. Both moved to repositories of their own, so a site that installs either one from its own repository was being classified as running a core plugin. (#5854)
For Developers
Added
e_random: one fail-closed source for every secret.bytes(),hex(),int(), andpick(), plusisAvailable()for the installer. There is no weak fallback anywhere in it: with no cryptographically secure generator behind it every method throws, because a guessable secret is worse than an unavailable site, and the exception message names exactly which of/dev/urandom, OpenSSL, libsodium, or mcrypt PHP is missing. The public surface is identical to thee107\Security\Randomclass onmaster, which reaches this one through aclass_alias, so a later fix moves between the branches unchanged. The installer refuses to proceed on a host that cannot supply a generator, which is the last point where an operator can still do something about it. (#5856)e_token_injector: the token is added to the finished page.e_form::open()has never emitted a token, and a large part of the core plus an unbounded set of third-party plugins writes raw<form>markup, which is why token protection was opt-in and mostly opted out of. The injector rewrites the response at thee_http_header::setContent()flush point, so every eligible form is protected without a plugin changing a line. It is unconditional and has no off switch, because withe_form::token()deprecated and no longer called anywhere in the core, switching it off would leave a site refusing its own forms rather than relaxing anything. Eligibility is deliberately narrow, because a token sent to the wrong place is worse than no token: HTML responses only,method="post"only, and an action that can be proven same-origin, so a form posting to a payment gateway is left alone. Markup inside<textarea>,<script>, and HTML comments is never touched, because the language file editor and several plugin admin screens legitimately keep form markup inside a textarea and writing into one corrupts it on save. (#5856)e107.security.csrfToken()in JavaScript. One new member on thee107object, and a function rather than a value so that where the token is read from stays an implementation detail we can change later. Anything using$.ajaxor$.postneeds nothing: a$.ajaxPrefilterattaches the token to same-origin POSTs, in the request body where the payload allows it and in anX-e-tokenheader where it does not. Reach forcsrfToken()only when driving your ownXMLHttpRequestorfetch(), as the Plupload uploader does, and never put the token in a query string, where it would be written into every access log in front of the site. (#5856)e_file::resolveSendPath(),getSendRoots(),isAbsolutePath(), andisSafeRelativePath().resolveSendPath()returns the canonical path orfalse, rejecting NUL bytes beforerealpath()can see them (PHP 8 raises aValueErrorthere, which would turn a bad request into a fatal), dropping roots that cannot be resolved instead of letting one poison the result, comparing canonical prefixes terminated with a separator rather than searching for a substring anywhere, and failing closed when no root resolves at all. Stream wrappers such asphp://andphar://are refused structurally, by never resolving. (1fa51ecb56)$opts['roots']one_file::send(). It replaces the default root list rather than extending it, which is what lets a caller handling untrusted input pin itself to a single directory. No caller in the tree passed$optsbefore, so nothing existing changes behavior. The core media handler widens toe_ROOTrather than narrowing, because plugin and theme installs both write public{e_PLUGIN}and{e_THEME}rows throughimportIcons(). (1fa51ecb56)paragonie/random_compat, vendored as a production dependency.random_bytes()andrandom_int()are native from PHP 7 onwards, and this branch still supports 5.6. The polyfill is a production dependency rather than a development one because e107 cannot mint a single secret without it. Every changed file in this release passesphp -lunderphp:5.6-cli. (#5856)- Security test coverage.
e_randomTestpins the output shape every consumer depends on and the fail-closed contract, including a child process with the generator taken away entirely. How it is taken away depends on the PHP version, because the two generations lose it in different places: on PHP 7 and laterrandom_bytes()andrandom_int()are disabled throughdisable_functions, which is what a hardenedphp.inilooks like from inside the class, and on PHP 5.6 the probe declares the two functions itself, which reproduces how therandom_compatpolyfill behaves on a host with no entropy source.e_token_injectorTestcovers eligibility and the markup it must not touch. Six acceptance Cests drive the rules over the wire: path traversal, token injection, a tokenless POST to an endpoint that never had a guard, a cookieless POST, allow-and-log recording field names and not values, and password reset code redemption. (#5856, 1fa51ecb56)
Changed
e_session::check()refuses a state-changing request that carries no token. Every disjunct used to readisset(...) && !checkFormToken(...), so omitting the field matched no branch and fell through toreturn true. A POST is now refused when it presents a cookie and carries no token. The cookie condition is the point: a request carrying nothing the browser attached on its own has no ambient authority to borrow. Only POST counts as state-changing, since it is the only method a browser sends cross-site without a preflight, so every GET behaves exactly as before. The mode is read throughe_session::tokenCheckMode()and can be overridden for a single request withe_session::setTokenCheckMode(), which returns the displaced value and restores preference control when passednull.checkFormToken()now compares withhash_equals().HTTP_X_E_TOKENis accepted alongside the posted field. (#5856)e_form::token()is deprecated as of v2.3.10, and nothing in the core calls it any more. Avoid it in new code and drop the call when you are next in that file. Nothing is removed and calling it stays harmless: the page simply ends up with two identical hidden inputs. Form POST protection is automatic whether the markup came frome_form, a theme template, or raw HTML in a plugin. If you are building an AJAX request body by hand, take the token frome107.security.csrfToken()on the JavaScript side instead. (#5856)- The per-file
e-tokenguard is gone from twelve entry points. Each opened with the same six lines forcing$_POST['e-token']to an empty string beforeclass2.php, so that a missing token would be read as an invalid one. The rule incheck()covers every entry point at once, so the copies said nothing the framework does not already do, and they actively defeated the carve-out for cookieless callers by refusing requests the rule deliberately allows. The plugin scaffold generator no longer writes the guard into new plugins either. (#5856) e_admin_controller::checkRequestToken()and the update routines keep their owncheck()call. It turns a baredie()into an error an admin can read, and it catches a token that expired between rendering a form and submitting it. Neither forces the tokenless case any more, becauseclass2.phphas already applied the site'stokenCheckMode()and second-guessing it there would make allow-and-log enforce. (#5856)e_file::send()runs its containment check before tearing down the output buffers, and streams from the path that passed it. Both are behavioral fixes rather than tidying. The check used to run afterob_end_clean(), so arealpath()warning could land inside the body of a binary download; and containment was tested onrealpath($filename)while the actual I/O used$filename, leaving a window in which a symlink swapped between the two would be read despite a passing check. (1fa51ecb56)- The core plugin list has one definition.
e107plugin::$core_pluginsande_plugin's copy have been merged intoe_plugin::CORE_PLUGINS, so the two lists cannot drift apart again, andlogandmetaweblogare out of it. (#5854) - The query-string filter in
filter_request()was deliberately left alone. It blocks the literal../../and it is not the boundary here:.././../reaches exactly the same file without ever containing that string, so no amount of pattern matching would have helped. Anyone maintaining a downstream package should fix the containment check rather than the filter. (1fa51ecb56)
Removed
e107_web/js/e_ajax.phpande107_web/js/e_js.php. Both were moved out ofe107_files/in November 2012 without their relative bootstrap path following, so both have raised a fatal error on every direct request in every release since v2.0alpha.e_ajax.phpadditionally passed$_POST['ajax_scfile']toparse_scbatch(), which reads the path withfile()and evaluates the resultingSC_BEGINblocks. That chain was never reachable in any release or tagged prerelease, but repairing the bootstrap path on its own would have made it live, so the endpoint is removed rather than repaired. Thechatbox_menuheader whose only statement loaded it, the featureboxajax_urldefault that pointed at it, and both entries in the redirection exception list have gone with it. (#5853)- Six unreferenced files, 4,580 lines.
pcltar.lib.phpandpcltrace.lib.php(archive handling reachable only from a branch that sits after an unconditionalreturn, inside a block comment;pclzip.lib.phpstays, andpclerror.lib.phpis kept for its documentedPCLZIP_ERROR_EXTERNALintegration),search/search_event.php(never registered as a search engine in this project's history, and it queried tables belonging to a plugin that is no longer part of the core),e_file_inspector_sqlphar.phpandcli_class.php(each declares a class whose name appears nowhere else and has no autoload entry), andmetaweblog.php(required a plugin that moved to its own repository in December 2020, so it has been a guaranteed fatal since v2.3.1). (#5853) - 4,441 lines of commented-out code across 18 core files. The largest are the version 1 procedural table-check engine in
db_verify_class.php,sc_admin_navigationOld()inadmin_shortcodes.php, the mail form and recipient renderers inmailout_admin_class.php, and the theme upload andrenderOnline()blocks intheme_handler.php. These blocks are a live hazard: automated review tooling and human readers alike keep matching them withgrepand mistaking them for reachable code, which has already produced false vulnerability findings against this project. Removal was mechanical and constrained: only comment runs of twelve lines or more were dropped, each file was rejected unless its live token stream was byte-identical before and after, and every file was then checked withphp -l. Docblocks and explanatory prose are preserved throughout. (#5853) - Deliberately not removed: the emptied stubs.
e107_admin/filemanager.php,e107_admin/fla.php,e107_handlers/emote.php, ande107_web/utilities/*.phpstay exactly as they are. An e107 upgrade extracts over the top and never deletes files, so an empty file in the core is what overwrites a previous version's working code on an upgraded site. Deleting the stub would leave the old code live. (#5853)
Acknowledgments
Thanks to
- @orionhridoy for three private reports in one day: the unauthenticated arbitrary file read (GHSA-87hm-vh32-7c3r), the cross-site request forgery hole (GHSA-72q5-94gw-prww), and the guessable security tokens (GHSA-gm6q-rqm6-p9m4); to
- @allannjuguna for the report of the
e_ajax.phpshortcode batch endpoint (GHSA-wpmq-47ch-x74p). No release ever shipped it in a state where it could run, so it is not a vulnerability in any version of e107, but the report is what prompted the dead-code clearance in this release and the File Inspector entries for the leftovers; and to - @sindizzy for the off-screen sign-in form (#5844), with @tgtje and @Taffman, whose earlier thread in #5581 kept it in view long enough to be traced.
Full changelog: v2.3.9...v2.3.10