Skip to content

Various security improvements#1473

Merged
LukeTowers merged 4 commits intodevelopfrom
fix/security-improvements
Apr 2, 2026
Merged

Various security improvements#1473
LukeTowers merged 4 commits intodevelopfrom
fix/security-improvements

Conversation

@LukeTowers
Copy link
Copy Markdown
Member

@LukeTowers LukeTowers commented Apr 1, 2026

Summary by CodeRabbit

  • New Features

    • Added a dedicated "My Account" page for users to edit their own account and consolidated self-edit routes to /backend/myaccount.
  • Bug Fixes

    • Tightened authorization around user management (superuser protection, delete/restore/unsuspend controls) and adjusted permission-denial messaging.
    • Strengthened AJAX/postback handler validation for safer request handling.
  • Tests

    • Added extensive tests covering authorization and postback/handler validation.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 1, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 632da553-c773-4f5d-9eb2-250257d35bc5

📥 Commits

Reviewing files that changed from the base of the PR and between 9aabe55 and 2b1bb7c.

📒 Files selected for processing (2)
  • modules/backend/controllers/Index.php
  • modules/backend/controllers/Users.php

Walkthrough

Extracts "my account" UI into a dedicated MyAccount controller and form, centralizes AJAX/postback handler-name validation in CMS and Backend controllers, and adds centralized user-management authorization via User::canBeManagedByUser(), plus corresponding tests and translation key updates.

Changes

Cohort / File(s) Summary
MyAccount Controller & Views
modules/backend/controllers/MyAccount.php, modules/backend/controllers/myaccount/config_form.yaml, modules/backend/controllers/myaccount/index.php, modules/backend/ServiceProvider.php
Adds a new MyAccount controller, form config and view; updates settings/navigation route from backend/users/myaccount to backend/myaccount.
Users Controller
modules/backend/controllers/Users.php
Removes myaccount() action body and myaccount_onSave() handler; stops nullifying required permissions for the myaccount action; redirects self-edit targets to backend/myaccount.
Handler Name Validation
modules/backend/classes/Controller.php, modules/cms/classes/Controller.php
Adds protected validateHandlerName(string $handler) to enforce AJAX handler-name regex and uses it in postback/AJAX flows before handler execution.
User Model Authorization
modules/backend/models/User.php
Introduces canBeManagedByUser() and applies it in beforeSave(), beforeDelete(), beforeRestore(), unsuspend(), and getResetPasswordCode() to enforce management permissions; adjusts thrown translation key to user.cannot_manage_user.
Translations
modules/backend/lang/en/lang.php
Replaced user.superuser_grant_denied and user.manage_users_denied with a single user.cannot_manage_user message.
Tests: Controller Postback Validation
modules/backend/tests/classes/ControllerPostbackTest.php, modules/cms/tests/classes/ControllerPostbackTest.php
Adds tests that mock AJAX and postback requests to verify invalid handler names are rejected (assert SystemException) and valid names pass validation.
Tests: MyAccount & User Authorization
modules/backend/tests/controllers/MyAccountTest.php, modules/backend/tests/models/UserAuthorizationTest.php
Adds tests ensuring MyAccount controller requires no specific permission and comprehensive User model authorization tests (canBeManagedByUser, beforeSave/delete/restore, unsuspend, getResetPasswordCode).
Index Permission Redirect
modules/backend/controllers/Index.php
Updates permission-denied redirect target to backend/myaccount when no main menu items exist.

Sequence Diagram

sequenceDiagram
    participant Client
    participant MyAccount as MyAccount Controller
    participant FormBehavior as FormController Behavior
    participant User as User Model
    participant Auth as BackendAuth

    Client->>MyAccount: POST index_onSave (form submit)
    MyAccount->>FormBehavior: delegate update_onSave($userId, 'myaccount')
    FormBehavior->>User: beforeSave() (authorization)
    User->>User: canBeManagedByUser(actor)
    alt Authorization Denied
        User-->>FormBehavior: throw AuthorizationException
        FormBehavior-->>Client: error response
    else Authorization Allowed
        User-->>FormBehavior: save succeeded
        FormBehavior-->>MyAccount: return result
        MyAccount->>MyAccount: detect login/password change
        alt Credentials changed
            MyAccount->>Auth: BackendAuth::login($user->reload(), true)
            Auth-->>MyAccount: session refreshed
        end
        MyAccount-->>Client: success response
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.97% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Various security improvements' is vague and generic, using the non-descriptive term 'various' that fails to convey the specific nature of the changeset. Replace with a more specific title that highlights the main security concern, such as 'Add authorization and handler validation security checks' or 'Refactor MyAccount controller and strengthen user authorization'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/security-improvements

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (2)
modules/backend/tests/controllers/MyAccountTest.php (1)

