fix(security): close unauthenticated RCE chain (Patchstack, CVSS 9.8) - #658
Merged
arifulhoque7 merged 1 commit intoAug 19, 2026
Conversation
Patchstack report (CVSS 9.8) chained three defects into unauthenticated
remote code execution.
Router fails closed
check_permission() treated a route with no ->permission() as public, so
every forgotten permission chain was an unauthenticated endpoint. It now
denies by default; genuinely public endpoints opt in through the new
Public_Access permission class.
Trello import routes are gated
routes/trello.php declared 16 routes with no permission at all. They drive
the admin Tools import, so they now require Settings_Page_Access, matching
the AdminRoute guard on the ToolsPage screen. get-mime-type-icon now
requires Authentic.
Administrator permission actually checks
Core\Permissions\Administrator::check() returned true unconditionally,
which made every route using it public as well. It now mirrors the Pro
class and checks manage_options.
No hard-coded password
Import_helper::save_imported_user() created WordPress accounts with
md5('123456#'). Imported users now get a random password and the standard
new-user notification so they set their own.
PHP object injection
Task_Controller::privacy() and Milestone_Controller::privacy() wrote the
raw is_private request param into pm_meta, and Task::include_metas() read
it back through unserialize(). Both controllers now cast to int, and every
unserialize() of stored plugin data goes through wedevs_pm_safe_unserialize()
which passes allowed_classes => false, so a serialized object can never
instantiate a POP gadget.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes weDevsOfficial/pm-pro#455
Fixes the Patchstack report (CVSS 9.8, unauthenticated RCE) against WP Project Manager <= 4.0.6, plus everything the same sweep turned up in the Free plugin.
The reported chain
The report chained three defects into unauthenticated remote code execution:
co_worker) injects a serialized PHP object,What was wrong and what changed
1. The router failed open
core/Router/WP_Router.php::check_permission()Any route that forgot its permission chain was reachable without authentication. It now returns a
WP_Errorwithrest_authorization_required_code(). Endpoints that genuinely have to stay public opt in explicitly through the newWeDevs\PM\Core\Permissions\Public_Accessclass instead of relying on omission — none in Free need it today, but the Pro webhook does.2.
routes/trello.phpdeclared 16 routes with no permissiontrello,trello/test,trello/get_user,trello/get_boards,trello/get_lists,trello/get_cards,trello/get_subcards,trello/get_users— GET and POST each. They drive the Trello import: the caller supplies their own Trelloapp_key/app_token, so the plugin happily imports an attacker's board on an unauthenticated request.All 16 now require
Settings_Page_Access, matching theAdminRouteguard already on the Tools screen that calls them.GET get-mime-type-iconinroutes/file.phpwas also ungated; it now requiresAuthentic.3.
Core\Permissions\Administratornever checked anythingEvery route using it was public too (
pusher/test). It now checksmanage_options, mirroring the Pro class of the same name.4. Hard-coded password
src/Imports/Helpers/Import_helper.php::save_imported_user()created accounts withmd5('123456#')=dcf877b18896c5fa70fbbd4dc9c8adb0(CWE-798) and added them to the imported project asco_worker. It worked even with "Anyone can register" disabled.Imported users now get
wp_generate_password( 24, true, true )and the standard new-user notification so they set their own password.wp_insert_user()errors are skipped instead of being passed on as a user id.5. PHP object injection
Write side —
Task_Controller::privacy()andMilestone_Controller::privacy()stored the rawis_privaterequest param inpm_meta. Both nowintval()it, matching whatDiscussion_Board_Controlleralready did.Read side —
Task::include_metas()ran@unserialize()on that stored value, so the attacker chose the class and property values of an object the plugin instantiated. Everyunserialize()of stored plugin data now goes through the newwedevs_pm_safe_unserialize(), which passesallowed_classes => false:A serialized object degrades to
__PHP_Incomplete_Class, which has no magic methods to fire — so even if a new write path leaks in later, there is no gadget to reach. Applied toTask/Helper/Task.php,Common/Models/Meta.php,Activity/Models/Activity.php,Activity/Helper/Activity.php(7 call sites),Comment/Models/Comment.php,My_Task/Controllers/MyTask_Controller.php, andSettings/Models/Settings.php.Proof
Unauthenticated, before → after (local
we-pm.test, WP 6.x, PHP 7.4):Authenticated admin, still working:
Object injection: a task's
privacymeta can no longer hold anything but an int, and even a pre-existing poisoned row now deserializes to__PHP_Incomplete_Classrather than instantiating the gadget.Not broken
AdminRoute, so the gate matches the UI).0/1— which is all ofviews/assets/src.wedevs_pm_safe_unserialize()returns arrays and scalars exactly asmaybe_unserialize()did, and hands back the raw string when the payload is not serialized, so stored settings, activity meta and comment mentions keep working.vendor/bin/phpcsclean on every touched file;php -lclean under PHP 7.4 (the version inreadme.txt).