Skip to content

e107 v2.3.11

Latest

Choose a tag to compare

@Deltik Deltik released this 07 Aug 20:32
· 936 commits to master since this release
v2.3.11

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.

Seventeen advisories accompany this release. Thirteen are new. Four are already-published advisories being amended in place: three of them, GHSA-3j33-c9v4-4p42, GHSA-5w63-63rh-99q6 and GHSA-92fr-7h4f-22pp, were fixed incompletely the first time, so if you upgraded for one of those you were not covered and are not covered until you upgrade again. The fourth, GHSA-87hm-vh32-7c3r, was fixed correctly for the route that was reported, and is amended because the same defect was then found in two endpoints that report did not cover.

Advisory Severity What it was
GHSA-376g-2pcx-p4x8 9.8 Critical A visitor with no account could run code on your server.
GHSA-3j33-c9v4-4p42
CVE-2026-48997
9.8 Critical Amended. The image resizer pasted a site preference straight into a shell command. The first fix escaped two values on that line and left the third.
GHSA-p2p8-9jwc-8985 9.3 Critical An anonymous visitor could store script in a page only administrators see.
GHSA-x6mx-9j79-rg8x 8.1 High Deleting your own forum post could delete any file the web server can write to, alongside five more forum permission failures.
GHSA-2qvf-jf25-5w68 8.0 High Untrusted content was written into pages without being encoded for where it landed.
GHSA-vgr2-p8wp-crr2 7.5 High On a site that lets members choose their own theme, any signed-in member could reach the theme manager and install a theme or a plugin.
GHSA-wpfx-95hf-26jc 7.5 High Restricted forums, downloads and media were served to people with no access to them.
GHSA-87hm-vh32-7c3r 7.5 High Amended. The download handler would read any file on the server; both thumbnail endpoints would serve any file that decodes as an image, from anywhere on the server. The first fix covered the download handler and neither thumbnailer.
GHSA-8h49-xpqr-224j 7.2 High An administrator holding only a delegated user-management permission could promote themselves to full administrator, or take over another administrator's account.
GHSA-7484-7876-mw5v 6.5 Medium Six endpoints did something on an anonymous caller's say-so, from stripping a user class to mailing your server environment.
GHSA-f4v9-vcf8-24p3 6.5 Medium Any member could send a private message as somebody else.
GHSA-46vx-phhg-m6h9 6.5 Medium Any member could read every other member's private message attachments by counting through the message IDs.
GHSA-5w63-63rh-99q6
CVE-2026-43934
6.5 Medium Amended. Any signed-in member could still edit anyone's comment. The first fix closed the AJAX route and left the legacy one.
GHSA-jxxm-5qpx-h42q 6.1 Medium A redirect that took its destination from the request could be pointed off your site, which is a ready-made phishing page on your domain.
GHSA-w5qr-xwc7-hq3r 5.3 Medium Two bundled TinyMCE endpoints performed no working permission check.
GHSA-gfh5-w9r2-3546 4.3 Medium Forum attachments were accepted without the upload permission and were fetchable by raw path.
GHSA-92fr-7h4f-22pp
CVE-2026-43936
4.3 Medium Amended. An outbound request was checked once and then allowed to follow a redirect anywhere at all.

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. (#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. (#5863, #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. (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. (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. (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. (GHSA-92fr-7h4f-22pp)
  • 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. (GHSA-87hm-vh32-7c3r)
  • 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. (#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. (GHSA-7484-7876-mw5v)
  • 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. (#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. (#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. (5ca7930)
  • 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. (a10f513)

Fixed

  • [Security] A visitor with no account could reach an admin page's form handling, and from there the shell. Three faults line up into one chain, and each is closed on its own account. (GHSA-376g-2pcx-p4x8)
    • 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.
    • 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.
    • 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.
  • [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. (#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. (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 same route rewrote a superior administrator's password and email address, which is an account takeover rather than an escalation. 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. (GHSA-8h49-xpqr-224j)
  • [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. (GHSA-vgr2-p8wp-crr2)
  • [Security] Restricted forums and downloads were disclosed to anonymous readers. A forum carries a user class and so does the category it sits under, and several queries filtered the forum without ever joining the parent, so everything inside a restricted category was served to whoever asked. (GHSA-wpfx-95hf-26jc)
    • The forum's RSS feed is the serious one, because it serves post bodies rather than titles, to a reader with no account.
    • The forum's list addon served titles the same way, and the download feed filtered the download's own user class but never the category's, so a public download inside a restricted category was disclosed.
    • The comment feed served items without their parent's user class and publication checks, disclosing restricted items to an anonymous reader.
    • 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. (GHSA-wpfx-95hf-26jc)
  • [Security] The forum's authorization checks did not hold, in six separate places. Each was reproduced against a running install before it was fixed, and each fix arrives with a test that fails without it. (GHSA-x6mx-9j79-rg8x, #5862)
    • 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.
    • "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.
    • 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.
    • 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.
    • 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.
    • 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. (GHSA-gfh5-w9r2-3546)
  • [Security] Private message attachments could be read by members they were not sent to. (GHSA-46vx-phhg-m6h9, GHSA-f4v9-vcf8-24p3)
    • 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.
  • [Security] Six endpoints did something on an anonymous caller's say-so. Each is reachable without an account. (GHSA-7484-7876-mw5v)
    • Unsubscribe accepted a POST with no token check at all, so an anonymous, tokenless request could strip any user class from any account. The verification existed; the POST branch simply skipped past it.
    • 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.
    • The contact form had four problems at once: no token, a CAPTCHA gate that was bypassed by leaving the field out rather than getting it wrong, submission processing that ran before the check deciding whether the form is available at all, and a recipient selector that did not re-apply the rules it had used to build its own list.
    • 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.
    • The poll accepted repeated and out-of-range answers, so a multi-option poll could be stuffed by a single anonymous request, and download mirrors were served without the "download active" check the file branch already applied.
  • [Security] Both thumbnail endpoints would serve any file that decodes as an image, from anywhere on the server. The published fix for this closed the download handler, which read any file at all, and neither thumbnailer. (GHSA-87hm-vh32-7c3r)
    • 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.
    • 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.
    • 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.
    • 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. (GHSA-jxxm-5qpx-h42q)
  • [Security] Untrusted content was written into pages without being encoded for where it landed. (GHSA-2qvf-jf25-5w68, GHSA-p2p8-9jwc-8985)
    • 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.
    • Alongside it, the online-users shortcode, five newsfeed outputs, the admin feed, which had no encoding of any kind, and the address echoed back by email.php.
    • 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. (GHSA-w5qr-xwc7-hq3r)
  • [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. (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. (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. (#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 <div data-x="<script>"> 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. (#5863, #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. (a10f513)
  • 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. (#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. (#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. (#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. (#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. (#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. (#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. (6a28dc2)
  • 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. (9263a83)
  • 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. (e4dcefd)
  • {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. (cdca499)
  • 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. (1260398)
  • 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. (1b6d3b2)
  • 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. (ef1de0a)
  • 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. (aaa75e2)

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. (#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. (GHSA-jxxm-5qpx-h42q)
  • e107forum::permList(), visibleForumIds(), and threadVisibleSql() express the forum's visibility rule once. The feeds and listings now ask the forum rather than each rebuilding the same two-leg user class test; three copies of one permission rule is how that defect arrived. (GHSA-wpfx-95hf-26jc)
  • 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. (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. (GHSA-gfh5-w9r2-3546)

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 <script> 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. (GHSA-2qvf-jf25-5w68)
  • 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. (GHSA-376g-2pcx-p4x8)
  • 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. (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. (#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. (#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. (#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. (#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. (GHSA-87hm-vh32-7c3r)
  • The menu manager's links are ordinary query strings. ?enc=<base64> 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. (#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. (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. (#5868)

Fixed

  • resolveSendPath() no longer fatals when called outside a class2 bootstrap, which is how the legacy thumbnail entry point reaches it. (GHSA-87hm-vh32-7c3r)
  • Test coverage across the range, and the seams it needed. The forum plugin had no acceptance coverage at all before this release, which is why several of the authorization defects above sat unnoticed for years. (#5862, fe95a99)
    • 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, @orionhridoy, and @scgajge12, whose earlier reports are the four advisories amended above. Three of those fixes were incomplete at the time and this is the release that closes them; the fourth was correct for the route reported, and is amended because the same defect turned up in endpoints that report did not reach.

Full changelog: v2.3.10...v2.3.11