[!CAUTION]
v2.3.12 is a security release for sites on v2.3.11 or earlier. Upgrade from any 2.x at or below v2.3.11. If your site tracks the master branch, you are already past v2.3.12, so installing it would be a downgrade. v2.4.x is planned to be the next forward step.
[!IMPORTANT]
Upgrade immediately. This release closes twenty-one security advisories outright and narrows a twenty-second, which needs one setting from you as well as the update.
A forged Host header was reflected into the addresses the site prints. Also needs a setting from you, see below.
The two worst need only a member account.GHSA-f7x7-v438-qmf4 and GHSA-48cx-ccvq-52mc between them reach every field that accepts BBCode: forum posts, comments, profile signatures. An administrator who views that content can install plugins, so the ceiling is a full site takeover.
Several need no account at all, so any internet-facing site could have been reached through them. The three worst are the passwordless sign-in as a social-linked account (GHSA-9gr7-g6pw-5244), the unlimited password guessing (GHSA-vr9h-v6xq-4m35), and the anonymous request that boots e107 as the main administrator (GHSA-pw4f-4544-2mv2). Look through Admin » Users for accounts you do not recognize, and for accounts holding administrator rights that should not.
Two can be narrowed short of upgrading.GHSA-3357-4wp5-w5fp reaches a browser allowed to see the affected contact form. Setting Contact page visibility to Members removes the exposure for signed-out visitors, but a signed-in member or administrator can still be targeted. To narrow it further, set the page to Nobody, and set the bundled contact menu's own visibility to Nobody in Menu Manager or remove it too, since that menu does not read the contact preference. Members-only has shipped since v2.3.2, so an older site is open to guests until someone changes it. GHSA-pf37-7c5m-mpg3 needs an admin account holding the Languages permission, so withdrawing it narrows the exposure. Neither issue can be hidden from the main administrator, who passes every class and permission check.
One is not closed by upgrading.GHSA-w24r-4r8j-vqgc needs a hostname from you, not a code change: a default install has never told e107 which hostnames it answers to, so it builds its addresses from whatever Host header arrives. Set siteurl to your site's full address, or list your hostnames under Trusted Hosts in Admin » Preferences. Either one arms the boot-time host check, so name every hostname you serve on: once e107 knows of one, a request arriving on a hostname you did not name is refused outright. Until you do, and while your site still takes new member registrations, a stranger can have e107 email somebody an account activation link that points at a host they picked.
[!WARNING]
Some of these fixes change behavior you may be relying on. Each is deliberate, and each can stop something that worked in v2.3.11.
Do these before you upgrade.
Check that Trusted Hosts names every hostname you serve on. The boot-time host check now runs, and a request on a hostname you missed is refused outright. An empty list and a relative siteurl cannot lock you out, but a wrong entry in either can, and it takes the admin area with it. The way back in is the database: correct trusted_hosts or siteurl in the SitePrefs row, then delete S_Config_core.cache.php from your e107_system cache directory so the correction is read.
Export the Failed logins list if you keep it as a long-term record. The ban check now prunes that history to thirty days as it runs.
Regenerate your cron token if you have ever sent the Test Email in Admin » Schedule Tasks. That mail carried the token, so treat it as known.
Review the ban list for wildcard entries. An entry such as 10.77.66.* never matched anybody and starts matching after the ban files are next regenerated on v2.3.12. A wildcard whitelist row stays inert, as it was.
List your subdomains under Trusted Hosts if your site spans them. A valid security token no longer overrules a browser that says the request came from somewhere else.
Check Admin » Preferences for the user_tracking setting while it is still there. On a site installed before v2.3.0 where it was never changed, it reads cookie mode, and visitors can keep the old forgeable authentication cookie for up to thirty days, since only an explicit logout expires it. The old cookie can no longer authenticate anyone after upgrading. If you also want every browser to begin a fresh session under a new name, change Cookie Name in Admin » Preferences afterward.
These may behave differently afterward.
State-changing links now need a security token in their address: logging out, rating votes, social sign-in, deleting a private message, marking a forum read, much of the admin area. If logging out stops working, a theme, menu, or template is spelling out index.php?logout by hand and needs updating.
On PHP 8, a plugin that extends e107's routing or controller classes can stop the site loading, every page blank or naming the plugin's class in a Fatal error: Declaration of ... line. Rename that plugin's folder to get the site back, then send its author the one-line fix at For Developers » Changed.
*Five `LANCRONconstants are gone**, and a theme or plugin that prints one bare is a fatal error on PHP 8. If a page dies after upgrading, search your theme and plugin folders forLANCRON`; the five are named at For Developers » Changed.
The "Remember Me" checkbox is gone from the login form, and a browser still holding an old login cookie is signed out.
Eleven BBCode tags now filter or encode whatever a post writes into them.[table] keeps only the presentational attributes the tag was meant for and drops any others.
A news submission is stored through the same BBCode save pass as a comment or a forum post, so a tag's parameters are filtered on the way in and a submitter whose user class may post HTML keeps it, where the form used to strip every tag.
An [img] height, caption, or loading value set in the editor survives the save, where each used to be discarded every time.
Changing your own password in Settings now asks you to confirm it with your current password.
Wrong passwords count toward the automatic ban, and an address that exceeds the failed-login limit is banned for an hour. That hour arrives with Admin » Database » Update; until you run it, a wrong password is recorded and nobody is banned.
The SMTP connection test now needs the main-administrator permission, and the stored SMTP password is masked on the preferences screens.
Scheduled tasks triggered over a URL run as a guest, not as the first administrator. None of e107's own tasks cares; a third-party task that reads who is running it will behave differently.
On Apache, direct web requests into e107_handlers are refused where the server honors e107's .htaccess files. Core serves nothing from that directory directly, but a third-party public asset or endpoint placed there stops being served and must move.
A message sent through the contact form arrives as text, not as markup. A form whose senders were relied on to format their own messages loses that.
The Sign-in plugin no longer loads the Login menu's language file, so a custom signin_template.php written against the old LAN_LOGINMENU_* names loses its labels.
After upgrading, run Admin » Database » Update. This release changes the database: the failed-login ban gains a duration, the generic table gains two indexes, and session rows are re-keyed. Until you run it, those are pending.
On PHP 5.6 or 7.x, also check "Proof that a request came from this site" in Admin » Preferences after upgrading. Saving that screen while the recommended value was unset could silently select Off and stop e107 publishing security tokens. v2.3.12 fixes the form comparison but deliberately does not change a setting already stored as Off.
For Administrators below has the detail on each.
Highlights
Regression fixed: scheduled tasks run over a URL again, the only route most shared hosting panels and every external cron service offer. (#5919)
Regression fixed: avatars and thumbnails are visible again, after v2.3.11 began refusing the malformed image addresses e107 itself had generated for a decade. (#5893)
Busy sites stop serving the occasional broken page, now that every file e107 caches is written whole rather than emptied and then filled. (#5935)
A plugin or theme archive with a .. entry could delete e107_system, taking the cache, the logs, your saved backups, and the ban-list files with it. (#6119)
Private message attachments send again, silently dropped since v2.1.4 in 2017. (#5869)
Admin » Schedule Tasks now tells you how to schedule anything, writing the command, the crontab line, and the URL for you. (#5919)
The core paths covered by the test suite run without PHP 8.5 engine deprecations, including one that could print your server path at the top of every page on PHP 8.4 and later when deprecations were displayed. (#5951, #5952)
For Administrators
Added
Admin » Schedule Tasks has a Setup tab that writes your cron configuration for you. It inspects your server and offers three ways to do it, best first, each beside a Copy button: a web request for an external cron service, the PHP command line with the crontab or schtasks line, and a shell script. The Manage tab reports refused calls and links to Setup. (#5919)
The Download plugin's Local tab takes a typed path as well as a Media Manager pick. A file put in the downloads directory by FTP, which is how a 1.x migration or a restored backup arrives, has no Media Manager row and could not be attached to a download at all. What you type is resolved against the downloads directory, so a path that climbs out or names nothing is refused rather than saved. (#5737)
Changed
The "Remember Me" control is gone, and e107 no longer keeps your login in a browser cookie. The user_tracking preference is gone from Admin » Preferences and the authentication token lives only in the server-side session, so a browser still holding the old cookie is anonymous until its owner signs in again. The checkbox goes even from a site that had the setting switched off, since five of the eight places that drew it never consulted it. (GHSA-7v5h-vrhj-wjf5)
Changing your own password in Settings now goes through the confirmation page. It is the page an email change has always produced, so no theme template changes. Three smaller things changed with it: a replayed confirmation is refused; a submission carrying a validation error saves neither the new password nor an email address sent with it; and a confirmation displaced by a newer change in another tab is refused. An administrator setting somebody else's password is exempt. (GHSA-9qgj-v67f-r22q)
Wrong passwords count toward the automatic ban, and a failed-login ban now expires.ban_durations had no install default, so a failed-login ban was written with no expiry and never lifted. The counter now reads a rolling hour and failed-login bans get a one-hour duration, applied to existing sites once on upgrade and never overruling one you set afterward. The ban lands on the eleventh failure from one address inside the hour, successful sign-ins notwithstanding, and none is raised while that duration is missing or set to Indefinite. (GHSA-vr9h-v6xq-4m35)
Admin » Admin Password stores the password that was typed, and its form carries a security token. The screen compared its two boxes loosely, so 1e3 confirmed 1000, and it stored the first box, locking an administrator out of the account they thought they had set. On a site that allows email login it also stored a hash of the empty string as the email password. Its hand-written form now carries the token every other e107 form does.
The Failed logins list keeps thirty days where the automatic ban is on, and the generic table gains two indexes on upgrade. The ban counter reads that table on every failed login, and had to read every row ever recorded to do so. The check now drops history older than thirty days as it runs. (GHSA-vr9h-v6xq-4m35)
The boot-time host check arms on the hostnames you have declared, not on the shape of siteurl. An operator who listed their hostnames under Trusted Hosts and left siteurl relative was never checked against the list they had just written. (GHSA-w24r-4r8j-vqgc)
A numeric password must now be entered exactly. On a custom page the comparison read two numeric-looking strings as numbers, so 1e3 opened a page whose password was 1000. On PHP 8, where a trailing space no longer stops PHP reading a string as a number, 123 opened it too. Both stop working, and the same tightening applies to a plaintext password held by an alt_auth source. (GHSA-c33m-2hph-47p4)
Scheduled tasks triggered over a URL now run as a guest, not as the first administrator. A shell run is unchanged: user 1, every permission, under the name e107-cli. Over the web the run is an ordinary guest request, so your trusted-host list and the ban check apply; use the site address the Setup tab shows rather than localhost or an IP. (#5919)
A valid security token no longer overrules a browser that says the request came from somewhere else. The recommended setting accepts either proof, the browser's word or the token, and was read as "either will do", so a leaked token was accepted even where the browser had said the request came from another site. A browser that stays silent still falls through to the token. (#5914)
On PHP 5.6 and 7.x, saving Admin » Preferences no longer changes the unset, recommended request-proof setting to Off. Those PHP versions treated the empty option as equal to the option keyed zero, so the form marked both selected and the browser submitted Off. That stopped e107 publishing security tokens even though the administrator had changed no security setting. A site where Off is already stored stays Off until an administrator changes it. (98b4920)
Eleven BBCode tags now filter or encode whatever a post writes into them, instead of copying it into the HTML element unexamined. (GHSA-f7x7-v438-qmf4, GHSA-48cx-ccvq-52mc)
[table] is the one to check after upgrading. It rebuilds the tag from the attributes you wrote, keeping the presentational ones it was meant for and dropping anything else, so a table styled through an unusual attribute falls back to the default.
A link to anything but http, https, ftp, ftps, or mailto now renders as its own words. The old guard looked for javascript: alone and a tab inside the scheme walked past it, so [link] and [url] ask the URL encoder instead; a tel: or sms: link keeps its text and stops being a link.
The affected tags otherwise render as they did for ordinary content.[img], [textarea], and [stream] drop only the offending attribute; [alert] and [block] go through the class-attribute guard the other tags already used, with [alert] falling back to its default styling if its parameter holds whitespace; and [link], [email], [quote], and [flash] encode their values instead of listing them.
The core paths covered by the unit suite are quiet on PHP 8.5. Core code raised engine deprecations in nineteen categories there and on 8.6; the suite now reaches none. One could be visible when deprecations were displayed: on PHP 8.4 and later a Deprecated: Constant E_STRICT is deprecated line appeared twice at the head of every response, carrying your full server path. Scheduled tasks and command-line runs no longer bury their own output in deprecation notices. (#5951, #5952)
The Test Email in Admin » Schedule Tasks no longer mails your cron token. It dumps the server environment to the site administrator's address, and only one of its three tables was filtered. All three are filtered by value as well as by name now. (#5919)
Scheduled runs over HTTP are steadier. The token must match exactly and in constant time, a run is no longer cut off when the caller hangs up or by PHP's time limit, and a refused call is logged once an hour rather than once a call. (#5944)
The Documentation list in the admin area is ordered the same way on every server. It was sorted using the server's language settings, so the order depended on which locales the host had installed. (#5951)
Theme options no longer report "saved" when the write failed. Admin » Theme Manager printed the green success line whatever the preference write returned, so a database refusal produced "Theme options saved" beside "Settings not saved." on one screen. A failed write now says so once, with the reason in Admin » System Logs, and a theme that declares no options no longer forces a write that had nothing to store. (#6018)
Three dozen state-changing links across e107 now carry a security token, and refuse a request without one. e107's cross-site request protection treats only a POST as state-changing, so a link that acts on your site has to protect itself, and most did not. Every link e107 renders carries the token, so the ordinary route is unchanged; a bookmark or a hand-built link shows a refusal instead of acting. The reported case is Database Utilities; the rest is the same audit carried across the product. (GHSA-cg88-cq8v-w6g6)
Logging out is the one most likely to affect you.?logout on any page has been the e107 idiom since v1, so a theme, menu, or template that spells it out by hand leaves the visitor signed in with an error on the page. Both bundled themes and every core logout link were updated.
Rating votes and social sign-in. The caret-separated rate.php ballot and index.php?provider= both acted on a bare address. The address a provider returns to is unchanged, so nothing is re-registered with Facebook or Google.
Private messages, newsletters, and the forum: deleting a message, blocking or unblocking a member, unsubscribing from a newsletter, and marking a forum read. The forum's v1.x upgrade page refuses a tokenless request outright, POST included, and stops deleting six plugin files on every load.
The SMTP connection test now needs the main administrator, where any account holding the mailout permission could run it before. The stored password renders as dots on both preference screens, and leaving the field untouched preserves it.
The core database update page needs a token too, and answers a refusal with 403.e107_admin/e107_update.php reached the update routines from a bare address, so an image tag on a page a main administrator visited flushed the system cache and re-ran every installed plugin's setup file. The page's own form still submits.
Two more paths are confined as hardening rather than fixed vulnerabilities: the Sync with Github route, and the Feature Box legacy renderer, which can no longer include a file outside its own template directory.
A wildcard entry in the ban list matches again.10.77.66.* was stored as typed while the check compares an encoded form of the address, so it sat in the Banlist looking live and stopped nobody. Trailing whole-octet IPv4 wildcards are encoded now, on ban rows alone: a wildcard whitelist row stays inert. It takes effect the next time that screen rewrites the ban files, so open Admin » Users » Banlist and save one entry after upgrading. (#6114)
The Sign-in plugin ships its own language strings. It borrowed twelve constants from the Login menu, a separate plugin, so deleting that plugin from disk took the sign-in form down. Eleven LAN_SIGNIN_* strings replace them with identical text; the twelfth, the password field's label, now comes from core's LAN_PASSWORD and reads "Password" rather than "Password: ". (#5719)
Fixed
[Security] A BBCode parameter could name an HTML attribute, or escape its quotes.[textarea] and [stream] emitted each parameter as an attribute name, where no encoding protects it, so a member could write an event handler that fires for every reader with nothing to click. [img], [alert], and [block] let a parameter escape its quotes to the same end. All five now filter or encode each parameter where they render it, which is the only boundary that reaches content stored before the upgrade. (GHSA-f7x7-v438-qmf4, GHSA-48cx-ccvq-52mc)
The image renderer escapes the attributes it writes. The routine behind [img] pasted id, class, style, title, loading, and the width and height overrides into the tag as given, for every caller and not only the BBCode.
A news submission is saved through the BBCode pass. The Submitted News form stored what was typed without it, so that route needed no bypass at all, and the payload rendered in the admin panel's preview of the queue.
The save-time pass runs on a post that mixes HTML with BBCode.e_parse::isBBcode() answered false for any text carrying something shaped like , so a single tag anywhere in a submission skipped every save-time BBCode filter. It answers on the BBCode alone now. Stored rows are untouched, and the next save of such a post is filtered like any other. (#6265)
[img] keeps a caption and a loading value through the save, as it now does a height, and the caption reaches the reader as it was typed rather than with its apostrophes spelled out.
[Security] Five BBCode tags no longer pass what was typed inside them into the page unescaped.[link], [email], [quote], [table], and [flash] each wrote the text after the tag name, and for [email] and [table] the text between the tags as well, straight into an HTML attribute or an inline script, so a member could attach an event handler, or with [flash] inject an element outright. Ordinary content renders as before; [table] is the exception, above. (GHSA-48cx-ccvq-52mc)
[Security] A login request can no longer select e107's internal OAuth mode, and a profile update can no longer set the field it matches on. The posted autologin field reached the login handler, which read provider out of it as an internal mode. That mode matches on the account's social-provider identifier, a name rather than a secret, and returns success without checking a password: that identifier posted with an empty password signed you in as that account, even with social login switched off. The identifier was member-editable, a second credential no password change revokes. (GHSA-9gr7-g6pw-5244, GHSA-m8v8-wc99-3h82)
[Security] A read of the database no longer forges a working login for every account. The authentication token was the user id and a plain hash of the stored password hash, with no secret mixed in, so reading the user table computed a valid token for any account. In session mode, the default, the session table stored each identifier verbatim, so one read of it was a set of live cookies. Cookie-mode authentication is removed and stored identifiers are hashed. Live sessions survive the upgrade; a browser holding the old authentication cookie is signed out. If you think your database has been read, empty the e107_session table after upgrading, because an identifier captured before it works until its row expires. (GHSA-7v5h-vrhj-wjf5)
[Security] The automatic ban counts wrong passwords. e107 ships the ban switched on with a limit of ten, and it counted an unknown username or an unactivated account but never a wrong password, so guessing the password of an account that exists was neither recorded nor banned on either login form. A whitelisted address was also reported as banned while carrying on signing in, and the row recording an automatic ban was never written at all. With alt_auth installed, a local password that misses and then authorizes through the fallback takes its ban back with it; the member used to be signed in and locked out on their next request. (GHSA-vr9h-v6xq-4m35)
The activation-resend form's separate password check enters the same failure path now. It previously allowed unlimited guesses against an unactivated account without recording or banning them, and a correct guess could move the activation email to an address the visitor supplied. A correct password stored in e107's legacy MD5 format is accepted too; that check had mistaken the replacement hash returned for a valid old password for a failure. (e1097e6, 641d761)
[Security] The language file editor escapes what it writes into the PHP it generates, and writes only inside e107's language, plugin, and theme directories. Admin » Language » Tools pasted request values into that source, so a value carrying a quote could close the statement and append code of its own; the locale was written with no quotes at all. The file is loaded on ordinary page views, so the code ran for every visitor rather than staying behind the admin login, and the path was as open, the file being edited coming from the request. The regenerated header no longer carries the saving administrator's display name, which could end the comment it sat in and run the same way. (GHSA-pf37-7c5m-mpg3)
The editor also reads escaped quotes and trailing whitespace without damaging the phrase. Its reader could stop at an escaped quote or match a closing quote of the wrong kind, so saving the file again could truncate or otherwise change a translation the administrator had not edited. It now matches the quote that opened the phrase and preserves whitespace at the end. (508dc47, f12d76c)
[Security] Signing in changes the session identifier. e107 let an anonymous visitor's identifier become their authenticated one unchanged, so anyone who knew the value beforehand knew a signed-in session's value afterward. On a default install that identifier is the credential, not a handle on one. The only visible difference is that an admin screen left open from before a sign-in or a password change has to be reloaded before its inline edits are accepted again. (GHSA-22m8-h4gh-vfh3)
[Security] A script that declares itself command line only is refused over the web. Neither half of e107's guard for this did its job, so an anonymous web request to the bounced-mail handler booted e107 as the main administrator. From there it appended to a log file under e107_system and overwrote the cached timestamp Admin » Mail reads, once per request, with nothing to stop it. The caller could not supply the message text, so it could not mark anyone's mail as bounced or make the site send mail. (GHSA-pw4f-4544-2mv2)
[Security] Changing your password in Settings requires the current password. Settings withheld an email or login-name change until the holder re-entered their current password; the password change itself never asked. Anyone holding a live session could set a password they knew, lock the owner out, then satisfy the email confirmation with the password they had just set, and sending both changes in one request waived that confirmation too. The prompt now applies on every site, including those with no email login and those on the oldest password hashing, where the old checks never fired. (GHSA-9qgj-v67f-r22q)
[Security] A member could write any column on their own user row, including the ones that make an account an administrator. The confirmation stage that guards an email change sent the pending change out to the browser and rebuilt it from whatever came back, and both checks on that round trip did nothing; one had never worked since 2016. A member who knew only their own password could post that stage directly, name any set of columns, and be saved as written, without going near the settings form. (GHSA-pq2m-9gxf-64x9)
[Security] The user manager applies the rules that protect administrator accounts on every route that writes them. Admin » Users refuses to rewrite an administrator's account without the "Modify Admin perms" permission, and refuses to ban the main administrator at all. Neither applied on the batch route, nor on the six single-row controls any user-management permission could reach by posting the trigger: Ban, Unban, Delete, Verify, Reqverify, and Resend. A delegated administrator could ban or delete the main administrator, sign another one out everywhere, or, where the signup password option is empty, have Resend replace their password; unbanning does not restore a working login. All seven now answer to both rules. (GHSA-hwmh-m57x-w5h2)
The user export leaves out the password hash and the session key. Ticking rows on Admin » Users and choosing Export wrote both columns into the file, and the export is exempt from the administrator rule because it only reads.
[Security] Database Utilities asks a state-changing link to prove where it came from. Every mode was dispatched straight off the query string, behind the main-administrator check alone, and the six that act needed nothing more. A main administrator who loaded an attacker's page ran, from something as quiet as an image tag, an optimize over every table, a system cache flush that re-executed every plugin's setup file, a recursive permissions change, a registry rebuild, and a full database dump. (GHSA-cg88-cq8v-w6g6)
[Security] A member can only vote once on a star rating. The duplicate-voter check was broken twice over: the first person to vote on an item could re-vote without limit, and everyone else could bank one vote per rating value. A single member could drive a displayed average anywhere they liked, the widget rendering read-only while the endpoint behind it went on accepting votes. Tallies already inflated stay inflated. (GHSA-f2q2-hchc-h3wx)
[Security] Authentication values are compared byte for byte and in constant time. About one MD5 digest in 340 million takes a form PHP reads as the number zero, and where the server computed one of those the check then accepted any value PHP also read as zero, so there was nothing for an attacker to compute. Every comparison in core that guards a login or a stored credential now compares byte by byte. (GHSA-c33m-2hph-47p4)
[Security] The site's own addresses were built from the Host header the visitor sent. e107 puts them in its canonical link, its OpenGraph tags and its resource URLs, and the check that should have refused an unrecognized hostname almost never ran. It now runs whenever you have told e107 a hostname of its own; if you never have, the note at the top of these release notes says what to set, because upgrading alone does not close this for you. (GHSA-w24r-4r8j-vqgc)
[Security] A contact form open to guests no longer hands posted values back to the page as markup. The message a visitor typed was written into the form's text box as it arrived, the email address into an attribute it could break out of, and the assembled page was parsed again, so a shortcode typed into any field was expanded too. An install from v2.3.2 or later did not expose signed-out visitors by default, because Contact Form Visibility has shipped set to Members since then; signed-in members and administrators who could see the form remained exposed. An install from an earlier release is open to guests unless someone changed it: the preference shipped as everyone from 2015 to 2022, did not exist before that, is treated as public when absent, and no upgrade sets it. The bundled contact menu shows the same fields and does not read that preference, so set its own visibility in Menu Manager or take it out. (GHSA-3357-4wp5-w5fp)
[Security] Saving the Online menu configuration no longer files your session's security token as a preference. The screen copied every field the browser posted onto the shared menu preference row, and since v2.3.10 e107 injects a hidden security token into every form it renders. Each save stored the live token and wrote it into Admin » System Logs, where any administrator who can read the log could take it. (#6084)
[Security] The File Inspector no longer writes its scan identifier into the page unchecked. The identifier arrived from the address bar and went into an HTML attribute as it stood, so a link sent to an administrator could run script in their session. Only administrators who can reach the File Inspector were exposed, and only by opening the link. (GHSA-396r-g8m8-w9xx)
[Security] Deleting a private message, or changing your block list, asks the request to prove where it came from. Four addresses acted on a bare link, so a page a member merely visited could empty their inbox or rewrite who they had blocked. Marking a message read stays a plain link, deliberately: the one in notification mail depends on it. (GHSA-8xcj-7wmw-r939)
[Security] The member typeahead answers only where the member list is public. Two lookups, one in Private Messaging and one in core, replied to anyone who asked, and a keyword of punctuation matched everything, so a single request returned every account on the site, banned and unvalidated ones included. (GHSA-72mc-5q6j-2fqq)
[Security] Submitting or editing an FAQ requires the permission the form checks. The FAQ plugin tested the permission when it drew the form and not when it saved, so anyone could post entries straight to the handler. Sites that never installed the FAQ plugin were not affected. (GHSA-qj68-pgg7-hf3p)
[Security] Switching comments off on an item now stops comments on it. The rule lived in only one of the two paths that save a comment, so the other accepted posts on news items, custom pages, downloads and profiles whose comments were closed. (GHSA-vhf5-8cfh-jr22)
Regressions from v2.3.11.
Regression fixed: scheduled tasks can be triggered over a URL again. v2.3.11 refused every call that arrived over the web, so a crontab that had worked for years ran nothing while logging a refusal a minute. (#5919)
Regression fixed: thumbnails and avatars resolve again. e107 has always rendered thumbnail addresses that the request layer then mangles, and v2.3.11's stricter thumbnailer turned a silent fallback into a visible 403 Bad URL. (#5893)
Regression fixed: outbound requests can reach the second address of a hostname. v2.3.11 pinned each fetch to the first resolved address, so on a server without cURL a dual-stack hostname whose IPv6 address sorted first failed outright. (#6054)
Regression fixed: sending an attachment no longer walks every attachment directory on the site. v2.3.11 made every private message with an attachment, and every forum post whose form drew the field, re-check thousands of member directories for no gain. (#6160)
A busy site no longer serves half-written cache files. e107 wrote each cached file by emptying it and then filling it, so a request that read one in between got an empty or partial file. Which cache lost the race decided the symptom, which is why this has been reported for years as unrelated one-off glitches. Every cache e107 rewrites on a live request now goes through a temporary file and a rename, and the page-block cache's separate read-side race was narrowed with it. (#5935, #5917)
A plugin or theme archive with a .. entry no longer deletes e107_system. Uploading a zip whose first entry sits above the root made the unpacker resolve its destination to the parent of e107's temporary directory and remove it recursively, taking the cache, the logs, the saved backups, the import folder, and the ban-list files. Four upload failures that all reported "Couldn't detect the root folder in the zip" now name their reason. (#6119)
Menus, navigation, and site links.
The Login menu is working again, in five ways. Saving its configuration no longer empties the shared menu preference row, which had been reverting the banner, comment, online, last-seen, and forum menus to their defaults since 2014. Additional Links and plugin statistics are offered again, absent since December 2020. The screen shows what you just saved, the statistics sit inside the menu rather than beside it, and the username box has its placeholder back on username-only sites. (#6044, #6048, #6069, #6083, #6096)
Navigation built from site links could exhaust PHP's memory and take the page down. The site links a fresh install creates were enough to make a renderer call itself with the menu it was drawing, so a theme using {SITELINKS} or {SITELINKS_ALT} went blank. Neither bundled theme does. (#6065)
A site link keeps the address you saved when its search-engine-friendly route cannot be built. Six places overwrote it with a generated address that a plugin missing from the URL registry does not have, so the link pointed at the site root and its admin field came up blank. Each of the six now falls back to the address on the row. (#5783)
A navigation menu pointing at an empty link category draws nothing instead of an empty box. A default install ships links in two categories only, so a menu placed anywhere else is the common case. (#6112)
The News Grid menu's Featured setting takes effect. It was saved under a field name one letter short of the one the renderer reads. Existing menus keep the old key, so save each once in Menu Manager after upgrading. (#5955)
A Feature Box menu renders again when its category preference is empty. The plugin seeds one category name and the code fell back to a different one, so the menu asked for a category that did not exist. (#5957)
The Menu Manager opens again on a theme whose layout uses {FEATUREBOX}. Its preview printed a name only the Feature Box plugin defines, which is fatal on PHP 8 when that plugin is inactive. (#5956)
Two more link fixes. A plugin installed from the plugin manager lands in the same URL state as one installed at setup; and the bundled _blank sample plugin's navbar link points at the page it ships rather than a file it does not contain. (#5790, #6224)
The front end.
Login, signup, search, user settings, password reset, the members-only page and the Users Online menu render on a theme that supplies its own templates. e107 chose between the old and new template shapes by reading a constant the loader does not set, so a theme's own template was passed over and the page fell back to core markup or drew nothing at all. (#6017)
The Users Online page shows who is online. It blanked its own template variables before loading the template, so the page drew a heading over nothing, as it had since 2019. Its profile links are built through the URL handler now. (#6108, #6107)
The member list shows members again, under both URL configurations. The record count arrived as zero, so every page reported that the site has no registered members, and the paging bar divided by that zero. (#6002)
A mistyped address answers with the error page instead of a fatal error.e_PAGE is defined after the 404 page has been rendered, so a plugin's shortcode file read a constant that did not exist yet and every unknown URL ended in a fatal on PHP 8. (#6024)
The news page survives a junk ?page= value, which was an uncaught error on PHP 8, so a scanner or a mistyped link took the page down. Only sites whose News pagination preference was changed are reached. (#6110)
[email] and [link] survive the "Make URLs clickable" setting. The clickable pass ran before the BBCodes and handed them an anchor where they expected an address, so [link] lost the address and [email] produced a link inside a link. It runs after them now. (#5954)
Tabs switch again on a Bootstrap 4 theme. Bootstrap renamed the state class in version 4 and the data attributes in version 5, and e107 applied one boundary to the other. Both spellings travel on the same element now. (#5990)
The forum, private messages, the chatbox, and the contact form.
Private message attachments are sent again. The attachment branch was gated on a field the compose form has never posted, so since v2.1.4 the file was uploaded, the message was delivered without it, and neither party was told. (#5869)
A private message notification email names the member who sent it. It took the name from whoever the current request belonged to, so one sent from a scheduled task named e107-cli or nobody. (#5944)
The chatbox stays on the page you were reading. Its form actions were built from the underlying PHP script, so posting from a search-engine-friendly page landed the visitor on /news.php. (#5614)
The AJAX chatbox validates before it posts. Pressing Send bypassed the browser's own check on the message box, so an empty post made a wasted round trip. (#5616)
The first click on a smiley inserts it. The emote panels in the chatbox and the private message composer did nothing until the visitor had clicked into the message box first. (#5613)
A message sent through the contact form carries the sender's address. It traveled only as a Reply-To header, which many mail clients never show and which is lost when the message is forwarded. The From address stays the site's own, so the site's mail still passes SPF. (#5980)
The contact form stops drawing a second CAPTCHA over a plugin's own. e107 looked for the field name its own renderer emits, which a replacement does not use. (#6014)
The SMF importer files JPEG and GIF attachments as pictures. One condition tested the same extension twice, so everything but PNG and one spelling of JPEG arrived as a download link. WebP is accepted too, and an import already run is not reclassified. (#6134)
The forum's Quick Reply button says "Post a quick reply", and Post Reply keeps what you typed. With the Rich Text Editor selected the two read alike, and clicking the wrong one dropped the text; it is carried into the full reply form now. (#5647)
A theme that defines only some forum icons gets the rest from the plugin. The loader was all or nothing, so a partial forum_icons_template.php left about two dozen IMAGE_* names undefined, which is fatal on PHP 8. Such a theme now renders the plugin's default icon where it drew nothing. (#6209)
A forum attachment is refused when the deny rule over its directory cannot be written. Four places stored the bytes whatever the answer, so on a host that refuses the guard file the upload landed with nothing but its random name protecting it, and nothing was logged. (#6171)
A post that pastes a YouTube player keeps its text. The [youtube] save path for a pasted embed replaced the post with a placeholder, printed its parse into the save response, and dropped the privacy-domain setting; a pasted player now round-trips. (#6265)
The admin area.
Editing a member's user classes from the list no longer deletes one. The tick list held only the classes the column was configured to offer, and saving the cell wrote the ticked boxes over the whole field. Admin » Users omits the built-in Members class while Quick Add adds it to every account, so that combination silently dropped Members. (#5779)
Editing user classes from the Users list obeys the rules about which classes you may manage. Only the dedicated Set user class screen refused a class whose manager class the administrator does not hold; the inline pencil editor, the full edit form, the batch menu and Quick Add User did not check at all. All four apply the rule now, refusing and logging the attempt. The main administrator is unaffected, and a class you may not manage is no longer dropped by saving the editor.
The "Add All" and "Clear All" user-class batches work for administrators other than the main one. The shared guard refused every class rather than granting it, so "Add All" answered Update failed and did nothing, while "Clear All" read the empty list as no list at all and emptied the whole User Class column on every ticked row. Both now apply the classes you may manage and name the ones they left out.
Deleting a preference no longer takes the front end down with no way back. Removing url_config made every address carrying a path fail on PHP 8 while the front page kept answering. Admin » Database » Update puts the default back. (#5928)
A settings screen that cannot save now tells you why instead of ending in an error page. The screens that save this way include URLs, User Classes, Ban List, Emoticons, Meta Tags, Notifications, Search, and Themes. (#5921)
Saving ban messages or durations, and removing expired bans, no longer end in a blank page. Three calls in Admin » Users » Banlist went to a logging helper deleted in 2020, so each died after the work had been committed. (#6051)
An empty database statement is refused and reported instead of ending the request. The one place on this branch that can produce one is the character set conversion tool in Database Utilities. (#5904)
A failed database operation reports its reason again. Six live assignments spelled the two error-message properties differently from the way the class declares them, so a backup or a row copy that declined reported nothing at all. (#5951)
Admin » URL Configuration loads again on a site with the Gallery plugin installed. Its address profile read labels from the plugin's own language file and never loaded it, which is fatal on PHP 8. The alternative rewrite profile still misses that file, so a site using it will still fail to load Admin » URL Configuration » Settings. (#5917)
Previewing a theme no longer takes the theme manager down. Labels were read from the theme the request was rendering in rather than the one on screen, so the wrong language file loaded and the page was fatal on PHP 8. (#5996)
A failed "Sync with Github" shows the reason instead of a blank page, and gets three times as long to download. These archives run to tens of megabytes, and the old 40 second budget demanded a sustained rate many connections cannot hold. (#5893, #5620)
Outbound requests stop refusing hosts your server can reach. The guard resolved hostnames with PHP's own DNS functions, which query the network directly rather than asking the operating system, and refused the address when nothing came back. (#6012)
Admin > Schedule Tasks > Setup no longer fills the PHP Errors panel on a host with open_basedir set. It probes absolute paths for a control panel and a PHP binary, most of them outside every allowed prefix on such a host. (#5991)
The PHP Errors panel stops reporting diagnostics core deliberately silenced. Anything a caller had silenced with @ was collected and printed anyway, which the atomic cache write made visible: every ordinary cache miss began reporting a failed stat. (#5992)
Admin > Users > Avatars deletes the file it names, and measures the right limit. It passed a thumb.php address to the delete and to the size check, so a checked image was cleared from the account and left on disk while every avatar counted as missing, and the height report read the width preference. (#6023)
The Credits page no longer restyles the rest of the admin area. It registered four bare element rules, for body, p, a, and a:hover, into the shared inline stylesheet the admin header emits. (#5981)
For Developers
Added
e_parse::toJsString() encodes a value for a JavaScript string literal. It returns the quoted literal, its own quotes included, so interpolate the result bare. Quotes, apostrophes, angle brackets, and ampersands become hex escapes, so it is safe inside an HTML attribute carrying script too. Encoding failure returns an empty string literal. (5b27db3)
e107::writeFileAtomic($file, $data, $mode = null) writes a file so a concurrent reader gets the old contents or the new, never a partial one. It writes through tempnam() in the target's own directory and rename()s into place, falling back to file_put_contents() where either step is impossible, so a true return means the file holds the data, not that the write was atomic. Overwriting resets mode and owner, and the read side is uncovered. (#5935)
e_form::copyable() renders a block of text with a Copy button. It takes the text and an optional label, and emits its own script and styles once per page. New LAN_EFORM_COPY and LAN_EFORM_COPIED constants back it. (#5944)
e107\Reflection\ReflectionProperty and e107\Reflection\ReflectionMethod make private members readable across the whole supported PHP range. PHP 8.1 stopped requiring setAccessible() and 8.5 deprecates the now-empty call, while 5.6 through 8.0 still require it, so these two subclasses make the call themselves only where it is needed. (#5951)
Changed
Nullable type hints are gone from eleven core method signatures, and a plugin that overrides five of them with the old signature will fatal on PHP 8.eRequest $request = null is the only nullable spelling PHP 5.6 accepts and the one PHP 8.4 deprecates, so it had to go. The five that fatal are eFront::dispatch(), eDispatcher::dispatch(), eController::run(), eUrlConfig::parse(), and e_session::fetchMetadataReachesUs(). The fix is one line: delete the eRequest, eRouter, eResponse, or array hint from the override. Watch eUrlConfig::parse(), which a plugin shipping its own url/url.php overrides. (#5951, #5930)
The engine deprecations the unit suite raised on PHP 8.5 and 8.6 are cleared, one commit per deprecation. Nineteen categories, from dynamic properties and (integer) casts through setAccessible(), implicitly nullable parameters, E_STRICT, and strptime(). The suite does not reach everything the tree holds. Two are replacements rather than respellings: Latin-1 conversion goes through iconv() alone, and e_parse::cleanHtml() now substitutes ? for invalid UTF-8 where it produced U+FFFD. (#5951)
Dynamic properties are gone from four places, and two of them a third party could be leaning on.e107plugin::execute_plugin_method() no longer assigns version_from onto a plugin's setup class, which should call e107::getPlugin(), and e107::__get() memoizes into a static array, so $e107->tp after e107::destruct() returns the object rather than null. Six classes now declare the properties their constructors assign, so a subclass declaring one with narrower visibility fails with an access-level error. (#5951)
Cookie-mode authentication is gone, and five login shortcodes with it.LOGIN_TABLE_REMEMBERME, LOGIN_TABLE_AUTOLOGIN, and LOGIN_TABLE_AUTOLOGIN_LAN go from the core login form; {LM_REMEMBERME} and {SIGNIN_REMEMBERME} go from the Login menu and Sign-in plugins. An unresolved shortcode renders empty; the language constants stay defined, since a removed constant printed bare is fatal on PHP 8. The user_tracking row is no longer seeded on a fresh install, so third-party code reading it without varset() warns there; on an upgraded site it survives reading cookie, so code branching on it takes the branch for a feature that is gone. session_set() keeps its value in the session and ignores its $expire, $path, $domain, and $secure arguments, so a value parked across visits is lost when the session ends. e_session_db::_sanitize() is now static, which is fatal for a subclass that overrides it as an instance method. Session rows are re-keyed to a digest of the identifier, which raises a pending core update on essentially every site. (GHSA-7v5h-vrhj-wjf5)
userlogin::login() no longer honors 'provider' in its $autologin argument, and user_xup is out of the member-editable field list. The provider mode travels on an instance flag only userlogin::loginProvider() sets, and class2.php casts the posted value to an integer, so no mode can be selected from a request. The documented 'signup' value still works, and social signup and login write user_xup server-side without consulting the field list. (GHSA-9gr7-g6pw-5244, GHSA-m8v8-wc99-3h82)
The user-settings confirmation form no longer round-trips the pending change through the browser.updated_data, updated_key, updated_extended, extended_key, and the private getValidationKey() in usersettings.php are gone; the change is held in the session and the form carries an opaque handle. A confirmation the server is no longer holding anything for now says so instead of reporting success, and the confirmed values are no longer passed through filter(..., 'str'), which had been double-encoding plain fields and flattening cleanHtml() output on rich-text ones. UserHandler::hasReadonlyField() has also been repaired. It answered false for any field set it could not iterate, which is the one answer its own documentation promises never to give; it now reads an array or a Traversable, refuses anything else rather than reporting no restricted field, and recognizes a list of field names as well as a keyed set. user_class remains writable, because user settings already filters it through the classes the member is allowed to edit. (GHSA-pq2m-9gxf-64x9)
An admin list batch must name a field the screen declares batchable.e_admin_controller_ui::_handleListBatch() asked only that the posted column be a declared field; it now also requires the 'batch' flag the dropdown is built from, and refuses a field declared 'data' => false. A plugin admin UI that posts a batch trigger for a field it never marked 'batch' => true now silently does nothing. (#6074)
An entry script that sets $_E107['cli'] is refused when the request arrived over HTTP. The shared guard tests the shape of the request, as cron.php already did, rather than a User-Agent header and the debug flag. The debug clause is deleted rather than corrected: nothing has read e107_config.php that early. (GHSA-pw4f-4544-2mv2)
Apache is also told to refuse every direct request into e107_handlers. Core fetches nothing from that directory over the web, so a default installation loses nothing. A third-party public asset or endpoint placed there stops being served on a host that honors e107's .htaccess files and must move elsewhere. The PHP bootstrap guard above remains the protection on servers that ignore the file. (064476a)
A scheduled task must no longer assume who is running it.cronScheduler's docblock states the contract: a task runs as the first administrator from the command line and as a guest over HTTP, so it must not read ADMIN, USERID, USERNAME, or USERCLASS_LIST, and must not assume e107::redirect() is a no-op. cronScheduler::run() now takes a $via argument, so a subclass overriding run() with no parameters fails to load; nothing in the tree does. (#5944)
*Five `LANCRONconstants and one help file are gone, and about forty constants are new.**LAN_CRON_13,LAN_CRON_14,LAN_CRON_15,LAN_CRON_16, andLAN_CRON_60go, withe107_languages/English/admin/help/cron.php`. A language pack that translates the old names needs updating, and a theme or plugin that prints any of the five bare is a fatal error on PHP 8. (#5944)
Three places that turn stored or posted text into a filename or an identifier are confined. (804197f)
A shortcode name is checked before it is used as a filename. Names must match ^[a-z0-9_]+$, and one that does not is left in the page as written. Members reach this parser, because toEmail() turns shortcode parsing on by default.
The plugin builder no longer takes identifiers for generated code from posted keys. Identifiers must look like identifiers or they are dropped, and values go through var_export().
email.php narrows its plugin source parameter, as print.php already did, differing only in that it also permits a hyphen.
e_jsmanager encodes every asset URL it prints and packs its registry through one method. A path or media value registered by a caller reached a or attribute unencoded, and one carrying the #|# separator the registry joins on could shift the fields of its own record. Reaching either needs a permission that already grants code execution, so this is hardening rather than an advisory. (cceda40, ac2f645)
Two hardening changes, neither of which alters a public signature. The legacy CRUD methods (select(), count(), delete(), fields(), insert(), update(), replace(), db_UpdateArray(), db_FieldList()) return false, without throwing, and record error number -1 for a table name outside [A-Za-z0-9_]+, on both drivers, so a plugin that passed a dotted db.table or a backticked name now gets false. And users_admin_ui::beforeUpdate() filters user_class through checkAllowed(), so every route into the model applies the userclass_editclass rule, vetting a withdrawal like an assignment.
e_db_pdo::close() now releases the connection, and the last result set with it. A PDOStatement holds a reference to its connection, so nulling the handle left the server connection alive whenever a query had run. Nothing fetched before close() is readable after it. The mysqli driver already behaved this way. (#5935)
e_pref::save() honors its own "no messages" argument. Both error branches printed the raw mySQL error #NNNN on screen whatever the caller asked for. Around two dozen core callers pass false, so a failed preference write in the update routines, the plugin handler, Menu Manager, Search, or the URL configuration now reports to Admin > Logs. (#6018)
A theme configuration field absent from the submission is stored as an empty value of its own type.null used to be stored, which is invisible to isset(), so a theme calling count() or in_array() on getThemePref('x') took the page down on PHP 8. A field the form posts as name[] now stores an empty array, everything else an empty string. If your theme relied on getThemePref('x', 'fallback') returning the fallback for an unticked box, it now gets ''. A theme's values also save into its own preference row. (#5995, #6036, #6133)
The test suite has separate dependency locks for newer and older PHP releases. The single pinned lock could not be installed above PHP 8.3, so from 8.4 upward the suite never reached its first test. The default lock is resolved for PHP 8.1.33 and carries Codeception 5.2.2 with PHPUnit 10.5.64; the PHP 7.4 and 5.6 locks carry Codeception 4.2.2 with PHPUnit 9.6.36 and 5.7.27 respectively. The harness installs the newest committed lock its PHP can satisfy. (#5950)
Continuous integration and the test harness were reworked, and the Code Climate integration is gone. The unit workflow's hand-rolled provisioning is replaced by e107-tests up and e107-tests ci-unit, so a CI failure reproduces locally with those two commands. The unit matrix no longer measures coverage nothing consumed, which was most of its wall clock: the PHP 5.6 cell falls from about sixteen minutes to two. (#5884, #6180, #6221, #5940, #6270)
Test-suite changes worth knowing if you run it.AdminConfirmTokenTest is gone: it matched source against a registry pinned by file and line, so unrelated edits failed it, and 0038_AdminConfirmTokenCest covers the same ground against the real forms. The entry-script sweep now runs each script in its own process and fails on a warning or a notice. themeHandlerTest stops leaving theme preferences behind, which had been failing a later case in a shuffled run, and .gitignore now covers the e107_config.php.bak the harness writes while a test site is up, which a broad git add could stage with the database password in it. (a6e54a1, #5917, #6228)
Fixed
Eight reads of array keys that need not be present are guarded, clearing the PHP 8 warnings they raised. They sit in the search preferences, File Inspector, Mailout, the user model, the page address builder, and Admin > Language > Tools. Nothing rendered changes, and none of it was visible on a default site: e107's own error handler discards warnings, so these surfaced only with ?debug= set. (#5886, #5917, #6109)
Four more reads that are fatal on PHP 8 rather than merely noisy.sitelinks::get() called count() on a missing submenu bucket in link display mode 3; e_parse::toAvatar() multiplied an empty string when the documented hd option was used with no explicit height; four forum templates read IMAGE_post2 and IMAGE_e as barewords at include time; and language::$_select_array was read before it was declared. None is reachable from core or a shipped theme. (#6073, #6060, #6086, #6085)
e_db::getLastErrorNumber() returns a MySQL error number on the PDO driver. It returned PDOException::getCode(), the SQLSTATE: '23000' where the mysqli driver returns 1062. PDO is the shipped default, so every caller comparing that value against a MySQL error code failed silently on nearly every install. The Feature Box admin screen is the demonstrated casualty: its duplicate-key branch never ran. Four more failure paths now record a number as well as the text. (#5993, #6040)
e_file::isValidURL() connects to the address its own policy check passed. It asked the outbound policy about the URL, then handed the URL as typed to fopen(), so the stream wrapper resolved the name again and connected wherever that answer pointed. Every other outbound path in the class already pins. The one-second budget now travels in the request context, and the status line is parsed as a code rather than searched as a substring. (#6027)
A suite run on a fresh checkout no longer dies before its first test. Anything printed before e107 loads sends the response headers, after which its own ini_set() calls on the session settings cannot take effect. The harness now generates Codeception's actor classes in its own process, and the suite bootstrap turns off display_errors before e107 boots. Bringing an existing environment up with different flags either takes effect or stops and tells you to recreate it, and ci-unit checks whether the running PHP has xdebug loaded rather than trusting a label. (#5884, #5950, #5937)
Acknowledgments
Thanks to
@orionhridoy for reporting eighteen of the twenty-two advisories, each with a working proof of concept, the affected files named, and an honest account of what the attack does and does not require: the two BBCode attribute cross-site scripting holes, the contact form that handed posted values back to the page as markup, the passwordless sign-in as a social-linked account, the second way in that a password change does not revoke, the login forged from one read of the database, the automatic ban that never counted a wrong password, the loose comparisons in the login paths, the session identifier that signing in left unchanged, the password change that needed no password, the database operations a page ran for an administrator who merely looked at it, the star rating one member could vote on over and over, and the forged Host header reflected into the site's addresses; and, in a second batch on September 5, the File Inspector link that ran script in an administrator's session, the private messages a page could delete, the member typeahead that answered anyone who asked, the FAQ entries that needed no account, and the comments accepted where they had been switched off; to
@3ng1 for reporting the language file editor code injection, with a working proof of concept, a correct root cause, and an explicit note on what the issue was not; to
@archnexus707 and, independently and six days later, @orionhridoy, for the member who could write any column on their own user row, including the two that make an account an administrator; both identified the broken guard on the confirmation stage exactly; to
@Jimmi08, whose reports run through this whole release: the unfetchable thumbnails and the blank "Sync with Github" page (#5893); the front end taken down by a deleted preference (#5928), which arrived with the fix worked out; the News Grid Featured count (#5955), the Menu Manager killed by {FEATUREBOX} (#5956), the Feature Box menu that drew nothing (#5957), the PDO driver reporting a SQLSTATE (#5993), the theme manager reading another theme's language file (#5996), the member list reporting no members (#6002), the Login menu's missing plugin statistics (#6044) and its statistics rendering outside the menu (#6048), the theme options that reported "saved" either way (#6018), and the Users Online page empty since 2019 (#6108) with its hand-built profile links (#6107). Her chatbox fork also identified the emote panel's dead first click (#5613) and the form actions that break search-engine-friendly URLs (#5614, #5616); to
@Alex-e107nl for scheduled tasks refusing every call after v2.3.11 (#5919), the error on saving the URL configuration page (#5921), the update that could not complete (#5904), the Credits page restyling the whole admin area (#5981), the open_basedir warnings filling Schedule Tasks > Setup (#5991), the silenced cache diagnostics reaching the PHP Errors panel (#5992), the contact form drawing a second CAPTCHA over a plugin's own (#6014), the ban durations and failed logins that could not be saved (#6051), the missing username placeholder (#6096), and the news page killed by a junk ?page= value (#6110); to
@BillyBoy0823 for the site link that lost part of its address after a sign-in setting changed (#5783), the user classes that did not survive an inline edit (#5779), and the two forum reply buttons that carried the same label (#5647); to
@sindizzy for the navigation menu that painted an empty box rather than saying it had no links to show (#6112); to
@tgtje for the undefined keys on the language pack verifier (#6109); to
@darkdi for the pull request that fixed the SMF importer's duplicated extension test, and for adding WebP to it (#6134); to
@rica-carv for the screenshot, posted in somebody else's issue, that turned out to be a second and unrelated fault: every unknown address on the site ended in a fatal error rather than the error page, tracked as #6024 (#6002); and to
@Kanonimpresor for confirming the broken avatars on the released v2.3.11 (#5893).
[!CAUTION]
v2.3.11 is a security release for sites on v2.3.10 or earlier.Upgrade from any 2.x at or below v2.3.10. If your site tracks the master branch, you are already past v2.3.11, so installing it would be a downgrade. v2.4.x is planned to be the next forward step.
[!IMPORTANT]
Upgrade immediately. v2.3.11 is the largest security release e107 has published to date. It closes a chain that let a visitor with no account run code on the server, a path from a signed-in member to arbitrary PHP on disk on any site that lets members choose their own theme, several endpoints an anonymous request could abuse, restricted forum and download content being served to anonymous feed readers, an open redirect, and a set of places where untrusted content was written into a page without being encoded for it.
Forty-four advisories accompany this release. Forty-one are new. Three are already-published advisories being amended in place, because each was fixed incompletely the first time: GHSA-3j33-c9v4-4p42, GHSA-5w63-63rh-99q6 and GHSA-92fr-7h4f-22pp. If you upgraded for one of those, you were not covered and are not covered until you upgrade again.
Updated September 2026. These advisories were first published as seventeen, several of which covered more than one vulnerability. A CVE identifies a single vulnerability, so they have been split until each advisory covers one. Nothing about the fixes changed. GHSA-87hm-vh32-7c3r, which was briefly amended for this release, is back to the download handler it reported, which v2.3.10 fixed; the two thumbnail endpoints it had been extended to are advisories of their own below.
On a site that allows anonymous forum posting, a visitor with no account owned every anonymous post, and deleting one could delete any file on the server.
e107 did not check TLS certificates on its own outbound requests, so an attacker on the network could swap the plugin, theme, or update being installed.
Any user-management administrator could reset the password of every pending registration.
You must upgrade to mitigate these risks.
[!WARNING]
Some of these fixes change behavior you may be relying on. Read For Administrators » Changed before you upgrade, in particular the notes on outbound requests over TLS, redirects that leave your site, and the legacy e107_images/thumb.php endpoint. Each one is a deliberate secure-by-default choice, and each one can stop something working that worked in older versions of e107.
Highlights
[Security] Rollup to fix numerous security vulnerabilities. See the table above for a summary, the entries below for more details, or the GHSA links for the full write-ups.
Regression fixed: the previous release refused submissions from pages that were never given a security token. Its new token rule reached the installer, the menu manager frame, media uploads, error pages and AJAX fragments, none of which were handed a token to send. A browser still running the previous release's cached JavaScript kept posting without one for as long as that file stayed cached. (https://github.com/e107inc/e107/discussions/5858)
The menu manager no longer carries the code patterns antivirus products match on. It decoded Base64 out of a query string and ran the result through eval(), which is indistinguishable from a web shell to a scanner. Hosts were emptying the file rather than quarantining it, which took the whole site down. (https://github.com/e107inc/e107/discussions/5863, https://github.com/e107inc/e107/discussions/5873)
For Administrators
Changed
Signing in to the admin area now starts at the admin login page. A guest who requests an admin page used to have the login form rendered in place, at the address they asked for. Because that happened after the page had already read and acted on the request, the login now happens one step earlier: the guest is sent to the admin area's front door instead. Where you land after signing in has not changed: the admin login has always returned you to the admin front page rather than to the page you originally asked for, so a bookmark that goes through a login needed the same extra click in v2.3.10. Being returned to the page you were trying to reach, rather than to the dashboard, is already implemented on the development line and will arrive with e107 v2.4. (https://github.com/e107inc/e107/security/advisories/GHSA-376g-2pcx-p4x8)
A redirect that leaves your site is refused unless e107 knows it is meant to. Anything that took a destination from the visitor, a return address, a jump target, or a query string, could be pointed at any website in the world and would send the visitor there from your domain, which is a ready-made phishing page. Redirects that are the site's own decision, such as the marketplace, a banner click-through, an external download mirror, or a social sign-in, still work. Two consequences are worth knowing before you upgrade. (https://github.com/e107inc/e107/security/advisories/GHSA-jxxm-5qpx-h42q)
If your site answers on more than one hostname, list them under trusted hosts. A multilanguage install using parked domains or subdomains, or a site that serves assets from a second hostname, moves visitors between its own hosts. The list of hosts e107 will move a visitor to is built too early in the boot to consult anything else, so any host other than the one being served has to be named in Admin Area » Site Preferences » Site Information » Trusted Hosts. A host that is not listed sends the visitor to your front page instead.
A third-party plugin that redirects off your site will stop doing so. It will send the visitor to the front page and write a line to the PHP error log saying which destination was refused. That is deliberate: the alternative was to fix the handful of places found today and wait for the next one to be reported.
email.php now actually redirects. For years it sent a malformed Location: header, so a visitor who was not permitted to email an item was served an empty page rather than being sent to the front page. It sends the visitor to the front page now, which is what it always meant to do, and which is observable if you were relying on the blank page. (https://github.com/e107inc/e107/security/advisories/GHSA-jxxm-5qpx-h42q)
Outbound requests that e107 makes itself now verify the other end's TLS certificate. e107 fetches from the marketplace, from newsfeeds, from plugin and theme downloads, and from anything a plugin asks it to fetch through the core, and it used to turn certificate verification off on every one of those paths. It no longer does, on either the cURL path or the fallback. This is the largest compatibility cost in the release: a site that fetches from a host with a self-signed or otherwise unverifiable certificate stops fetching from it. There is no setting to turn this back off. (https://github.com/e107inc/e107/security/advisories/GHSA-w5f3-6v5g-4mm4)
The legacy e107_images/thumb.php endpoint is now a translating shim. Nothing in e107 has called it since the 0.8 series, but upgrades never delete files, so it ships a replacement rather than being left as it was. It now translates the request it understands into one the modern thumbnailer serves, and refuses the shapes that were the vulnerability: an absolute path, a path containing .., and a source carrying a scheme://. The size selector that used to hand a small source back untouched is accepted and ignored, so a source narrower than the size you asked for comes back through the modern thumbnailer rather than as the original bytes. A thumbnail it does serve may also differ byte for byte from before, because the modern endpoint encodes to the Thumbnail quality preference, 75 by default, rather than the legacy endpoint's 99. (https://github.com/e107inc/e107/security/advisories/GHSA-wq9c-9fw4-3fg2)
The security-token setting now asks which proof a request must bring, not just how strict to be about a missing token. The previous release accepted only a security token, and a token protects a page only if that page was actually handed one. Sec-Fetch-Site, which the browser sets and no attacker's page can forge, is now an accepted alternative, so a page that never got a token is no longer an outage. Admin Area » Site Preferences » Site Information, directly below Trusted Hosts, now offers e107's recommendation (the default, and what every site that has never touched this setting gets), token or same-site, same-site only, same-origin only, token required, token optional and logged, and off. The three settings the previous release shipped keep exactly the meaning they had, so nothing already stored changes behavior. (https://github.com/e107inc/e107/discussions/5858)
Reporting a forum post now answers to your flood-control setting. The report form fired an email to the moderators with no throttle of any kind, so anyone who could post, including a guest on a forum that allows anonymous posting, could use your mail server to send as fast as they could script it. Reports are now rate limited per reporter, with guests sharing one bucket, and admins are exempt exactly as they are for posting. (https://github.com/e107inc/e107/commit/dd18b46de016a680188051cacaf7285a45efb0c8)
Deleting your own forum post now follows the rules the forum always advertised. The post must be the last in its thread, must not be the post that opened the thread, and the thread must still be open. The control that offers the button promised all three; the server checked only that the thread was still open. This narrows what a member may do, but only to what e107 already told them they could do. Editing is not affected by these three rules: an author may still edit their opening post, and a post that is not the last one. (https://github.com/e107inc/e107/pull/5862)
Legacy shortcode batch files are deprecated and confined. The old parse_scbatch() interface would load a batch from anywhere PHP can read, including remote and wrapper addresses, and the result is executed. It now only accepts a file inside the plugin, theme, or core directories, and every call raises a deprecation notice. Callers pass their own filename, which is what the interface was for, so this should be invisible; if a plugin on your site loads a batch from elsewhere, it will stop. (https://github.com/e107inc/e107/pull/5868)
{TOTAL_FORUMPOSTS} now reports posts, and the number it shows will get bigger. It counted rows in the topics table while naming itself for posts, and cached that count under a key two other shortcodes fill with a genuine post count. Whichever of them rendered first decided the number for the rest of the page, so a member with 21 of 42 posts could render 50% or 300% on the same page with nothing about the site having changed. All three now count posts. If you display this figure anywhere, expect it to rise on any forum where anyone has ever replied, because replies were never being counted. (https://github.com/e107inc/e107/commit/5ca793085c452f8e4d168d6179bfb31fe1b9c592)
A fresh install no longer publishes four dead social links.default_install.xml seeded the four social URLs with #, and neither guard in the social shortcode skipped that value, so every new e107 site put four footer links in its theme that open a blank tab and announce a destination they do not have. The placeholders are gone from the core and from the voux theme, and the shortcode refuses a # destination even if one is already stored. (https://github.com/e107inc/e107/commit/a10f513e97f6facc9c8a830b0e231b23fbc86bc3)
Every admin page acted on a request before checking who made it. The page built its dispatcher, which read the request and ran the controller's setup and its form triggers, and only afterwards required the file that authenticates the caller. This is the ordering all 39 core admin pages use, and the ordering the plugin scaffold generator emits, so every admin page built from the wizard inherited it. The check now happens in the dispatcher itself, before anything reads the request, so third-party admin pages are covered without their authors touching a line. (https://github.com/e107inc/e107/security/advisories/GHSA-376g-2pcx-p4x8)
The media manager page was reachable and would write core preferences. Its permission check exempted the file picker entirely, and its setup routine read settings straight out of the request and saved them with no action test, no token, and no permission test, which is how the image path preference could be set by someone who was not signed in. The picker now requires an authenticated administrator in every case, and the preference writer, which nothing could legitimately reach, is gone rather than gated. (https://github.com/e107inc/e107/security/advisories/GHSA-376g-2pcx-p4x8, https://github.com/e107inc/e107/security/advisories/GHSA-9747-hf3v-cvcv)
The image resizer pasted a site preference into a shell command. Every other value on those two lines was already escaped; the ImageMagick path was not, so whatever that preference held was interpreted by /bin/sh. It is escaped now. This is the one change in the chain that also protects a site whose preference was poisoned before the patch, because it makes an already-stored value inert. A path containing spaces starts working as a side effect. (https://github.com/e107inc/e107/security/advisories/GHSA-3j33-c9v4-4p42)
[Security] A shortcode batch loaded from a request was unauthenticated remote code execution.parse_scbatch() handed its filename to a function that accepts every stream wrapper PHP offers, and the lines it returns are executed later by the template parser, so nothing dangerous appeared to happen at the point of the call. The core's own copy of this endpoint was removed some time ago, but at least one third-party plugin copied the pattern and repaired the broken bootstrap that had been keeping the core's copy inert. (https://github.com/e107inc/e107/pull/5868)
[Security] Any signed-in member could reach the theme manager from the front end. On a site running the bundled User Theme menu, whose Class which can select themes setting admits them, posting a theme change constructed the theme manager, whose setup acts on theme upload, plugin installation, Git pull, content installation, style submission, and menu presets with no permission check of any kind. The only thing resembling a guard was defeated by a precedence bug that made it always pass. The front-end theme picker still works; everything else now requires the permission to manage themes. A fresh install does not configure that menu, so the sites affected are those where an administrator set it up, or where a plugin or a direct database edit set the preference. (https://github.com/e107inc/e107/security/advisories/GHSA-vgr2-p8wp-crr2)
[Security] An administrator holding only a delegated user-management permission could promote themselves to full administrator. The user manager took the permissions field straight out of the submitted form and wrote it to the account, with nothing asking whether the person submitting it held the permission to grant permissions. The trigger now refuses a permissions field from a caller who may not grant one, and the per-route permission map the file has described in a comment since it was written is declared for the add, preferences and maintenance routes. The list and edit routes are still covered only by the single permission test at the top of the file, which the code now records as an open item rather than describing a map it does not have. (https://github.com/e107inc/e107/security/advisories/GHSA-8h49-xpqr-224j)
The same route rewrote a superior administrator's password and email address, which is an account takeover rather than an escalation. Editing an account that already holds administrator status now needs the permission to change administrator permissions. (https://github.com/e107inc/e107/security/advisories/GHSA-w85q-mcvf-7q5v)
The page's batch actions would write any field the request named, not only the ones the page offers for batch editing, so a batch could set the permissions or the password of any account. In the user manager, a batch can now only write the fields the page declares batchable. (https://github.com/e107inc/e107/security/advisories/GHSA-4crv-cpjw-pm8h)
A permission an admin page declared for one route could be bypassed by spelling the route differently, for example in capitals. This is what would have made the new route map worth nothing, and it applies to every admin page that declares one. (https://github.com/e107inc/e107/security/advisories/GHSA-h4jq-w26j-r3h2)
[Security] The "confirm your identity" guard on several admin forms never fired. It was written in a way PHP reads as comparing a true-or-false value against a 32-character string, which is false for any non-empty submission, so the guard's exit never ran and posting any value at all walked straight past it. The guard is now a real comparison. It was never worth much, and it is documented as what it is: a check that a submission came from a form this account was served, not a forgery check and emphatically not a permission check. (https://github.com/e107inc/e107/security/advisories/GHSA-vgr2-p8wp-crr2)
Restricting a forum through the admin interface did nothing on a site with content caching switched on. The admin page asked the cache to clear the wrong set of entries, so the entries the reader actually writes stayed on disk until something else happened to clear them.
The tracked-topics listing returned exactly one row whatever you were tracking, because the loop ran a second query on the same database handle and ended the stream it was reading from.
[Security] Forum subscriptions kept mailing post bodies to people who could no longer read the forum. The notification selected every subscription row for a thread and mailed the post to each address without asking whether the recipient could still open the forum, so subscriptions taken out before permissions were tightened, and members removed from a class, went on receiving the contents. Recipients are now checked against both the forum's and the category's user class, and a row that fails is deleted as it is found. A subscription with no valid address attached no longer produces a recipient at all. (https://github.com/e107inc/e107/security/advisories/GHSA-q3vc-vvpg-4397)
A moderator of any one forum could moderate every forum on the site, by three independent routes, deleting, locking, unlocking, sticking, and unsticking threads and posts anywhere. The moderator list was cached per forum object regardless of which forum was being asked about, and the page being viewed had already fixed the answer before any write was authorized. Moving a thread was worse still: only the origin was checked, so a moderator could push a thread into a forum they have no rights over. (https://github.com/e107inc/e107/security/advisories/GHSA-2pqf-36x9-m46r)
"Your own post" meant "any post with no owner". The check compared the caller's user id against the post's author and nothing else. A visitor with no account has user id 0, and an anonymous post is stored with author 0, so on a site that allows anonymous posting every passing stranger owned every anonymous post on the site, including in forums they could not read. The same identity confusion sat in the editing check, so a guest could rewrite another guest's post. (https://github.com/e107inc/e107/security/advisories/GHSA-p4vr-pjjc-cvrc)
Deleting your own post could delete any file the web server could reach. The attachment list was written straight out of the submitted form with no validation at all, and the delete pasted each stored entry onto the poster's attachment directory and removed the result. Post an ordinary reply carrying a relative path, delete it, and the file is gone. Demonstrated end to end through the real forms, with e107_config.php as the obvious target, which leaves a site believing it is not installed. The same loop was also broken for genuine attachments, which were never actually deleted and were orphaned instead. (https://github.com/e107inc/e107/security/advisories/GHSA-x6mx-9j79-rg8x)
A quick reply could be filed in a thread you cannot open. The permission question was asked about one identifier from the request and the reply was filed under a different one, so naming any forum you may post in bought a reply in any thread on the site. Closed threads were not consulted at all, although the ordinary posting form has refused them for as long as it has existed. (https://github.com/e107inc/e107/security/advisories/GHSA-v55m-8jrf-77g3)
Subscribing to a thread was a way of reading it. The subscribe action asked only whether you were signed in, and the notification mails out the full text of every later reply, so a subscription to a thread you cannot open was a standing feed of its contents. Subscribing now answers to the same view permission the forum's own pages do. (https://github.com/e107inc/e107/security/advisories/GHSA-q3vc-vvpg-4397)
The new-threads listing offered no user class filter at all, alone among the listings the plugin ships. It could not print what it fetched, for a separate reason fixed alongside it, but the query should not be the thing standing between a member and a closed forum.
[Security] Forum post attachments in a restricted forum were fetchable by anyone. They are written into a publicly served directory, with no random component in the name and no rule stopping the web server handing them over, so the attachments of a post in a restricted forum could be fetched by anyone who could guess or had once seen the name. The serving route that resolves a file back to its post now applies the same check the thread itself uses, and install and upgrade write a deny rule across the attachment directories so that the raw path stops answering. Attachments in public forums keep rendering, because the templates have always linked through the serving route rather than the raw path. The deny rule is an Apache mechanism: on a server that ignores .htaccess, nginx in particular, raw paths keep working, and there the random component in the name is all that stands in the way. New attachments gain that component; renaming the files already on disk is a migration and is not in this release. Separately, the upload itself did not check the permission to attach files, so any member who could post could attach files even where you had turned attachments off. (https://github.com/e107inc/e107/security/advisories/GHSA-m2vw-pp79-82rx, https://github.com/e107inc/e107/security/advisories/GHSA-gfh5-w9r2-3546)
The download route never asked who was calling. It compared the requested filename against the one recorded on the message, which answers whether the file belongs to the message, not whether you are allowed to see the message. Message IDs are sequential, so counting through them walked the attachments of every conversation on the site. The caller must now be the sender or the recipient, which is the check the message view itself has applied for years.
The web server was handing the files out directly. Attachments live under a publicly served directory and e107 shipped no rule saying otherwise, so closing the route was necessary and not sufficient. A deny rule is now written when an attachment is stored, and install and upgrade also sweep the directories a site already holds. This is an Apache mechanism; a server that ignores .htaccess, nginx in particular, is not protected by it, and there the random component in the filename is all that is left. That component was widened from four digits to sixteen hexadecimal characters, which is the last place in the tree a security-bearing value came from a weak random source.
A member could send a message as somebody else. The insert took its row from the submitted form wholesale, so the sender field could simply be posted.
The Google sitemap endpoint let a request name the method to call, filtered only by whether a method of that name exists, which made every public method on every installed plugin's sitemap class anonymously invocable. It is an allow-list now. (https://github.com/e107inc/e107/security/advisories/GHSA-74pv-g3g9-86c2)
The cron entry point could be run over the web. Its guard tested the PHP server API name against apache, which is Apache 1's; every modern configuration reports something else and walked straight through. It now asks whether this is a web request rather than which binary is running, because tightening the list would have broken cron on cPanel and DirectAdmin, where the command-line PHP is frequently a CGI build.
The cron failure email dumped the server environment, so anyone who could make a cron job fail could have your environment variables, secrets included, emailed to your administrator address on demand. That content is gone and the mail is rate limited, where it used to be one message per failure per run. (https://github.com/e107inc/e107/security/advisories/GHSA-q4fp-j8j4-hmjj)
The current thumbnailer took a path and re-encoded whatever it found there. It refused a handful of address schemes by name and did nothing else, so an absolute path, a traversal, or a path constant smuggled in through the encoded identifier parameter all read files the site never meant to publish. It is now contained to the directories that actually hold site images, checked against fully resolved paths. The scheme deny-list became an allow-list, because "is this one of the five schemes we thought of" is the wrong question. The encoded parameter is now read through the same sanitizer as every other parameter. (https://github.com/e107inc/e107/security/advisories/GHSA-rfq3-mc44-648g)
The legacy e107_images/thumb.php served the file untouched when the request selected its noscale mode, which is an unauthenticated arbitrary file read with no resizing involved at all. See Changed above for what replaces it. (https://github.com/e107inc/e107/security/advisories/GHSA-wq9c-9fw4-3fg2)
Restricted media library items were being re-encoded for anyone who asked. The download handler checks a media item's user class; the thumbnailer did not, so the two endpoints disagreed about who may read the same bytes. Containment cannot fix that, because the media directory has to stay a permitted thumbnail root or every avatar stops rendering, so the thumbnailer now looks the file up and applies its user class before it encodes anything. A file with no library entry is unrestricted, which keeps theme and plugin images working. (https://github.com/e107inc/e107/security/advisories/GHSA-wmqr-5mw9-hx7x)
Three more problems in the same endpoint: the width and height parameters were unbounded, and a large pair is a memory exhaustion with no account needed; the type parameter became a cache filename extension unchecked; and the error handler printed the PHP error message, the file and line it came from, and the PHP and operating system versions to unauthenticated callers. Dimensions are clamped, type is checked against a list, and errors are logged and answered with an opaque 500.
[Security] A redirect that took its destination from the request could be pointed off your site. The forum's jump control filtered its destination into a variable and then passed the unfiltered request value to the redirector, so an anonymous visitor could send anyone anywhere, from your domain. The call site was the smaller half of the problem: the redirector itself validated nothing, so every caller passing request data was an open redirect and the forum jump was simply the one that got found. Destinations are now read the way a browser reads them, because a browser deletes tabs and line breaks from an address before it looks for a hostname, and an earlier version of this fix was still bypassable with a single tab character. (https://github.com/e107inc/e107/security/advisories/GHSA-jxxm-5qpx-h42q)
The sharpest one needs no account at all: an anonymous visitor's browser identification string was written into an attribute on the admin dashboard, so the attacker is a stranger and the victim is an administrator. (https://github.com/e107inc/e107/security/advisories/GHSA-p2p8-9jwc-8985)
Two of these stored finished markup rather than data, so a site poisoned before the patch would have gone on serving the stored bytes indefinitely, even after the hostile feed was gone. They store data and compose at display time now. The dashboard's add-ons panel caches its markup for three hours, and its cache tag changes with this release so that an upgraded site is not served three more hours of the old markup.
[Security] The TinyMCE parser endpoint declared itself an admin area and checked nothing. It is the only file in the core or the bundled plugins that claims to be an admin area and has no permission check of any kind, no sign-in test and no permission test, and it is reachable on every install whether or not TinyMCE is installed or configured as your editor. Its sibling dialog file has exactly the check it was missing. Also closed here: the editor's main-administrator check was a text comparison that a crafted parameter defeated, and its path containment did not treat a backslash as a separator, so a Windows-style traversal passed through intact. (https://github.com/e107inc/e107/security/advisories/GHSA-w5qr-xwc7-hq3r, https://github.com/e107inc/e107/security/advisories/GHSA-mpqj-pp9q-7jjf)
[Security] Any signed-in member could still edit anyone's comment. The published fix added an author check to the route the AJAX editor uses, and the legacy submit route has its own update statement a few lines away that was left alone. The same check applies to both routes now, along with the moderator exception the interface already assumes exists, and the comment being edited is derived from the request the same way on both routes so they cannot disagree about which comment is meant. (https://github.com/e107inc/e107/security/advisories/GHSA-5w63-63rh-99q6)
[Security] An outbound request was checked once and then allowed to follow a redirect anywhere. The address was validated as it was typed and the request was then handed to cURL with redirect-following switched on, so a host that passed the check could answer with a redirect to any address at all, including one inside your private network, and nothing looked again. The redirect chain is now walked by hand with every hop checked, capped at a maximum number of hops, and the validated addresses are pinned so that the name cannot be resolved a second time to somewhere else. Three sibling routes that had their own copies of the download loop with no checks at all now go through the same policy. Two limits belong in the open: on an install that defines an outbound proxy the address policy is advisory only, because the proxy does the resolving; and the mail validation handler does its own name resolution and connection with no policy, which is admin-only and whose pre-authentication call site is commented out, but it is the same defect in a sibling file. (https://github.com/e107inc/e107/security/advisories/GHSA-92fr-7h4f-22pp)
Regression fixed: the previous release refused ordinary form submissions across the site. Requiring a security token is sound, and a token protects a page only if that page was actually handed one. Several core documents never were, so every write issued from them became an outage rather than a safeguard. (https://github.com/e107inc/e107/discussions/5858)
A brand-new site ended its install on "Unauthorized access!". The installer's last step closed with a button that posts to the front page, and the installer renders its own markup so that post carried no token. Nothing was being submitted at that point, so the step now hands over with a link instead, and drops the installer's own cookie on the way out.
Returning visitors kept running the script from before the upgrade. Stylesheet addresses carried a cache-busting parameter and script addresses never did, and e107's own server configuration asks browsers to keep JavaScript for a month. So a returning visitor ran the old script, which has no code to send a token, against a server that had begun to insist on one. Script addresses now change with the release, so an upgrade clears the cache by itself.
Every menu manager write was refused, even on a browser that had never seen the site. The menu manager renders its working area in a frame that goes through neither the admin header nor the front-end header, which is where the core registers the script that sends the token, so the frame received a token and none of the code that sends it.
Four more ways a legitimate submission lost its token: drag-and-drop uploads drive their own request that the token sender never sees, so every admin image and media field refused a dropped file; the error page suppressed the minting of the session's first token and then published the empty result, so a visitor whose first request of a session was a dead link was refused thereafter; the routine that stamps tokens into pages had no notion of tag boundaries, so a news item containing something like
switched injection off for every form after it while the page still rendered perfectly; and AJAX list fragments never reached the point where a page is given its token, so filtering a list and then using a batch action was refused.
The menu manager was being emptied by host malware scanners.e107_handlers/menumanager_class.php carried the classic backdoor fingerprint, a base64 decode of a request parameter fed into the superglobals, and eval() on the contents of a theme file, and e107_admin/menus.php carried the decode half as well. Scanners match on exactly that, and one that cleans a file rather than quarantining it leaves nothing behind, which takes the site down with an error naming a file that has nothing to do with the fault. The core is not doing the truncating; the core was holding the pattern that invites it. All three patterns are gone: the links are ordinary query strings, the legacy theme layouts are read by a parser instead of being executed, and the failure now names the class that is actually missing. Every theme that takes this path was checked to report the same templates and menu areas as before. (https://github.com/e107inc/e107/discussions/5863, https://github.com/e107inc/e107/discussions/5873)
The first time an administrator saved Preferences on a fresh install, the contact form was silently switched off.default_install.xml declared seven preferences twice, and the importer resolves a duplicate by letting the last one win. For the contact recipient setting the losing value was the one that works: on PHP 8 the surviving value did not match any option in the dropdown, so the dropdown preselected its first entry, which is "nobody", and the preferences page saves every field whether or not you touched it. This is a plausible source of "my contact form disappeared", and it is install-time only. The upgrade repairs the stored preference where it still holds a non-numeric leftover, but it cannot tell a deliberate "nobody" from one this bug produced, so if your contact form went quiet, set the recipient again after upgrading. (https://github.com/e107inc/e107/commit/a10f513e97f6facc9c8a830b0e231b23fbc86bc3)
A forum's "last post" pointed at the wrong thread, and reply counts were wrong. The recalculation read when a thread was started rather than when its last post happened, and ordered by it, so removing a single spam post could point a busy forum at a thread nobody had touched in years, dated to that thread's birth and credited to whoever had posted in it most recently. Separately, the reply count stored a raw row count where every reader expects the opening post to be excluded, so splitting a topic left both halves one reply heavy and produced a page of results that is not there. (https://github.com/e107inc/e107/pull/5862)
"Mark all forums read" did nothing. The branch meaning "all of them" tested for the identifier being zero rather than being absent, so the board-wide link took the per-forum path with an empty list, matched nothing, and redirected having done nothing. Every other link carries an identifier, which is why only that one was affected. (https://github.com/e107inc/e107/pull/5862)
Clicking Cancel on "delete this thread?" deleted the thread. The confirmation dialog stopped the event reaching parent elements, which does not stop another handler on the same element, and the forum's own script binds one and binds it first. The forum now asks the question itself and stops the event dead on both answers, and the core's confirmation handler was hardened the same way, which closes it for every other confirmation link in e107. Alongside it: one click sent more than one request, because the elements were filtered with a one-time event binder where a run-once marker was meant, and there is no unique key on the subscription table to stop the duplicate rows or the duplicate notification email; any forum action on a page carrying the editor for something else threw before the request was made; and a request that failed said nothing anywhere, which looks exactly like a click that never fired. (https://github.com/e107inc/e107/pull/5862)
The forum's AJAX replies did not match what happened. A duplicate reply reported success, so the reply slid into the page looking accepted and was gone on the next refresh. An empty reply reported nothing at all, because the response was only assembled on the way through the success path. And moderation swallowed every other AJAX request on the page: the forum answered any request from a moderator with a forum response, so a moderator's poll vote, rating, or plugin widget on a forum page came back as a forum error while the same click by an ordinary member went through, which reads as a permissions fault and is not one. (https://github.com/e107inc/e107/pull/5862)
The new-topics listing rendered as an empty table, and was a fatal error for guests. The rows on that page are threads, and a version 2 theme was handing them to the template written for forum rows, whose placeholders resolve against nothing. The listing now has a section of its own, in the version 2 style, read with merging on, so a theme whose template predates this falls back to the plugin's and one that overrides part of it keeps the rest. The fatal error for anyone without an account, and for every crawler, came from a constant that is only defined for signed-in visitors being read bare, and the already-read filter compared thread identifiers against forum identifiers, so reading one thread hid every thread in whichever forum happened to carry that number. (https://github.com/e107inc/e107/pull/5862)
Using quick reply broke every other form on the page. The forum rotated the session's security token after a reply, which buys nothing, since that token is a session-lifetime secret shared by every form rather than a one-time value, and it invalidated the page's token, every form on it, the track button, and the moderator links, none of which the reply's own response can reach. The sharpest case was an empty reply: the reply is skipped, the token is rotated anyway, and nothing comes back to write anywhere. The rotation is gone, and the reply now always returns the current token. (https://github.com/e107inc/e107/pull/5859)
A language file that failed halfway through was remembered as having loaded. The three routines that load language files marked the file loaded before including it. PHP records a file as included the moment it starts reading it, so an include that stops partway is never retried, and the note saying it had loaded meant every later caller was turned away. One error in one translation therefore removed every phrase below that point for the rest of the request, and the page rendered with pieces missing rather than failing. Which pieces went missing moved with the order things were drawn in, which is what made it so hard to report. (https://github.com/e107inc/e107/commit/6a28dc2ff49b8aee57985034ff1d616c3ed173b8)
The forum statistics page was a fatal error on a forum where nobody had replied yet. Each member's share of the replies is divided by the total number of replies, which is the post count less the topic count, and that is zero on a forum whose threads have no answers. On PHP 8 dividing by it takes the page down completely. (https://github.com/e107inc/e107/commit/9263a8396c89df4ee1752fee19934f508acfb3b8)
The forum statistics page filled the error log on every ordinary forum. The top-repliers table walks one result set and reaches into a second, sparser one without checking. A member who has only ever replied has never opened a thread and so has no row in the second, and a post whose author has since been deleted has no row either. The arithmetic happened to come out right, so the table has always looked correct while writing a warning per member, and printing into the middle of itself on a site with error display turned on. (https://github.com/e107inc/e107/commit/e4dcefda1b36646992fbf0934c23696bc78330b6)
{USER_FORUMPER} showed 0% for everyone but the first member on the page. The member's own post tally was worked out inside the block that computes the site-wide total once and caches it, but read outside that block. The first member on a page filled the cache and got the right answer; every member after them took the cache-hit path, where the tally had never been set, and divided nothing by the total. (https://github.com/e107inc/e107/commit/cdca499c37679a92032f28141a64521738d3d3fe)
The first {LANGUAGELINKS} on a page decided the shape of all the rest. Its two options were stored in constants, and a constant cannot be changed once set, so a theme carrying the shortcode in a language menu and again in the footer got one of the two wrong, depending on which was drawn first. Both are read per call now. Code that sets the constants beforehand to pick the defaults still works, which is what the original comment promised; an option on the call itself now wins. (https://github.com/e107inc/e107/commit/12603981ed56b9307cbd48763cf7a3aeb41a5b6a)
A member record that had been discarded could never be reloaded. The routine that clears a loaded member from the registry wrote a key one character different from the one that stores it, so it cleared something nothing had ever set. From that point every lookup of that member for the rest of the request was handed the emptied record: no name, no email, no user classes. (https://github.com/e107inc/e107/commit/1b6d3b264a7bf957d1040c58da40999ae92b8d8f)
Only the first email template asked for in a request could be found. The template file was read with require_once, which does nothing at all the second time it is asked for a file, so the variables the template defines were not there and the mailout reported the template as missing. Each file is still read once; what it defines is now kept and read from there. (https://github.com/e107inc/e107/commit/ef1de0a49bb3c518ef98acd7825fc47a426abd64)
The release check announced a new version through a handler it had never fetched. The message handler was picked up inside the branch that reads the cached answer, and that branch returns immediately, so the path that actually queries the release feed and finds something newer called a method on nothing. Nothing in e107 reaches this today, so it is latent rather than live, but the handler can be driven by a plugin. (https://github.com/e107inc/e107/commit/aaa75e2d0a9b7ea5669d17c13ade086a26095738)
For Developers
Added
e_theme_layout_parser reads the layouts out of a pre-v2.2.2 theme.php by tokenizing the source and folding the assignments, replacing a chain of regular expressions feeding eval(). It covers what real themes use: string literals, heredocs and nowdocs, array literals, element assignment, and concatenation with constants. Assignments inside a function or class body are left alone, matching what eval() did, and an expression that will not fold keeps its raw source as the value, so a theme doing something clever still lists its layouts. (https://github.com/e107inc/e107/discussions/5863)
redirect_class::goOffsite() is how a caller states that a destination deliberately leaves the site. go() takes the permit as a fifth argument, and writing that out at the call site states nothing: an author who copies it and drops an argument turns off cache prevention rather than turning on the permit. (https://github.com/e107inc/e107/security/advisories/GHSA-jxxm-5qpx-h42q)
e_user_model::checkAdminPwchangeToken() replaces the hand-written ADMINPWCHANGE comparisons. Its documentation is explicit about how little the check is worth: the value is an MD5 of a timestamp, it is the MD5 of '0' for an administrator who has never changed their password, and the MD5 of the empty string for anyone who is not an administrator at all. It confirms a submission came from a form this account was served. It is not a forgery check, and it is not a permission check. (https://github.com/e107inc/e107/security/advisories/GHSA-vgr2-p8wp-crr2)
e107_plugins/forum/forum_attachments.php holds the forum's attachment rules in one place: which forums a caller may read, which post a given attachment hangs off, and where the attachment directories are. forum_class::sendFile() asks it before serving, so an attachment in a restricted forum is refused, and one that no post accounts for is refused rather than served as an orphan. It also writes the deny rules that stop the web server answering for the raw path. (https://github.com/e107inc/e107/security/advisories/GHSA-m2vw-pp79-82rx)
Changed
$tp->toHTML() is not an output encoder, and its documentation now says so.'scripts' is true in its default option set and it decodes entities inside script blocks, so passing remote or visitor-supplied content through it re-emits verbatim and undoes any encoding applied earlier. It is also not context aware: the result is a fragment of HTML, not an attribute value, not a URL, and not JavaScript. Several sinks in the tree were relying on it as an encoder. For visitor content, pass a USER_* context, which strips scripts; for an attribute use toAttribute(); for a URL encode for the URL. (https://github.com/e107inc/e107/security/advisories/GHSA-jv3q-35qg-rr53)
e_admin_dispatcher::__construct() authenticates before it does anything else, ahead of runObservers() and the controller's init(), and ahead of requiring boot.php. It does not key on e_ADMIN_AREA, because for plugins that constant is a filename heuristic and a third-party page named settings.php would keep the whole window. The opt-out is a protected $requireAuth property on the dispatcher, which cannot be set from a theme or an e_module.php. AJAX requests are answered by e_jshelper::sendAjaxError() with a 403 rather than being redirected. hasRouteAccess() now canonicalizes the route and mode before it looks them up, because a route can be spelled several ways and a declared permission map could previously be bypassed by spelling it differently. (https://github.com/e107inc/e107/security/advisories/GHSA-376g-2pcx-p4x8, https://github.com/e107inc/e107/security/advisories/GHSA-h4jq-w26j-r3h2)
redirect_class::go() refuses an off-site destination by default. The predicate compares the host rather than prefixing the string, honors the trusted_hosts preference, treats any scheme other than HTTP and HTTPS as leaving, and normalizes its input the way a URL parser does before testing it, so the string that is tested is the string that is emitted. verifyDestinationUrl() shares that normalization. A refusal is written to the PHP error log, because go() runs too early in the boot to reach the logging subsystem. (https://github.com/e107inc/e107/security/advisories/GHSA-jxxm-5qpx-h42q)
csrf_enforce now names which proof a request must bring. The modes are a menu rather than a ladder: TOKEN_CHECK_OFF (0), TOKEN_CHECK_LOG (1), TOKEN_CHECK_ENFORCE (2), CSRF_CHECK_TOKEN_OR_SAME_SITE (3), CSRF_CHECK_SAME_SITE (4), and CSRF_CHECK_SAME_ORIGIN (5). An unset, empty, or out-of-range value resolves through CSRF_CHECK_RECOMMENDED, which currently points at 3, deliberately indirect so that the recommendation can be raised in a later release without every operator having to act. It points at 3 rather than 4 because an upgrade cannot ask the operator's browser what it supports. 'same-site' is honored only where Origin names a host this site serves, so a user-content subdomain or one that has been taken over does not vouch for a request. In modes 0, 4 and 5 e_token_injector stops injecting, because a token nothing checks is not merely wasted work: it is a live session token stamped into every same-origin form on the page, including one an author or an attacker put in a news item. Three ad-hoc guards that demanded or faked a token had to go with it, in comment.php, comment_class.php, and forum_class.php. The token guards on the state-changing GET routes in plugin.php, theme.php, and language.php stay. (https://github.com/e107inc/e107/discussions/5858)
The new preference wording uses new language keys.PRFLAN_294 through PRFLAN_298 shipped in the previous release describing a setting with three choices, and they are restored to exactly what shipped rather than being reworded in place, because rewording a key that has already gone out leaves every translation silently wrong with nothing signaling that it needs revisiting. The new wording takes PRFLAN_305 onwards; the gap at 299 to 304 belongs to a master preference, so a key means the same thing on both branches. (https://github.com/e107inc/e107/discussions/5858)
Core asset URLs carry a cache-busting parameter derived from the version.e_jsmanager::url() appends the cache identifier only when its flag is exactly true, and the script branch passed the string 'js', so stylesheets always got a parameter and scripts never did. The identifier is now paired with a hash of e_VERSION, so every core asset address changes on upgrade rather than only when an administrator empties the cache. It is hashed because an asset address is public and the exact release is not worth publishing on every page. (https://github.com/e107inc/e107/discussions/5858)
parse_scbatch() raises E_USER_DEPRECATED on every call, and file mode is confined to e_PLUGIN, e_THEME, and e_CORE. The path is canonicalized through e_file::resolveSendPath(), which fails for every stream wrapper, and the roots deliberately omit media, uploads, and system so that an upload can never become an executable batch. A refused path returns an empty array, which is already what an unreadable path returned. Its documentation now records the trust contract, the deferred eval(), and the class-based batch API that superseded it in 2.0. Callers that pass __FILE__, which is what the interface was for, are unaffected. (https://github.com/e107inc/e107/pull/5868)
resize_image() loses its 'stdout' surface. The legacy thumbnailer was the only caller; the two other apparent callers were already dead, one requiring the handler without calling it and one inside a comment block. mimeFromFilename() is deprecated rather than removed, because third-party plugins require this handler directly and that is the only place it can still be called from. resize_method is now constrained to the three backends that exist, defaulting to gd2, where an unrecognized value used to print Invalid resize function and return false instead of producing an image. (https://github.com/e107inc/e107/security/advisories/GHSA-wq9c-9fw4-3fg2, https://github.com/e107inc/e107/security/advisories/GHSA-9747-hf3v-cvcv)
The menu manager's links are ordinary query strings.?enc= decoded back through parse_str($string, $_GET) is gone, replaced with http_build_query(), which also encodes the class and custom-pages values properly where the old raw concatenation did not. The gate that decides whether a request belongs to the menu manager now tests the two parameters those links carry, which is the same set of requests. No decoder is kept for old links, deliberately: keeping the decode next to the eval() is what scanners match on. (https://github.com/e107inc/e107/discussions/5863)
e_file::protectDirectory() is called when a private message attachment is written, as well as from install and upgrade, so a directory created by a restore, or on a site whose plugin predates this, is covered the first time anything is written to it rather than only by a hook that has already run. It deliberately does not live inside e_file::getUserDir(), because the forum reaches that same helper and applies its own protection from its own setup. (https://github.com/e107inc/e107/security/advisories/GHSA-46vx-phhg-m6h9)
ecache::clear($tag, true) does not mean "and everything related". The second argument selects the system namespace, so the forum admin's cache clear was emptying entries nothing ever writes while the entries the reader writes stayed on disk. The cache handler's documentation now says which argument is which.
The test harness resolves its worktree from the script's own path. Letting the working directory win reads well in a shell session and fails everywhere else, because a caller whose working directory is reset between commands points its absolute path at one worktree and drives whichever one it happens to be standing in. --worktree names another tree explicitly and sits beside --env as a global flag; E107_TESTS_NO_HANDOVER, which only existed to paper over the old default, is removed. (https://github.com/e107inc/e107/pull/5868)
The forum fixture installs the plugin properly rather than merely creating its tables, and asserts itself from both sides before anything relies on it, because a forum seeded at the top level grants nobody anything and a suite built on one would see every "X cannot do Y" assertion pass without exercising a line of the code it names.
havePluginInstalled(), dontSeeTableInDatabase() and havePluginTables() were added, along with a guard on the admin feed constant so that a test can point it at a local fixture rather than reaching the network. Without a real install, a test that posts to a front-end plugin page gets a redirect either way, which is indistinguishable from a refusal, so those tests would have passed for free. havePluginTables() creates every table a plugin ships with the engine e107 would actually pick; the pattern it replaced required a statement to end immediately after the engine name, so any table carrying options after it was missed, and because the body group is lazy several tables could collapse into one match.
The unit suite no longer lets one test's leavings decide whether the next one passes. The shortcode parser lives for the whole run and kept every batch object it had built, still holding the variables it was last rendered with, so a shortcode reading a variable it never set passed or warned depending on the shuffle.
The local deployer makes the parent directory writable, not just the file. It writes files world-writable because the web container runs as a different user, then created missing parents owned by the test process, so a fixture seeded into a directory the application later writes to itself failed for a reason nothing in the failing test named.
The container serves TLS as well as plain HTTP, with a self-signed certificate generated at build time, because the outbound request policy tests need a peer they can verify and one they cannot. Nothing shipped changes.
Authorization tests assert on the side effect rather than the rendered page, because checkAccess() rewrites the action to e403 while the controller is still constructed and its init() still runs, so the page says Access Denied while the write lands. A test that reads the response body passes against vulnerable code.
Every csrf_enforce mode is pinned, against requests bringing no proof, a wrong token, a valid token and the Sec-Fetch-Site values that distinguish the modes, with the cookieless POST that has no ambient authority to borrow covered in all six. One case is the guard on the default itself: if the recommendation ever moves to a mode that reads no token, that assertion fails, which is the intended way to find out that the change turns away every visitor whose browser predates Fetch Metadata.
Acknowledgments
Thanks to
@arpitjain099 for reporting that any member could download another member's private message attachments; to
@Taffman for reporting that the previous release refused every edit and every chatbox post (#5858), and to @Kanonimpresor and @kvnorde for the extra cases in that thread that showed it was not one broken page but a class of them; to
@BillyBoy0823 for reporting menumanager_class.php arriving at 0 bytes, and for going back to his host to identify the scanner and the signature that did it (#5863); and to
@wiewied for the second report of the same thing on a different site (#5873), which is what brought the fix onto the stable line rather than leaving each administrator to patch by hand; and to
@riodrwn, @longnv719, and @scgajge12, whose earlier reports are the three advisories amended above, each fixed incompletely at the time and closed by this release; and to
@orionhridoy, whose report of the download handler is why the thumbnail endpoints were checked for the same defect.
[!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. (https://github.com/e107inc/e107/commit/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. (https://github.com/e107inc/e107/pull/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. (https://github.com/e107inc/e107/pull/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. (https://github.com/e107inc/e107/commit/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. (https://github.com/e107inc/e107/pull/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. (https://github.com/e107inc/e107/pull/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. (https://github.com/e107inc/e107/commit/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. (https://github.com/e107inc/e107/issues/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. (https://github.com/e107inc/e107/pull/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. (https://github.com/e107inc/e107/pull/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. (https://github.com/e107inc/e107/pull/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. (https://github.com/e107inc/e107/pull/5856)
Every page you serve gains two small pieces of markup. Each same-origin POST form gets a hidden e-token input as its first child, and every HTML page gets a before . This is the part of the release most likely to surprise a theme: a stylesheet or script that targets form > :first-child will 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: with e_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.
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. (https://github.com/e107inc/e107/commit/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, or e107_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. (https://github.com/e107inc/e107/pull/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 up e107_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. (https://github.com/e107inc/e107/commit/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. (https://github.com/e107inc/e107/pull/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. (https://github.com/e107inc/e107/pull/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. (https://github.com/e107inc/e107/issues/5844)
A theme with LINKDISPLAY set 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. (https://github.com/e107inc/e107/pull/5854)
The RSS page could die instead of rendering. When neither the requested template nor a theme override resolved, rss.php fell 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. (https://github.com/e107inc/e107/pull/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_menu plugin was folded into user, so the page came up with empty labels. (https://github.com/e107inc/e107/pull/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. (https://github.com/e107inc/e107/pull/5854)
Every admin create, edit, and list page requested two files that do not exist.admin.js and the whole scriptaculous folder 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". (https://github.com/e107inc/e107/pull/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. (https://github.com/e107inc/e107/pull/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. (https://github.com/e107inc/e107/pull/5854)
The log and metaweblog plugins 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. (https://github.com/e107inc/e107/pull/5854)
For Developers
Added
e_random: one fail-closed source for every secret.bytes(), hex(), int(), and pick(), plus isAvailable() 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 the e107SecurityRandom class on master, which reaches this one through a class_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. (https://github.com/e107inc/e107/pull/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 markup, which is why token protection was opt-in and mostly opted out of. The injector rewrites the response at the e_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 with e_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 , , 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. (https://github.com/e107inc/e107/pull/5856)
e107.security.csrfToken() in JavaScript. One new member on the e107 object, 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 $.ajax or $.post needs nothing: a $.ajaxPrefilter attaches the token to same-origin POSTs, in the request body where the payload allows it and in an X-e-token header where it does not. Reach for csrfToken() only when driving your own XMLHttpRequest or fetch(), 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. (https://github.com/e107inc/e107/pull/5856)
e_file::resolveSendPath(), getSendRoots(), isAbsolutePath(), and isSafeRelativePath().resolveSendPath() returns the canonical path or false, rejecting NUL bytes before realpath() can see them (PHP 8 raises a ValueError there, 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 as php:// and phar:// are refused structurally, by never resolving. (https://github.com/e107inc/e107/commit/1fa51ecb56)
$opts['roots'] on e_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 $opts before, so nothing existing changes behavior. The core media handler widens to e_ROOT rather than narrowing, because plugin and theme installs both write public {e_PLUGIN} and {e_THEME} rows through importIcons(). (https://github.com/e107inc/e107/commit/1fa51ecb56)
paragonie/random_compat, vendored as a production dependency.random_bytes() and random_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 passes php -l under php:5.6-cli. (https://github.com/e107inc/e107/pull/5856)
Security test coverage.e_randomTest pins 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 later random_bytes() and random_int() are disabled through disable_functions, which is what a hardened php.ini looks like from inside the class, and on PHP 5.6 the probe declares the two functions itself, which reproduces how the random_compat polyfill behaves on a host with no entropy source. e_token_injectorTest covers 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. (https://github.com/e107inc/e107/pull/5856, https://github.com/e107inc/e107/commit/1fa51ecb56)
Changed
e_session::check() refuses a state-changing request that carries no token. Every disjunct used to read isset(...) && !checkFormToken(...), so omitting the field matched no branch and fell through to return 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 through e_session::tokenCheckMode() and can be overridden for a single request with e_session::setTokenCheckMode(), which returns the displaced value and restores preference control when passed null. checkFormToken() now compares with hash_equals(). HTTP_X_E_TOKEN is accepted alongside the posted field. (https://github.com/e107inc/e107/pull/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 from e_form, a theme template, or raw HTML in a plugin. If you are building an AJAX request body by hand, take the token from e107.security.csrfToken() on the JavaScript side instead. (https://github.com/e107inc/e107/pull/5856)
The per-file e-token guard is gone from twelve entry points. Each opened with the same six lines forcing $_POST['e-token'] to an empty string before class2.php, so that a missing token would be read as an invalid one. The rule in check() 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. (https://github.com/e107inc/e107/pull/5856)
e_admin_controller::checkRequestToken() and the update routines keep their own check() call. It turns a bare die() 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, because class2.php has already applied the site's tokenCheckMode() and second-guessing it there would make allow-and-log enforce. (https://github.com/e107inc/e107/pull/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 after ob_end_clean(), so a realpath() warning could land inside the body of a binary download; and containment was tested on realpath($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. (https://github.com/e107inc/e107/commit/1fa51ecb56)
The core plugin list has one definition.e107plugin::$core_plugins and e_plugin's copy have been merged into e_plugin::CORE_PLUGINS, so the two lists cannot drift apart again, and log and metaweblog are out of it. (https://github.com/e107inc/e107/pull/5854)
The query-string filter in filter_request() was deliberately left alone. It blocks the literal .https://e107.org/.https://e107.org/ and it is not the boundary here: .https://e107.org/https://e107.org/.https://e107.org/ 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. (https://github.com/e107inc/e107/commit/1fa51ecb56)
Removed
e107_web/js/e_ajax.php and e107_web/js/e_js.php. Both were moved out of e107_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.php additionally passed $_POST['ajax_scfile'] to parse_scbatch(), which reads the path with file() and evaluates the resulting SC_BEGIN blocks. 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. The chatbox_menu header whose only statement loaded it, the featurebox ajax_url default that pointed at it, and both entries in the redirection exception list have gone with it. (https://github.com/e107inc/e107/pull/5853)
Six unreferenced files, 4,580 lines.pcltar.lib.php and pcltrace.lib.php (archive handling reachable only from a branch that sits after an unconditional return, inside a block comment; pclzip.lib.php stays, and pclerror.lib.php is kept for its documented PCLZIP_ERROR_EXTERNAL integration), 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.php and cli_class.php (each declares a class whose name appears nowhere else and has no autoload entry), and metaweblog.php (required a plugin that moved to its own repository in December 2020, so it has been a guaranteed fatal since v2.3.1). (https://github.com/e107inc/e107/pull/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() in admin_shortcodes.php, the mail form and recipient renderers in mailout_admin_class.php, and the theme upload and renderOnline() blocks in theme_handler.php. These blocks are a live hazard: automated review tooling and human readers alike keep matching them with grep and 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 with php -l. Docblocks and explanatory prose are preserved throughout. (https://github.com/e107inc/e107/pull/5853)
Deliberately not removed: the emptied stubs.e107_admin/filemanager.php, e107_admin/fla.php, e107_handlers/emote.php, and e107_web/utilities/*.php stay 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. (https://github.com/e107inc/e107/pull/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.php shortcode 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.
[!CAUTION] v2.3.9 is a maintenance release for sites on v2.3.8 or earlier. Upgrade from any 2.x at or below v2.3.8. If your site tracks the master branch, you are already past v2.3.9, so installing it would be a downgrade. v2.4.x is planned to be the next forward step.
[!NOTE] There are no security fixes in this release. If your site is still on v2.3.7 or earlier, the release you need urgently is v2.3.8, which closed an unauthenticated SQL injection and two further advisories. v2.3.9 carries all of that forward and adds bug fixes on top.
Highlights
Regression fixed: You can install e107 into any database your server will accept. v2.3.8 turned away database names containing a dot or a space, and said something unrelated when it did, which left some people unable to install at all. (https://github.com/e107inc/e107/discussions/5819)
Regression fixed: Your dashboard will tell you about updates again. Debug code left switched on had silenced the update notice for everyone, whatever version they were running. (https://github.com/e107inc/e107/pull/5839)
No more broken placeholder images. e107 borrowed them from an outside service that has since shut down. It now draws its own, on your own server. (https://github.com/e107inc/e107/issues/5581)
Members-only pages no longer send visitors round in circles. On some sites a guest was bounced between the login page and the members-only page until the browser gave up. (https://github.com/e107inc/e107/issues/5335)
Installer: valid database names were rejected. v2.3.8 allow-listed the database name to A-Za-z0-9_-. MySQL and MariaDB permit periods, spaces, dollar signs and Unicode in database names, verified on MariaDB 10.11 and MySQL 8.0, so an existing database named, for example, e107_2.3.8 could not be used at all. The restriction protected nothing, because whoever fills in that form already holds the database credentials, and the name only ever reaches SQL inside backticks. It is now escaped there instead. The table prefix keeps its A-Za-z0-9_ rule, because the prefix really is dropped into queries unquoted. (https://github.com/e107inc/e107/discussions/5819)
Installer: the wrong error message on two failure paths. A rejected database name and a database server below the minimum version both showed "A database name or prefix beginning with some digits followed by 'e' or 'E' is not acceptable", which described neither problem. Each failure now states the rule it actually broke, and a too-old MySQL server is named as such. (https://github.com/e107inc/e107/discussions/5819)
The core-update notice never appeared on the dashboard. Two leftover debug lines pinned the cached result to a fixed value and returned early, so the notice was dead for everyone. It now checks for an update when the cache is cold, stores the answer for an hour, and renders the notice. The separate scheduled update e-mail is unchanged, and the dashboard deliberately does not send it. (https://github.com/e107inc/e107/pull/5839)
Broken placeholder images across a fresh install.via.placeholder.com has gone; the domain no longer completes a TLS handshake. thumb.php used to redirect missing-image requests there, the hero plugin's three sample slides embedded its URLs directly, and the media handler had a special case for the domain name. thumb.php now serves a small SVG placeholder it generates itself, with cache headers, in about two microseconds; the hero slides ship as local SVG files; and image detection now ignores the query string, so existing database content still pointing at the dead service is classified correctly instead of by domain name. (https://github.com/e107inc/e107/issues/5581)
"Too many redirects" on members-only pages. The members-only redirect always sent visitors to login.php, ignoring a customlogin.php if the site had one, and the login page could only render in its bare iframe form. Worse, with registration disabled and no social login, the stock login page turns a guest away and bounces them back to the page they came from, looping until the browser aborts. e107 now redirects to whichever login page the site is configured to use, and falls back to the members-only notice in exactly that dead-end case, so the loop cannot form. (https://github.com/e107inc/e107/issues/5335)
Backup failed on PHP 8.2+ when an archive held a zero-byte file. Reading zero bytes from a file is an error on modern PHP, and the backup routine did it unconditionally while compressing in memory, ending the backup with a fatal error. Zero-length files are now written as empty entries. (https://github.com/e107inc/e107/issues/5208)
Adding an FAQ inserted the wrong data, or nothing at all. The insert supplied eight values positionally for a ten-column table. On a server in strict mode that is MySQL error 1136 and the FAQ is not saved; otherwise the values shift left and the display order ends up stored as the author's IP address. The insert now names every column, matching the rest of the plugin. (https://github.com/e107inc/e107/pull/5839)
Duplicated entries in the administration menu. When two entries in a sorted category shared the same display name, the menu builder re-added every matching entry once per match, so two entries sharing a name produced four rows, three produced nine, and so on. Each entry now renders exactly once. (https://github.com/e107inc/e107/issues/5786)
Media Manager search missed results that clearly matched. The image, icon, audio, video and general media pickers filtered their results with a case-sensitive comparison, so searching for "orchidea" did not find "Orchidea" even though the database query had already matched it. Searching is now case-insensitive, and it folds case correctly for accented Latin, Cyrillic, Greek and Turkish titles too, not only for A to Z. (https://github.com/e107inc/e107/discussions/5791)
Wrong text on the upload page. Two core language files each defined the same constant with different wording, so the upload page showed whichever one happened to load first. The stray duplicate is gone and the upload page has its own string. (https://github.com/e107inc/e107/issues/5730)
Changed
The gallery's prettyPhoto share buttons are gone. The default template put a http://twitter.com/share link and a Facebook "like" frame into every gallery overlay. The insecure addresses were blocked as mixed content on any HTTPS site, and the Facebook frame never received the right address anyway, so what remained was broken markup on every image. The default is now empty. A theme that defines its own share template still overrides it and is unaffected. (https://github.com/e107inc/e107/discussions/5651)
Security reporting instructions live in one place. The SECURITY.md file at the top of the repository still pointed reporters at an e-mail address that does not reliably deliver. GitHub serves the policy from .github/SECURITY.md, which asks for private reports through GitHub Security Advisories, so the stale copy has been removed. To report a vulnerability in e107, use Security Advisories. (https://github.com/e107inc/e107/pull/5822)
For Developers
Added
The reworked e107-tests harness, backported from master.release/v2.3.x can now run its own PHP matrix locally, including the 5.6 and 7.0 legs whose end-of-life Debian bases previously could not build a container at all. State moves out of per-worktree docker/.envs/ files into Docker labels and per-environment named vendor volumes, so list is global, gc reaps orphans, and the repository's composer.lock is never rewritten (composer install, with a loud per-environment composer update fallback when the interpreter cannot satisfy the lock). Adapted for this branch: no Selenium service, DEFAULT_PHP=8.2, acceptance browses http://localhost/ from inside the web container, and Codeception stays at 4.2.2. Validated on 5.6, 7.0, 7.4, 8.1 and 8.2. (https://github.com/e107inc/e107/pull/5837, https://github.com/e107inc/e107/pull/5836)
Offline coverage for the library manager.testCdnUrlContainsVersion asserts each CDN version appears in its resolved URL, and testCdnFileReportsHardcodedVersion exercises every entry's own version_arguments pattern against real bytes vendored under tests/_data/library_headers/. testRefreshCdnFixtures regenerates those fixtures from the live CDNs and is the one Internet-touching path, skipped unless E107_REFRESH_LIBRARY_FIXTURES is set. (https://github.com/e107inc/e107/issues/4560)
Changed
Installer name handling: escape, do not restrict.check_name()'s non-strict mode (the database name) no longer imposes a character-set rule; strict mode (the table prefix) keeps A-Za-z0-9_, because the prefix is interpolated unquoted into every query. A new quoteDbName() doubles backticks and wraps the name, and the three CREATE/ALTER/DROP DATABASE sites use it. e_db_pdo::database() and e_db_mysql::database() double backticks in their quoted interpolations; mysqli_select_db() needs nothing. The legacy quote and semicolon guard stays, because a semicolon cannot be represented in the PDO DSN. SqlInjectionFixesTest pins the new contract, including a backtick-doubling breakout guard. New strings LANINS_148, LANINS_149 and LANINS_150 replace the misused LANINS_105. (https://github.com/e107inc/e107/discussions/5819)
Themes can opt out of the bare login page.login.php forced e_IFRAME unconditionally. It now defers to the login core template, so setting $LOGIN_TEMPLATE['page']['noiframe'] = true in a theme override renders the login page with the full site chrome. The default is unchanged. The members-only redirect target also respects e_LOGIN now rather than hardcoding login.php. (https://github.com/e107inc/e107/issues/5335)
LAN_418 in lan_upload.php is now LAN_UL_041.LAN_407 and LAN_418 were each defined with different values in two core language packs, so load order decided which text won. The unused LAN_407 blurb has been dropped from lan_signup.php (the signup e-mail composes from LAN_EMAIL_04/05/06), and lan_upload.php's "Absolute maximum file size: " is renamed to LAN_UL_041, with upload.php repointed. lan_search.php keeps sole ownership of LAN_418. Translators and language-pack maintainers: add LAN_UL_041 to your lan_upload.php; the old LAN_418 definition there is no longer read. (https://github.com/e107inc/e107/issues/5730)
sc_cb_avatar() honours $parm. The chatbox avatar shortcode hardcoded crop => 'C' and rebuilt its own options array, discarding any crop, w or h a template passed in, which pinned chatbox avatars to the legacy thumb.php query-string URL while sibling avatar shortcodes forwarded $parm and reached SEF addresses. $parm is now merged over the defaults. 'C' remains the default crop, so existing rendering is unchanged, but a template can pass crop=false to escape to a SEF-eligible URL where thumb.php is broken on the host. (https://github.com/e107inc/e107/issues/5657)
e_library_manager takes an injectable file handler. It defaults to e107::getFile(), and detectPath() and getVersion() read through it, so a test can stub every remote read. _before() injects a handler that throws on any fetch, so an accidental network read now fails loudly rather than quietly downloading. testDetectionVersionConsistency compares each CDN pin against its bundled local sibling offline, which catches drift between the shipped copy and the hard-coded version, and testCoreLibraryPresence has dropped its remote URL probe. The cdn.jquery.once pin is corrected: it claimed 2.1.2 while the bundled copy ships 2.2.3, and the legacy jsDelivr path it used does not serve 2.2.3 at all, so it is repointed at the npm path. (https://github.com/e107inc/e107/issues/4560)
e_parse::toAvatar() fetches through the e107::getFile() singleton. Its base64 data-URI branch fetched a real avatar over the network at test time, so any DNS or network hiccup silently fell through to the plain remote and failed the assertion, which looked order-dependent under the shuffled suite. The fetch is now injectable and the test stubs it. (https://github.com/e107inc/e107/pull/5841)
Releases are built and published from GitHub Actions.build-release.yml is replaced by release.yml. The build step is unchanged in substance, but the workflow now finishes the job instead of stopping at a workflow artifact: it uploads the archives and core_image.phar to the GitHub Release, then uploads the nine archives to SourceForge over SFTP with a pinned host key and sets the per-platform default downloads. A guard asserts the full tag history was fetched before the core integrity image is built, and a build whose tag does not match the version it produced refuses to upload. (https://github.com/e107inc/e107/pull/5831)
Fixed
e_navigation::admin() cross-join. The sorted branch built a list of every entry's display text and then, for each text, re-appended every entry matching it, emitting K entries K times each for a same-named group. The text list is now deduplicated before the cross-join. (https://github.com/e107inc/e107/issues/5786)
pclzip reading zero bytes.fread($h, 0) throws a ValueError on PHP 8.2+, so an archive containing a zero-byte file fatally errored during in-memory compression. The read is guarded, and the file is still written as a zero-byte entry. (https://github.com/e107inc/e107/issues/5208)
Multibyte-safe media search.stripos() folds case for ASCII only, so a title differing in case on a non-ASCII letter could be dropped by the PHP post-filter even though the case-insensitive database collation had matched it. The five title filters now use the parser's ustrpos() and ustrtolower(), which use mbstring when it is present and fall back to the byte functions when it is not, so there is no new hard dependency. (https://github.com/e107inc/e107/discussions/5791)
Acknowledgments
Thanks to
@Jimmi08 for reporting the members-only redirect loop (#5335), the duplicated administration menu entries (#5786) and the PHP 8.2 backup failure (#5208); to
@kreossino for the case-sensitive Media Manager search (discussion #5791) and the broken gallery share template (discussion #5651); to
@Kanonimpresor for the installation failure that surfaced the database-name regression (discussion #5819); to
@tgtje for the language-constant collision report (#5730); and to
@sindizzy, @tgtje and @Taffman, whose thread in #5581 traced the broken images on fresh installs to the shut-down placeholder service.
[!CAUTION] v2.3.8 is a security release for sites on v2.3.7 or earlier. Upgrade from any 2.x at or below v2.3.7. If your site tracks the master branch, you are already past v2.3.8, so installing it would be a downgrade. v2.4.x is planned to be the next forward step.
[!IMPORTANT] Upgrade promptly. v2.3.8 closes an unauthenticated SQL injection reachable on a default site, finishes the core-wide SQL-injection cleanup begun in v2.3.7, and removes an open redirect.
Unauthenticated SQL injection in the news item view (GHSA-fr8h-3vfv-9q98, CVSS 8.6). A crafted news address let request data reach a database query without being made safe, with no login and no user interaction, on an ordinary front-end page. Any public-facing site is exposed. (https://github.com/e107inc/e107/commit/d11929a0ff)
All audited SQL-injection sinks closed across the core.v2.3.7 hardened many SQL injection risks, whether exploitable or not. v2.3.8 works through more risks that a follow-up audit found across admin, user, installer, and additional front-end code. There is no specific vulnerability mitigation but to upgrade.
Code execution from a poisoned preferences value (GHSA-568x-w5qj-vr7c, CVE-2026-57859, CVSS 7.2). A stored user-preferences value was rebuilt with eval(), so a hostile value written into the database out of band (for example through a separate injection) could run as PHP when that account's preferences were loaded. It is now parsed safely and rejects anything that is not plain stored data, which closes the second stage an injection would otherwise chain into. (https://github.com/e107inc/e107/commit/59cf3aaaa0)
You must upgrade to mitigate the numerous vulnerability risks.
Highlights
[Security] Unauthenticated SQL injection through the news pages (GHSA-fr8h-3vfv-9q98). A single news item address carried a value straight into a database query without being made safe, so an attacker could run their own database commands with no login. It is now read as a number, as the surrounding queries already were. (https://github.com/e107inc/e107/commit/d11929a0ff)
[Security] SQL injection audit cleanup.v2.3.7 hardened many but not all SQL injection risks. A follow-up audit of the whole v2.3.x tree found more of the same pattern in admin, user-account, installer, and other front-end code. Those are now fixed too, so the same class of attack is closed off at all known areas of the e107 core code. Plugins should be audited separately and are not in the scope of fixes in this release. There is nothing for you to configure. (https://github.com/e107inc/e107/commit/32940962e3, https://github.com/e107inc/e107/commit/fb8fe63e9e)
[Security] Stored preferences can no longer run as code (GHSA-568x-w5qj-vr7c, CVE-2026-57859). e107 was rebuilding a saved user-preferences value by running it through PHP's eval(), with only a loose check on the front of the string. A hostile value planted in the database could therefore run as code the next time that account's preferences loaded. The value is now read with a strict parser that only accepts ordinary stored data and refuses anything else, so it can no longer be turned into code, even if something else managed to write into the database. (https://github.com/e107inc/e107/commit/59cf3aaaa0)
[Security] Open redirect removed from the download handler (GHSA-wcc5-8jrf-6q26, CVSS 6.1). A download link could be crafted to bounce a visitor off to any other website while appearing to come from your domain, which is a common trick for phishing. That redirect is gone; a link that is not a real download now lands on the ordinary file-not-found page. (https://github.com/e107inc/e107/commit/b1def30489)
[Fix] Blank pages and emails when a theme has no override. Some templates (including the "notify" e-mail and the default page fallback) could come out empty because the template file was only read once but looked up under two names. The content is now captured reliably. (https://github.com/e107inc/e107/commit/4b68ed8b40)
[Fix] A leftover notification could take the whole site down. If a saved notification pointed at a plugin that had since been removed or renamed, every page that triggered an event (such as saving content) could go blank on PHP 7 and later. e107 now skips and logs the stale notification instead of stopping. (https://github.com/e107inc/e107/commit/40e73cefde)
For Administrators
Changed
Deep audit SQL injection risks closed across the core (security). The following vulnerable areas are now closed off to SQL injection:
The plugin/core update screen now carries a security token (hardening). The action to update your plugins/database could previously be triggered by a forged request if a logged-in main administrator was lured to a malicious page (a CSRF). It now requires e107's own one-time form token before any update runs, so only a genuine click on the real update button can start it. Sites that have deliberately turned off token protection are unaffected. (https://github.com/e107inc/e107/commit/23fa132aee)
A saved value that could run as code is now read safely (security). e107 stores some data (such as a user's preferences) as text and reads it back when needed. One of those readers was rebuilding the value by running it through PHP's code evaluator, so a value tampered with directly in the database could execute the next time it was loaded. That reader now uses a strict parser that only understands ordinary saved data and rejects anything else, so a poisoned value fails safely instead of running. There is nothing to configure. Ref GHSA-568x-w5qj-vr7c, CVE-2026-57859. (https://github.com/e107inc/e107/commit/59cf3aaaa0)
Fixed
Open redirect in the download handler (security).download/request.php would redirect to any web address placed in the link's query string, letting a crafted request.php?https://… link bounce a visitor off to another site under the trust of your domain (a phishing aid). Real downloads are served by their numeric ID and are unaffected; an address that is not a registered download now falls through to the normal file-not-found page. Ref GHSA-wcc5-8jrf-6q26. (https://github.com/e107inc/e107/commit/b1def30489)
Blank template output when a theme provides no override. With no theme override in play, a template file that e107 looks up under two internal names was only read the first time, so the second lookup found nothing and rendered an empty result for the rest of the page load. This showed up on the "notify" administrator e-mail and the default page fallback, and on any merged template request. The template content is now captured once per file and reused, so those outputs come through. (https://github.com/e107inc/e107/commit/4b68ed8b40)
Stale notification could blank the site (non-security). A saved notification pointing at a plugin class that had since been removed or renamed threw an uncatchable error on PHP 7 and later, so every page that fired an event (for example saving content) could go blank. e107 now checks the class exists, skips and logs the stale entry, and carries on. Fixes #5604. (https://github.com/e107inc/e107/commit/40e73cefde)
News grid crash on PHP 8. A News grid menu whose caption is stored as a per-language array with no entry for the active language could stop the page with a TypeError, because the array was handed to defined(). The caption is now checked before that call, so the grid renders. Fixes #5649. (https://github.com/e107inc/e107/commit/ddf6726c1a)
Editor page-break button. In the TinyMCE editor the page-break button inserted a marker e107's pagination never recognized, so it did nothing. It now inserts e107's own [newpage] marker, so a page break splits the content as intended. Fixes #5792. (https://github.com/e107inc/e107/commit/1ef5658a52)
Site disclaimer year. The site disclaimer showed a literal "YYYY" to anything that read it directly (for example the RSS feed copyright) instead of the current year. The year is now substituted where the value is defined, matching the shortcode. Fixes #5714. (https://github.com/e107inc/e107/commit/9d1258e696)
Admin Help panels on PHP 8. The Log, Users and Upload admin Help panels emitted "Undefined array key" warnings while parsing the page address. They no longer reach for a value that is not there. Fixes discussion #5762. (https://github.com/e107inc/e107/commit/c1545b1665)
Log noise without the optional social-login integration. On sites that do not have the optional social-login plugin installed, e107 wrote a "failed to open stream" warning to the PHP error log on every page. The include is now guarded, so the log stays quiet; behavior is unchanged where the plugin is present. Fixes #5683. (https://github.com/e107inc/e107/commit/79ce2dfed1)
For Developers
Added
Regression coverage for the SQL-injection fixes.SqlInjectionFixesTest pins the news item integer cast, the admin batch field allowlist (reject and accept paths), the user-model ID cast, the user-extended partial-update and create validation, and the installer name checks. The probe controller is built via eval so the unit loader's include-time class scan does not pull in admin_ui.php. (https://github.com/e107inc/e107/commit/181eeba90e)
e107TemplateSourceTest. Pins the capture-once-per-physical-file behavior so a template backing two registry paths cannot regress to caching an empty array for the second one. (https://github.com/e107inc/e107/commit/4b68ed8b40)
e_array::unserialize() parser coverage. New e_arrayTest cases pin the sink and the live e_user_pref::load() path, a var_export() round-trip, an edge-form round-trip (NUL / INF / NAN / PHP_INT_MIN), and an injection-rejection case; validated against the var_export()/eval() baseline on PHP 5.6 through 8.5. (https://github.com/e107inc/e107/commit/59cf3aaaa0)
SQL-injection remediation techniques (2.3.x). The e107 v2.3.x branch predates the future e_db prepared-statement and query builder API, so tainted values are escaped inside their SQL quotes with escape(), numeric / LIMIT / OFFSET positions are (int) cast, and dynamic table/column identifiers are confined to a strict [A-Za-z0-9_] allowlist (escaping cannot protect an identifier). The news item action reuses the integer cast it already computed; admin_ui / model_class validate the column against the controller's declared fields before it reaches the SET/WHERE clause, both at dispatch and unconditionally in batchUpdate(); e_user_model::load() int-casts the ID and the unadmin path moves its permission gate ahead of the query. If you maintain a plugin that builds raw SQL from request input, mirror these practices! (https://github.com/e107inc/e107/commit/32940962e3)
_getFieldValue()safestr now escapes. The safestr field type was serialized into single quotes without escape(), unlike the sibling str branch; its filter() pre-processing encodes quotes but leaves the backslash intact, so a trailing backslash could escape its own closing quote. It now calls escape() to match str, and the stored bytes are unchanged for any value without a backslash. xmlClass::e107Export() validates each requested table against the live table list before it reaches the FROM identifier position. (https://github.com/e107inc/e107/commit/fb8fe63e9e)
CSRF check at the e107Update choke point.e107Update::__construct() reads key($_POST['update']) / key($_POST['update_core']) and runs the upgrade routines; both callers (db.php's db_update branch and e107_update.php) are gated only by getperms('0'). A valid session token is now required at the shared constructor choke point before any update runs, validated through e107::getSession()->check(false) (fail-closed, honors e_SECURITY_LEVEL); the real form emitted by renderForm() now includes the e-token hidden field. (https://github.com/e107inc/e107/commit/23fa132aee)
e_array::unserialize() no longer uses eval(). It rebuilt the var_export() array-storage format by prepending $data = and calling eval(), guarded only by a substr(...,0,5) === 'array' check, so a stored value beginning with array that carried a PHP expression executed on load (second-order, reachable through e_user_pref::load() from a poisoned user_prefs column). It is replaced by a token_get_all() parser that accepts only the literal grammar var_export() emits (array structure with string / int / float / bool / null / nested-array leaves) and fails closed otherwise. The multi-token edge forms (NUL via 'a' . "" . 'b', INF/-INF/NAN, PHP_INT_MIN) are read faithfully, and the existing $data = strip, stripslashes fixups and PDO => fix are retained, so legacy and addslash'd values still round-trip; stored bytes are unchanged for well-formed data. Ref CVE-2026-57859 / GHSA-568x-w5qj-vr7c. (https://github.com/e107inc/e107/commit/59cf3aaaa0)
Template source captured once per physical path.e107::_getTemplate() caches each template under an override (/ext) and a non-override registry path; with no theme override both resolve to the same file, but include_once() only populated the template variable on the first inclusion, so the second flavor cached array() and every later lookup on it returned false. The file's definitions are now captured once under the templates/source/ registry namespace and re-read for each registry path the file backs. Template PHP still executes exactly once per request and theme overrides keep their distinct content. This clears the mail.php "notify" FIXME (2f9dae5eed, 2014) and un-deadens the page.php "default" fallback (46b2518e82, 2016). (https://github.com/e107inc/e107/commit/4b68ed8b40)
Fixed
e107_event::trigger() tolerates stale handlers. A notify_prefs entry whose class no longer exists is now skipped with a logged EVENT_01 warning behind a class_exists() guard, and the handler catch is widened to also cover Throwable, so an Error raised inside a handler on PHP 7+ is logged rather than left to fatal. The widening is a second catch(Throwable) after catch(Exception) so the code stays parseable and correct on PHP 5.6, where Throwable is undefined and Exception already catches every throwable. (https://github.com/e107inc/e107/commit/40e73cefde)
Acknowledgements
Our thanks to Aastha Aggarwal (@Aastha2602) for responsibly disclosing the open redirect (GHSA-wcc5-8jrf-6q26), and to @Jimmi08, whose report prompted the wider SQL-injection audit behind this release (GHSA-fr8h-3vfv-9q98), both through GitHub Security Advisories. We also thank Saidakbarxon Maxsudxonov (@sermikr0) for reporting the preferences code-execution issue (GHSA-568x-w5qj-vr7c, CVE-2026-57859), coordinated through VulnCheck.
[!CAUTION]
v2.3.7 is a security release for sites on v2.3.6 or earlier.Upgrade from any 2.x at or below v2.3.6. If your site tracks the master branch, you are already past v2.3.7, so installing it would be a downgrade. v2.4.x is planned to be the next forward step.
[!IMPORTANT]
Upgrade immediately. v2.3.7 closes two unauthenticated, network-reachable, Critical vulnerabilities. Neither requires authentication, user interaction, or any special privilege, and every public-facing site on v2.3.6 or earlier is exposed.
Remote code execution via install.php (GHSA-c8h6-wpj3-4cr8, CVSS 9.8). Reachable on a default installation: install.php stays live and exploitable after setup.
Mitigation: Delete install.php.
SQL injection throughout the e107 core (GHSA-5f75-c25x-59wx, CVSS 10.0). Request data is interpolated into SQL across ordinary, unauthenticated front-end request handling, including code an anonymous visitor triggers with a single page view (online-presence tracking writes the request URI into the database after a sanitizer that still permits the closing quote). It is not confined to any one plugin or feature, so no configuration change mitigates it. The only protection short of upgrading is to keep the site off the public Internet.
Treat this as an emergency upgrade.
Highlights
[Security] Remote code execution through the installer (GHSA-c8h6-wpj3-4cr8, CVSS 9.8). On a normal site, install.php stayed reachable after setup and could be tricked into running an attacker's code, with no login. It now refuses to run once the site is installed, and the information the setup wizard carries between steps can no longer be used to smuggle in code. (https://github.com/e107inc/e107/commit/6c40429521, https://github.com/e107inc/e107/commit/5d6d649e8c)
[Security] Database break-in through ordinary pages (GHSA-5f75-c25x-59wx, CVSS 10.0). Many everyday pages passed details from the web request into database queries without making them safe, so an attacker could run their own database commands, with no login and often just by visiting a crafted web address. This is now fixed throughout the core. No setting protects an unpatched site: you have to upgrade (or keep the site off the public Internet). (https://github.com/e107inc/e107/commit/120bd4bdbe)
[Security] A database-safety function that did nothing. One of e107's built-in functions for making a value safe to use in a database query was handing it back unchanged, so any code that relied on it had no protection. It now works as documented. (#5749, https://github.com/e107inc/e107/commit/b467b74f72)
For Administrators
Changed
The installer locks itself after setup (security). Once your site is installed, the setup wizard refuses to run again, so it can no longer be reached or misused on a live site. e107 decides this from the configuration file alone, so a database outage cannot accidentally reopen it. If you ever need to reinstall on purpose, delete e107_config.php first. Automated ("unattended") installs are only accepted on a site that is not already set up. Ref GHSA-c8h6-wpj3-4cr8. (https://github.com/e107inc/e107/commit/5d6d649e8c)
Database queries protected across the site (security). Many parts of e107 (including who's-online tracking, the ban list, ratings, comments, login, and registration) took information from the web request and used it in database queries without making it safe first. That has been corrected everywhere it was found, including the alt_auth external-database login that the report originally came in on. There is nothing for you to configure and the site behaves exactly as before; installing this release is what protects you. Ref GHSA-5f75-c25x-59wx. (https://github.com/e107inc/e107/commit/120bd4bdbe)
The website-address header is checked before use (hardening). e107 builds some links from the Host header a visitor's browser sends; it now rejects a malformed value and uses the server's own hostname instead. On a correctly configured site the existing host check already turns away mismatched hosts, so this is an extra safety net, and it also makes sure a non-standard port is kept in the links e107 generates. Ref #5734. (https://github.com/e107inc/e107/commit/a2f386b27f)
Fixed
Welcome Message creation date. A rewrite had dropped the automatic creation date when a Welcome Message menu item is created; it is restored, so the date is available for themes that choose to show it. Fixes #5739. (https://github.com/e107inc/e107/commit/bdb6c35116)
Media-category create page on PHP 8.media_cat_ui::createPage() called count() on an unset writeParms value when every category owner is restricted, throwing a TypeError under PHP 8. It now uses a null-safe check and keeps the same "category creation not available" behavior. Fixes #5741. (https://github.com/e107inc/e107/commit/d5e0d07814)
Enter advances the install wizard. The buttons bar emitted Back before Continue, so an implicit submit (Enter in a text field) sent the wizard back a step. The forward control is emitted first and the bar laid out row-reverse, so the visible order is unchanged and only the Enter behavior is fixed. (https://github.com/e107inc/e107/commit/2865e98f96)
For Developers
Added
Test coverage.installStateTest builds the real guzzlehttp/psr7 POP chain and asserts the new JSON codec rejects it while round-tripping scalars; SqlInjectionFixesTest pins the core SQL-injection remediations; and acceptance coverage exercises the installer reinstall guard, paste-to-resume, and the unattended-install path end to end. (https://github.com/e107inc/e107/commit/6c40429521, https://github.com/e107inc/e107/commit/892758085c)
eShims::setcookie() options-array shim. Backported SetcookieTrait (the PHP 7.3+ options-array form with a pre-7.3 SameSite-into-path fallback) so the installer can set a hardened (HttpOnly, Secure, SameSite=Strict) resume cookie with one call across every supported runtime. Existing setcookie() callers are untouched. (https://github.com/e107inc/e107/commit/1063102678)
Changed
Installer wizard state is now signed JSON, and the installer fails closed.install.php previously unserialize()d base64-encoded $_POST['previous_steps'] with no allowed_classes, at file scope on every request and again in the e_install constructor; the bundled guzzlehttp/psr7 PumpStream POP chain reached arbitrary callable execution once the decoded value flowed into site_path and was coerced to a string. Wizard state now travels as base64(JSON).hex(HMAC), verified in constant time on every request against a per-install CSPRNG signing token, and json_decode() only yields arrays and scalars, so object injection (CWE-502) is impossible by construction. e107_config.php is read as one of three positive states (fresh, install-pending, installed) instead of an emptiness check; the unattended ?create_tables path compares credentials with hash_equals() and refuses a database that already holds an e107 schema; and the finished config is written last, so a half-finished install never looks installed. (https://github.com/e107inc/e107/commit/6c40429521, https://github.com/e107inc/e107/commit/5d6d649e8c)
Core SQL-injection remediation techniques. Because the 2.3.x line predates the e_db prepared-statement API, tainted values are escaped inside their SQL quotes, numeric / LIMIT / OFFSET positions are (int) cast, and dynamic table/column identifiers are confined to a strict [A-Za-z0-9_] allowlist (escaping cannot protect identifiers). Import providers are fixed at the root in base_import_class::database(). Many sinks are reachable without a login: the online-presence tracker, for example, wrote e_REQUEST_URI into online_location after only FILTER_SANITIZE_URL, which keeps the single quote, so a crafted address reached the query directly. If you maintain a plugin that builds raw SQL from request input, mirror these. (https://github.com/e107inc/e107/commit/120bd4bdbe)
e_db_pdo::escape() now escapes. The shared testEscape() expectation that codified the pass-through is updated, which makes the e_db_mysql override redundant. One caller (social_setup.php) used escape() on a value interpolated into an unquoted numeric context and is cast to int instead. (#5749, https://github.com/e107inc/e107/commit/b467b74f72)
Host resolution helper (hardening). The request host is resolved through a shared resolveHttpHost() backported from master: it accepts the client Host only when it matches a host[:port] shape and otherwise falls back to SERVER_NAME, preserving the visited port. On a configured site the isAllowedHost() killswitch (added in v2.3.6) already rejects a mismatched Host before it reaches a URL, so this is defense-in-depth (covering an unset or misconfigured siteurl, and partial-bootstrap entry points that skip the killswitch) plus a regression lock: the accompanying e107HostValidationTest pins port preservation so a future sync from master cannot silently reintroduce the port-strip. (https://github.com/e107inc/e107/commit/a2f386b27f)
GitHub contribution templates revamped.CONTRIBUTING.md is now a policy document (backwards compatibility as the primary constraint, AI-assisted contributions welcome on the same quality bar, explicit rejection criteria for low-effort output), with matching PULL_REQUEST_TEMPLATE.md, issue forms, FUNDING.yml, and a new SECURITY.md. (https://github.com/e107inc/e107/commit/2b49a62ce8)
Fixed
Install log moved out of the document root.e107Install.log was written into the docroot, where the shipped .htaccess has no rule for .log files, so its install diagnostics (DB host, table prefix, pref dumps on failure) were fetchable over HTTP. It now lives under the deny-by-default e107_system/. (https://github.com/e107inc/e107/commit/c1ef5bab81)
Installer reads its own config past opcache. With opcache on, a same-second rewrite of e107_config.php could be served from the prior compile, hiding the freshly written provisioning lock. The installer now opcache_invalidate()s the file around each read and write. (https://github.com/e107inc/e107/commit/72b333107e)
Non-destructive test isolation.GitPreparer's rollback used git reset --hard && git clean -fdx, which destroyed uncommitted work on a crashed run. It now runs each suite inside a disposable git worktree with flock()-based orphan cleanup, never touching the main tree. (https://github.com/e107inc/e107/commit/aac93f1791)
Acknowledgements
Both vulnerabilities were responsibly disclosed through GitHub Security Advisories. Our thanks to @baozongwi (GHSA-c8h6-wpj3-4cr8) and @Jimmi08 (GHSA-5f75-c25x-59wx) for the reports.
[!CAUTION]
v2.3.6 is a bug-fix release for sites on v2.3.5 or earlier.Upgrade from v2.3.5 or earlier 2.x. If your site tracks the master branch, you are already past v2.3.6, so installing it would be a downgrade. v2.4.x is planned to be the next forward step.
Highlights
[Security] Command injection in resize_image() (GHSA-3j33-c9v4-4p42). The ImageMagick convert command line did not shell-escape its destination filename; reachable via news submission with the right combination of prefs. (https://github.com/e107inc/e107/commit/794a179f)
Host-header recovery for sites locked out by v2.3.4 / v2.3.5. The GHSA-7pmw-jwvr-cq2x killswitch now case-folds, strips www., and strips trailing ports before comparison, and returns a logged 503 instead of a bare die(). Closes #5627 and #5634. (https://github.com/e107inc/e107/commit/aba3b169)
trusted_hosts SitePref for parked, staging, and multi-host setups. Authorise additional incoming hostnames from Admin → Preferences without touching e107_config.php. (https://github.com/e107inc/e107/commit/c2bb246b)
trusted_hosts SitePref. New textarea below the Site URL row in Admin → Preferences. Paste additional hostnames one per line (e.g. staging.example.com, example.org); the server-side normaliser strips schemes/paths and de-duplicates case-insensitively, so https://staging.example.com/ saves as staging.example.com. The pref is purely additive: requests whose Host matches the configured siteurl continue to pass without listing it here. Ref GHSA-7pmw-jwvr-cq2x, #5627. (https://github.com/e107inc/e107/commit/c2bb246b)
Changed
Host-header validation (security).www. and the bare apex now match each other, hostnames compare case-insensitively, and trailing ports are stripped before comparison, so https://example.com:8080/ matches https://example.com/. Sites that were stuck on the "Site Configuration Issue Detected" page after upgrading from v2.3.3 should now boot again without manual SQL edits.
Host-mismatch response. The bare die('Site Configuration Issue Detected. ...') that returned 200 OK with one line of plain text is replaced by a 503 Service Unavailable with a short HTML body that points the operator at the server error log. The diagnostic detail (configured siteurl, request Host) is written via error_log(), which previously sat after the die() and never fired.
The visible page intentionally does not echo the incoming Host, the configured siteurl, or any admin URL. The diagnostic surface stays in the log, which already requires server access. (https://github.com/e107inc/e107/commit/aba3b169)
Fixed
Thumbnail rendering on PHP 8.thumb.php boots the framework directly without class2.php, so E107_DEBUG_LEVEL was never defined for that request; the first call that opened a DB handle (typically when the SitePrefs disk cache was cold) hit a PHP 8+ fatal in e_db_pdo::__construct() and broke every thumbnail on the site. Now guarded with defset(). Fixes #5664. (https://github.com/e107inc/e107/commit/59aef4f1)
Thumbnail database credentials. With the E107_DEBUG_LEVEL fatal out of the way, thumb.php then failed with SQLSTATE[HY000] [2002] No such file or directory because its manual bootstrap chain never populated the MySQL config the PDO connector reads. The bootstrap now routes through e107::initCore() (the same call class2.php uses), so credentials are set before the first DB call. Follow-up to #5664 / #5665. (https://github.com/e107inc/e107/commit/02a79729)
Forgot-password page on v1-style themes.fpw.php fataled with Undefined constant LAN_112 when the active theme had no theme.xml (the legacy theme path uses e107_core/templates/legacy/fpw_template.php, which still references the old constant). The BC shim that mapped LAN_112 → LAN_FPW22 only fired in the members-only branch; it now fires before every downstream template require, regardless of branch. Fixes #5653. (https://github.com/e107inc/e107/commit/d9725b91)
Other legacy templates. Any legacy core template under e107_core/templates/legacy/ that referenced a dropped v1.x LAN_* constant could fatal on PHP 8 with a single missed reference. e107::predefineLegacyLans() now tokenises each legacy template before it loads and auto-defines any missing LAN_* with its own name as a value, emitting an E_USER_WARNING per auto-define so the maintainer trail stays visible. Wired into the six legacy require sites (fpw.php ×2, search.php, signup.php, user.php, usersettings.php). Refs #5653. (https://github.com/e107inc/e107/commit/b1290139)
Admin navigation icons and user-handler permissions on PHP 8.sitelinks_class::setIconArray() built the admin nav map from ~40 E_32_* constants and user_handler::$core_perms referenced dozens of ADLAN_* / ADMSLAN_* / E_16_* / E_32_* entries, all loaded lazily by the admin language file as bare reads. PHP 8 promotes bare reads of undefined constants to fatal Errors, so any caller that hit these before the admin language file loaded crashed. Both sites are now wrapped in defset() so the undefined case returns the empty default. (https://github.com/e107inc/e107/commit/0635801a)
FAQ plugin cron registration on PHP 8.e107_plugins/faqs/e_cron.php referenced LANA_FAQ_CRON_1, LANA_FAQ_CRON_2, and LAN_AUTOMATION at admin cron-registration time, which runs before English_admin.php is loaded. PHP 8 turned the bareword fallback into a fatal; the registration path now uses defset(). (https://github.com/e107inc/e107/commit/21f7b584)
strftime() deprecation on PHP 8.1+.StrptimeTrait::buildMonthArrays() called PHP's strftime() to localise month names. PHP 8.1 deprecated the function and PHP 9 will remove it, so the @-suppressed call still landed in error_log. Both calls are now routed through the eShims::strftime() polyfill that already exists in the tree. (https://github.com/e107inc/e107/commit/295ce2a1)
For Developers
Added
e107::predefineLegacyLans($path) token-scan safety net for legacy templates. Tokenises the requested template, finds bare LAN_* token references (skipping function/method/class/static contexts and call sites), and define()s any that are still missing with their own name as value. Token extraction is cached on hash_file('sha256', $path) keyed entries (APCu when available, otherwise a file under e_CACHE); a process-local memo short-circuits repeat resolutions. Warm-cache cost is roughly 6 µs vs the ~4 µs baseline.
Wired into fpw.php, search.php, signup.php, user.php, and usersettings.php. The wrappers call it immediately before the existing require/include so caller-scope template variables ($FPW_TABLE, $SIGNUP_BODY, etc.) remain assignable. Refs #5653. (https://github.com/e107inc/e107/commit/b1290139)
trusted_hosts SitePref plumbing. A new e107::isAllowedHost() private helper composes the allow-list from parse_url(siteurl)['host'] plus the entries saved in trusted_hosts. The pref reads as a newline-separated list; the saver normalises (scheme/path strip, case-fold dedup) before writing back. (https://github.com/e107inc/e107/commit/c2bb246b)
Docker-based parallel test environments.e107_tests/bin/e107-tests is a new CLI that spins up an isolated PHP + Apache + MySQL stack per worktree (up | down | reset | clean | install | urls | status | logs | shell | db-shell | exec | run | list). Each worktree + matrix combo gets a deterministic compose project name derived from the worktree path, so parallel sessions never collide on container names, networks, volumes, or host ports. A new config.docker.yml layer slots into the existing config cascade (sample → yml → docker → local).
Test coverage.resize_handlerTest exercises the ImageMagick branch of resize_image() with three command-substitution payloads and asserts no marker file is created and no id-style output leaks into a filename. e107HostValidationTest covers the case-fold, www-strip, and port-strip behaviour of e107::isAllowedHost() plus trusted_hosts composition. e107RequireLegacyTemplateTest covers the tokeniser context filtering, define/warn behaviour, scope preservation, missing-file return value, and the #5653 regression case. (https://github.com/e107inc/e107/commit/794a179f, https://github.com/e107inc/e107/commit/c2bb246b, https://github.com/e107inc/e107/commit/b1290139)
Changed
language::bcDefs() no-argument default expanded. The boot-time call from class2.php:590 previously defined only LAN_180 → LAN_SEARCH. Legacy templates that referenced any other dropped v1.x constant whose replacement happens to live in English/English.php still fataled on PHP 8. The default now covers ~20 boot-resolvable aliases: generic actions (LAN_406 / LAN_419 / LAN_435), the v1 download prefix (LAN_dl_7..LAN_dl_35), and a defined-as-empty group for dropped-without-replacement constants.
Conflict policy: where the same legacy constant had divergent mappings across per-entrypoint shims (LAN_7..LAN_10, LAN_112, LAN_122, etc.) the global default deliberately omits them and per-entrypoint shims remain authoritative. Mappings whose replacement lives in a lazy lan_*.php cannot be resolved at boot (defined() returns false and the entry silently no-ops); those are still handled by per-entrypoint language::bcDefs() calls after the relevant e107::coreLan(). Refs #5653. (https://github.com/e107inc/e107/commit/d2ef6b10)
Host comparison helper. A new e107::normaliseHost() lowercases, strips a trailing ort, and strips a leading www. before comparison. Plugins that previously did their own Host-header guards should switch to e107::isAllowedHost() so the trusted_hosts allow-list composes correctly. (https://github.com/e107inc/e107/commit/aba3b169)
resize_image() shell escaping. The convert command line now passes the destination through escapeshellarg() and casts the integer geometry/quality args. If you maintain a fork or call resize_image() directly from a plugin, no migration is required, but any caller that previously relied on quoting the destination itself can drop that quoting. Ref GHSA-3j33-c9v4-4p42. (https://github.com/e107inc/e107/commit/794a179f)
Fixed
thumb.php bootstrap. The manual bootstrap chain (prepare_request / setDirs / set_constants / ...) is replaced by e107::initCore() fed the same mySQL-prefixed $sql_infocompact() array class2.php passes.
Note the v2.3.x e107::_init() does not translate keys the way master's e107::setMySQLConfig() does, so the array must go in prefixed. The master-style str_replace('mySQL', '', $k) translation breaks v2.3.x because e_db_pdo still reads $config['mySQLserver'] etc. Follow-up to #5664 / #5665. (https://github.com/e107inc/e107/commit/02a79729)
submitnews.php title slug. The slug used to build the destination filename for resize is now confined to [A-Za-z0-9_] before reaching resize_image(), so shell metacharacters never escape the filter even if a future caller relaxes the filename quoting. Ref GHSA-3j33-c9v4-4p42. (https://github.com/e107inc/e107/commit/794a179f)
resize_handlerTest teardown sentinel.tearDown() ran even when setUp() had short-circuited via markTestSkipped() (CI images without ImageMagick); in that case $savedPref === null took the unset($GLOBALS['pref']) branch and wiped the global pref array for every following test in the shuffled unit-suite run. A prefMutated sentinel now flips true only after _before() mutates $pref, and _after() bails out early otherwise.
CI on the legacy PHP cells.actions/checkout@v3 was reclassified to a Node 20 action, which requires glibc 2.27+; the official php:5.6 / php:7.0 images are on Debian 9 (glibc 2.24) and the action errors before its main logic. The two legacy cells now use an inline git fetch + git checkout against the run SHA.
The php:5.6 × mysql:8.0 and php:7.0 × mysql:8.0 combinations have also been dropped via matrix.exclude. Mysql:8 starts with character-set-server=utf8mb4 by default and the PDO driver in those PHP versions doesn't recognise the charset, failing the connection handshake before any test runs. (https://github.com/e107inc/e107/commit/d024d205, https://github.com/e107inc/e107/commit/756dc92c)
Composer audit no longer blocks resolution. Composer 2.9.x refuses to resolve package versions affected by a Packagist security advisory at install time. Every twig/twig between v1.28 and v3.20 carries at least one advisory, leaving v3.26.0 as the only resolution candidate; v3.26.0 requires PHP 8.1+, so the PHP 7.4 cell of the unit-test matrix failed composer update. The test harness is dev-only and never ships with a release, so the audit's resolution-time block is now disabled via config.audit.block-insecure: false. composer audit still runs after install and reports any advisories. (https://github.com/e107inc/e107/commit/46351d1a)
Matrix CI design restored. The "Collapse 5×5 matrix into one host-orchestrated job" experiment that landed during the v2.3.5 cycle was reverted: single jobs ran ~20 minutes end-to-end vs ~5 minutes per cell under master's parallel matrix, and exhibited intermittent docker / mysql container startup races at the per-cell DB pre-create step. v2.3.x CI is back to the matrix layout that mirrors master, with the legacy PHP cells extended via the inline-checkout and matrix.exclude pair above. (https://github.com/e107inc/e107/commit/da47684c)
language::bcDefs() graveyard collisions on v2.3.x. Pre-defining LAN_199, LAN_406, and LAN_419 at boot collided with v2.3.x's lazy lan_search.php and lan_upload.php (which still use define(); master converted them to array form in #5465). PHPUnit's convertWarningsToExceptions=true escalated the "Constant already defined" warning and aborted mid-load. Those three entries are dropped on this branch; the lazy LAN files remain authoritative. (https://github.com/e107inc/e107/commit/f1b4bf27)
[!CAUTION] v2.3.5 is a security release for sites on v2.3.4 or earlier. Upgrade from v2.3.4 or earlier 2.x. If your site tracks the master branch, you are already past v2.3.5, so installing it would be a downgrade. v2.4.x is planned to be the next forward step.
[!IMPORTANT] v2.3.5 ships a single fix for a CSRF vulnerability in the AJAX comment moderation endpoints. Sites that allow user comments and have at least one logged-in moderator or admin should upgrade promptly.
Highlights
[Security] CSRF on AJAX comment moderation (GHSA-m4hh-m278-jwg5). The AJAX delete, approve, and edit branches in comment.php previously executed when only the payload and the ADMIN gate were present, so a cross-origin POST without an e-token would still go through. A logged-in moderator visiting an attacker page could be made to block, approve, or edit comments without their knowledge. The endpoints now reject any mutation request that lacks a valid form token, and the front-end UI threads the token through the delete/approve/edit anchors so legitimate clicks keep working. (https://github.com/e107inc/e107/commit/a46a77a6)
For Administrators
Changed
AJAX comment moderation (security)./comment.php?mode=delete, ?mode=approve, and ?mode=edit now return {"msg":"Unauthorized access!","error":true} when the request omits or fails the e-token check. The shipped front-end UI was updated in lockstep, so this is transparent for legitimate use. If you maintain a custom theme that replaces the comment-options or comment-edit shortcodes, see the developer note below. Ref: GHSA-m4hh-m278-jwg5. (https://github.com/e107inc/e107/commit/a46a77a6)
For Developers
Changed
Comment-options shortcodes now emit data-token.comment_shortcodes::sc_comment_delete() and the comment-edit anchor render a data-token attribute populated from e_TOKEN. The corresponding handlers in front.jquery.js read it back and include e-token in the AJAX payload. If you ship a fork of these shortcodes or a custom AJAX client that talks to /comment.php, mirror the change — without it your delete/approve/edit calls will be rejected. Ref: GHSA-m4hh-m278-jwg5. (https://github.com/e107inc/e107/commit/a46a77a6)
Token enforcement is local to comment.php for now. The central session_handler::check() still only validates the token when one is present. If you add new AJAX mutation branches to comment.php or to similar endpoints, guard them explicitly with e107::getSession()->checkFormToken($_POST['e-token']) until the central gate is tightened.
[!CAUTION]
v2.3.4 is a bug-fix release for sites on v2.3.3 or earlier.Upgrade from v2.3.3 or earlier 2.x. If your site tracks the master branch, you are already past v2.3.4, so installing it would be a downgrade. v2.4.x is planned to be the next forward step.
[!IMPORTANT]
v2.3.4 collects the most overdue work in the queue: security advisory fixes for password reset, comment editing, and Media Manager imports; the PHP 8.x compatibility patches that have been accumulating; and the bug fixes that really needed to ship. It's not a feature release; the goal is to give v2.3.x sites a stable point release they can adopt while v2.4 work continues separately.
Highlights
[Security] Critical Broken Access Control on comment edit (GHSA-5w63-63rh-99q6).comment.php previously allowed any authenticated user to overwrite another user's comment by passing that comment's itemid. The updateComment() SQL now requires the row's comment_author_id to match the editor's USERID, so cross-user edits return "Update Failed" instead of succeeding silently. (https://github.com/e107inc/e107/commit/23961a8f)
[Security] Server-Side Request Forgery in Media Manager imports (GHSA-92fr-7h4f-22pp).e_file::getRemoteFile() and getRemoteContent() now reject URLs that resolve to private, loopback, link-local, or otherwise reserved IP addresses, and limit cURL to HTTP/HTTPS. Sites that legitimately need to fetch from intranet hosts can opt back in by defining e_REMOTE_FILE_ALLOW_PRIVATE to true. (https://github.com/e107inc/e107/commit/5f98cc9f, https://github.com/e107inc/e107/commit/40b2d111)
[Security] Privilege hardening. Media Manager preferences and avatar settings now require Main Admin. The default userclass visibility and edit permissions are also Main Admin by default (previously Admin). (#5489, #5477)
Admin area usability. Numerous fatal-error and rendering fixes across admin search, admin UI grids, mailout, polls, datetimepicker, phpinfo, and legacy admin pages. (#5211, #5464, #5271, #5473)
Email reliability. Fixes to CC handling, DKIM identity, persistent-recipient leakage across sendEmail() calls, and IP logging in notifications. (#5498, #5535, #5545)
PHP 8.x compatibility. Several warnings and fatals on PHP 8.0–8.5 removed from db_verify, thumb.php, file_class, theme_handler, and rating-/forum-info rendering. (#4501, #5443, #5482)
Forum info restored.sc_foruminfo now renders active-user counts and the newest member again. Two long-standing bugs had been hiding the whole block: the SELECT for the newest user was commented out, and the e_TRACKING_DISABLED ternary condition was inverted so the block was only shown when tracking was disabled, which is never the default. (https://github.com/e107inc/e107/commit/0e23f651, https://github.com/e107inc/e107/commit/54e4b9de)
[!NOTE]
A note from the maintainer, @Deltik:
v2.4 is going to need more time before it's at the quality level the e107 community deserves. Here's what's upcoming:
MyISAM → InnoDB as the default engine, for crash recovery, row-level locking, and proper transactions
utf8mb3 → utf8mb4 for native emoji support and full Unicode in usernames, posts, and comments
Implicit FULLTEXT indexes that work on InnoDB, so search no longer pins us to MyISAM
JWT-backed CAPTCHAs where the challenge carries its own server-signed solution token, eliminating the need to stash state in a guest session
No more sessions for guests. Every anonymous visitor today gets a server-side session row; that goes away.
New admin area skin with a collapsible sidebar, badges, and mobile navigation
Bootstrap 5.3 + FontAwesome 6 UI refresh across the front-end and admin
Admin change history with revert for auditable database edits
Custom domains per page and static URL mapping for editorial control over URLs
Schema.org (JSON-LD) support for better SEO, with news schema baked in
Sitemap index support for sites past the single-sitemap limit
Image alt-attribute management in Media Manager
Plugin test runner so plugin authors can ship PHPUnit/Codeception tests with their plugins
The community PR backlog finally getting reviewed and processed
Password reset (security).fpw.php now refuses to process any request when the siteurl preference is unset, and builds the reset link from the pref directly rather than from SITEURL (which could be derived from HTTP_HOST). Ref: GHSA-7pmw-jwvr-cq2x. (https://github.com/e107inc/e107/commit/04511f9f)
Media Manager permissions (security). Media Manager Preferences and Avatar settings now require Main Admin. Media Category management is restricted to the A2 permission. (#5489)
Userclass defaults (security). Default userclass visibility and edit permissions now default to Main Admin instead of Admin. (#5477)
Admin area theme gate. Non-bootstrap3 admin themes that were known to break the admin area are no longer accepted; the admin falls back to a working theme. (https://github.com/e107inc/e107/commit/3b7097e0)
Site redirection.www. → bare-domain (and vice versa) handling was refactored out of class2.php into a dedicated method. (#5097)
Fixed
Fatal errors on the admin search page (#5211), the admin-UI with custom method attribute+filter (#5464), the polls form column selector (#5271), and the plugin-repair extended-user-field path (#5483).
Admin user area: avatar rendering (#5146), extended user fields restored after plugin refresh (#5483), unbanned users keeping "not verified" status (https://github.com/e107inc/e107/commit/e875515d), oversized navigation icons (#5345).
Admin email/mailout: CC recipients added correctly, DKIM identity corrected, recipients no longer persist across multiple sendEmail() calls, core prefs no longer stored on instance, mailout mailer-type restriction that was blocking pref saves. (#5498, #5535, #4123, #5355)
Admin log: query-speed optimization and indexing improvements, duplicate column removed from the rolling log, debug SQL query output. (#5490, #5473)
Forum plugin: newforumposts_menu page rendering (#5340), shortcodes now use e_HTTP for online.php links (PR #5340), sc_foruminfo now renders the active-users block and the newest-member link (previously hidden by an inverted condition, with the underlying user lookup query commented out) (https://github.com/e107inc/e107/commit/0e23f651, https://github.com/e107inc/e107/commit/54e4b9de).
Ratings: widget renders cleanly for items that have not been rated yet; previously a missing rating row triggered a PHP warning that became a fatal on PHP 8.5. (https://github.com/e107inc/e107/commit/db358ca1)
News plugin: language loading (#5465), body/extended search scope (#5523).
Signup: COPPA links updated (#5121), duplicate LAN_ERROR_* constants removed (#3438).
Notifications: IPv4 format on user_ban_flood (#3612), IP populated in notify_class (#5545), mcp_token tracker ignored in application tracker (#5288).
Core boot: database warning in class2.php (#5220), missing creation-log entry ID (#5317), config-hash handling (#5120), override-class conflict (#5114), SEF URL in the admin user-settings nav link (#5082).
Scaffold for XmlAdminIcons in plugin_class (with rename guard in media_class). (#5295)
Changed
Comment ownership check.comment::updateComment() now constrains its SQL update to comment_author_id = USERID. If you maintain a fork or plugin that calls this method on behalf of another user (moderator tooling, cron-based imports), expect those calls to silently no-op and refactor toward a moderator-aware helper. Ref: GHSA-5w63-63rh-99q6. (https://github.com/e107inc/e107/commit/23961a8f)
e_file SSRF defense.getRemoteFile() and getRemoteContent() reject URLs that resolve to private/loopback/reserved IPs and any non-HTTP(S) scheme; cURL CURLOPT_PROTOCOLS/CURLOPT_REDIR_PROTOCOLS are pinned to HTTP/HTTPS. IPv4-mapped IPv6 addresses are canonicalized to IPv4 before the range check, so ::ffff:10.0.0.1 is treated as 10.0.0.1 and blocked. Define e_REMOTE_FILE_ALLOW_PRIVATE = true in e107_config.php to bypass for legitimate intranet use. Ref: GHSA-92fr-7h4f-22pp. (https://github.com/e107inc/e107/commit/5f98cc9f, https://github.com/e107inc/e107/commit/40b2d111)
Password reset link construction.fpw.php builds the reset URL from e107::getPref('siteurl') directly instead of the SITEURL constant. If you rely on SITEURL in similar contexts elsewhere, consider doing the same for anything that leaves the server (emails, webhooks, signed URLs). Ref: GHSA-7pmw-jwvr-cq2x. (https://github.com/e107inc/e107/commit/04511f9f)
Language file tooltips. Inline tooltip LAN constants standardized; some constants were consolidated. (#5465)
Permissions schema. Media Manager and userclass permissions tightened — plugins that depended on Admin (A) access to Media Manager prefs or avatars will now need Main Admin (0). (#5489, #5477)
e_parse::cleanHtml(). HTML5 void elements (, , etc.) are now normalized before serialization so saveHTML() output is identical across libxml versions. libxml as non-void and captured following content as its child; the new pre-serialization pass promotes that content to a sibling, and a complementary post-saveHTML pass strips the stray (and other void-element closing tags) that older libxml still emits, so every libxml ends up with the spec-compliant form. (https://github.com/e107inc/e107/commit/b63d11a2, https://github.com/e107inc/e107/commit/87abb7f7)
Form handler. Help-icon rendering, optgroup class identifiers, DST-related test stability. (#5214, #5269)
JavaScript. Invalid-element tab switch issue in admin/front JS. (#5230)
Update routines. Clear update info after GitHub update in admin boot/db, minor update_routines.php tweak (#5481), warning suppression in admin/auth.php for invalid language values (#5443).
Caching. Fatal in plugin_class.php cache-path resolution. (#5484)