12-28: Add coverage for My Account save re-authentication behavior.

Current tests validate permission wiring only. Consider adding a test that exercises index_onSave() when login/password changes to lock in the security-critical re-login flow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/backend/tests/controllers/MyAccountTest.php` around lines 12 - 28,
Add a new test that simulates changing the current user's login/password and
asserts the controller enforces re-authentication: create a UserFixture and
actingAs($user), instantiate MyAccount, populate request data with a different
login and/or new password, call MyAccount->index_onSave(), then assert the
post-save behavior enforces re-login by checking auth()->guest() (or that the
user was logged out) and that the response/session/flash contains the
re-authentication indicator (e.g., a 'relogin' flag or redirect to the login
route); reference MyAccount::index_onSave(), UserFixture and auth() in the test
to locate relevant code.
modules/backend/tests/models/UserAuthorizationTest.php (1)

20-32: Re-guard the model in a finally block.

Model::unguard() flips global state for the whole suite. If User::create() throws in setUp(), Model::reguard() never runs and later tests can start passing or failing for the wrong reason.

♻️ Suggested change
-        Model::unguard();
-        $this->targetUser = User::create([
-            'first_name' => 'Target',
-            'last_name' => 'User',
-            'login' => 'targetuser',
-            'email' => 'target@test.com',
-            'password' => 'TestPassword1',
-            'password_confirmation' => 'TestPassword1',
-            'is_activated' => true,
-            'is_superuser' => false,
-        ]);
-        Model::reguard();
+        Model::unguard();
+        try {
+            $this->targetUser = User::create([
+                'first_name' => 'Target',
+                'last_name' => 'User',
+                'login' => 'targetuser',
+                'email' => 'target@test.com',
+                'password' => 'TestPassword1',
+                'password_confirmation' => 'TestPassword1',
+                'is_activated' => true,
+                'is_superuser' => false,
+            ]);
+        } finally {
+            Model::reguard();
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/backend/tests/models/UserAuthorizationTest.php` around lines 20 - 32,
In setUp(), Model::unguard() is currently called without ensuring
Model::reguard() always runs; wrap the unguard/create/related code in a
try/finally so that Model::reguard() is executed in the finally block even if
User::create() (or any subsequent setup code) throws—keep the existing calls to
Model::unguard(), User::create(), and Model::reguard() but move the reguard into
the finally section to guarantee global state is restored after setUp().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@modules/backend/classes/Controller.php`:
- Around line 489-494: validateHandlerName currently assumes $handler is a
string and calls preg_match, which will raise a TypeError for arrays; update
validateHandlerName to explicitly check the type before calling preg_match (e.g.
if (!is_string($handler)) throw new SystemException(...)), then perform the
existing preg_match check and throw the same SystemException on failure;
reference validateHandlerName, preg_match, and SystemException (and keep the
Lang::get('backend::lang.ajax_handler.invalid_name', ['name' => $handler])
message) so array or non-string payloads (such as from post('_handler')) are
converted into the expected graceful SystemException.

In `@modules/backend/controllers/MyAccount.php`:
- Around line 73-74: $post('User[password]') can be non-string and calling
strlen(...) will throw a TypeError and the current boolean only checks non-empty
rather than an actual change; update the $passwordChanged logic to first ensure
the posted value is a string (use is_string on post('User[password]')), check
it's non-empty, and then compare it to the existing value appropriately (e.g.
compare raw values or verify against the stored hash via the user password
field) so $passwordChanged becomes true only when a valid string password was
supplied and it actually differs from $this->user->password.

In `@modules/backend/controllers/Users.php`:
- Around line 122-124: The impersonation redirect in
Users::update_onImpersonateUser() still points to "backend/users/myaccount" but
the self-edit route was changed to "backend/myaccount" (see the self-edit check
using $context != 'myaccount' and $recordId == $this->user->id); update the
redirect target in update_onImpersonateUser() to "backend/myaccount" so
impersonated regular users are sent to the new My Account URL and avoid the
backend.manage_users permission check.

In `@modules/backend/models/User.php`:
- Around line 131-155: canBeManagedByUser currently treats
BackendAuth::getUser() being null as fully authorized; change it to only allow
null/anonymous actor when running in trusted console/queue contexts. In the
canBeManagedByUser(?User $user = null) method, after resolving $user = $user ??
BackendAuth::getUser(), if $user is null return true only when the process is a
CLI/queue worker (e.g. App::runningInConsole() or other project-specific
queue/runtime check) and otherwise return false; preserve the existing
backend.manage_users and isSuperUser checks for real users; update the docblock
to reflect the new behavior.

In `@modules/backend/tests/classes/ControllerPostbackTest.php`:
- Around line 99-115: The test testAjaxPathAcceptsValidHandlerName currently
only uses a syntactically valid name ('onSave') and catches any SystemException,
so it doesn't prove a real handler is accepted; update the test to either target
an actual AJAX handler that exists on the Auth controller (replace
configAjaxRequestMock('onSave') with a real handler name implemented on Auth,
e.g., configAjaxRequestMock('onSignin') or add a simple public onXxx handler to
Auth) so run('signin') proceeds to the handler, or tighten the assertion to
expect the specific fallback exception message you know will be thrown when the
handler is missing (assertEquals or assertStringContainsString against 'Handler
not found') to prove name validation passed rather than swallowing all
SystemException types. Ensure you modify the test method
testAjaxPathAcceptsValidHandlerName and adjust the Request::swap call and
assertions accordingly.

In `@modules/cms/classes/Controller.php`:
- Around line 694-698: The validateHandlerName method currently has a strict
string signature and will raise a TypeError for non-string _handler inputs;
update validateHandlerName (and callers that pass post('_handler')) to first
check that the incoming value is a string (e.g., is_string($handler)) and if not
throw the same SystemException with
Lang::get('cms::lang.ajax_handler.invalid_name', ['name' => $handler]) and HTTP
400; keep the existing regex validation for string values so only actual
non-string inputs are converted into the intended 400 invalid-handler response
instead of a 500 TypeError.

In `@modules/cms/tests/classes/ControllerPostbackTest.php`:
- Around line 116-131: The test testAjaxPathAcceptsValidHandlerName currently
swallows all SystemException from Controller::run, which masks unrelated
failures; change it to let unexpected exceptions fail or explicitly assert the
handler's observable effect instead of blanket-catching. Specifically, remove
the try/catch around $controller->run('/ajax-test') (or if you must catch,
rethrow unless strpos($e->getMessage(), 'Invalid AJAX handler name') !== false)
and replace the final assertTrue(true) with an assertion that verifies the
onTest handler ran (e.g., expected response, header, or state change produced by
the ajax-test.htm onTest handler). Ensure you reference
testAjaxPathAcceptsValidHandlerName and Controller::run when updating the test.

---

Nitpick comments:
In `@modules/backend/tests/controllers/MyAccountTest.php`:
- Around line 12-28: Add a new test that simulates changing the current user's
login/password and asserts the controller enforces re-authentication: create a
UserFixture and actingAs($user), instantiate MyAccount, populate request data
with a different login and/or new password, call MyAccount->index_onSave(), then
assert the post-save behavior enforces re-login by checking auth()->guest() (or
that the user was logged out) and that the response/session/flash contains the
re-authentication indicator (e.g., a 'relogin' flag or redirect to the login
route); reference MyAccount::index_onSave(), UserFixture and auth() in the test
to locate relevant code.

In `@modules/backend/tests/models/UserAuthorizationTest.php`:
- Around line 20-32: In setUp(), Model::unguard() is currently called without
ensuring Model::reguard() always runs; wrap the unguard/create/related code in a
try/finally so that Model::reguard() is executed in the finally block even if
User::create() (or any subsequent setup code) throws—keep the existing calls to
Model::unguard(), User::create(), and Model::reguard() but move the reguard into
the finally section to guarantee global state is restored after setUp().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 987edcfe-aaa5-47e9-bcbb-1548db08ec9c

📥 Commits

Reviewing files that changed from the base of the PR and between 164c625 and 9aabe55.

📒 Files selected for processing (13)
  • modules/backend/ServiceProvider.php
  • modules/backend/classes/Controller.php
  • modules/backend/controllers/MyAccount.php
  • modules/backend/controllers/Users.php
  • modules/backend/controllers/myaccount/config_form.yaml
  • modules/backend/controllers/myaccount/index.php
  • modules/backend/lang/en/lang.php
  • modules/backend/models/User.php
  • modules/backend/tests/classes/ControllerPostbackTest.php
  • modules/backend/tests/controllers/MyAccountTest.php
  • modules/backend/tests/models/UserAuthorizationTest.php
  • modules/cms/classes/Controller.php
  • modules/cms/tests/classes/ControllerPostbackTest.php

@LukeTowers LukeTowers self-assigned this Apr 2, 2026
@LukeTowers LukeTowers added this to the 1.2.13 milestone Apr 2, 2026
@LukeTowers LukeTowers merged commit 6e3156d into develop Apr 2, 2026
14 checks passed
@LukeTowers LukeTowers deleted the fix/security-improvements branch April 2, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant