Skip to content

[3.0] Give each action subclass its own instance in ActionTrait::load() - #9321

Open
albertlast wants to merge 1 commit into
SimpleMachines:release-3.0from
albertlast:fix/action-trait-subclass-instances
Open

[3.0] Give each action subclass its own instance in ActionTrait::load()#9321
albertlast wants to merge 1 commit into
SimpleMachines:release-3.0from
albertlast:fix/action-trait-subclass-instances

Conversation

@albertlast

Copy link
Copy Markdown
Collaborator

Note

This change was produced by an LLM. The code, the commit message and this
description were all written by Claude (Anthropic), driven by @albertlast. It has
not yet had human code review.

Everything stated below was verified by actually running it against a PostgreSQL
install of this branch, rather than only reasoned about. Even so, please review it
as untrusted work: the diagnosis may be right while the fix is not what SMF would
prefer stylistically or architecturally.

Description

A banned member who tries to log in gets a fatal error instead of being told they
are banned:

SMF\Actions\Login2::load(): Return value must be of type SMF\Actions\Logout, SMF\Actions\Login2 returned

ActionTrait keeps the cached instance in a static property:

protected static $obj;

public static function load(): static
{
    if (!isset(static::$obj)) {
        static::$obj = new static();
    }

    return static::$obj;
}

A static property is shared with every descendant class that does not redeclare it.
Eleven action classes extend another action, and none of them redeclare $obj:

AgreementAccept extends Agreement      NotifyBoard extends Notify
Groups extends ViewGroups              NotifyTopic extends Notify
Login extends Login2                   Post2 extends Post
LoginTFA extends Login2                Register2 extends Register
Logout extends Login2                  UnreadReplies extends Unread
NotifyAnnouncements extends Notify

So each of those pairs shares one slot. Once the parent has been loaded, isset()
is true for the child as well, and the child gets handed the parent's instance —
which does not satisfy the static return type.

The reachable case today is logging in. ?action=login2 loads Login2, and
Login2::DoLogin() reaches User::enforceBans(), which kicks a banned member with:

// Log the user out.
if ($force_logout) {
    User::setMe(0);
    Logout::call(true, false);
}

Logout::load() then returns the Login2 instance that is already sitting in the
shared slot, and the member sees the TypeError above rather than the ban notice.
They also are not logged out, since Logout::execute() never runs.

This checks that the cached instance is of the class being loaded, rather than merely
present. Keeping $obj as-is, rather than switching to per-class storage, keeps the
property's meaning unchanged for anything that reads it. In the rare request that
loads both a parent and a child action, the second load() simply constructs a new
instance; the constructors are empty, so there is nothing to lose.

How this was verified

PostgreSQL 17 / PHP 8.4.23.

The baseline was release-3.0 plus the pending fixes #9310#9317 and #9319, since a
non-admin cannot complete a login without those and so cannot reach this code at all.

With a cannot_access ban on the member:

  • Before: POST ?action=login2HTTP 500 with the TypeError above, and it
    recorded in the error log.
  • After: HTTP 403 showing "Sorry claudetest, you are banned from using this
    forum!" together with the ban reason, and the member is logged out —
    index.php afterwards returns HTTP 200 as a guest.

Since this touches the loader every action goes through, also swept a range of
actions as an ordinary logged-in member, deliberately including the subclassed ones:
recent, unread, unreadreplies, stats, who, profile, pm, search,
help, credits, agreement, notifyboard, a board view, the post form, and
finally logout. All HTTP 200 (logout 302), no errors logged.

php -l clean.

Relationship to other PRs

Sources/ActionTrait.php is not touched by any other open PR. It sits immediately
after #9319 in the same login flow: #9319 gets an ordinary member through
enforceBans(), and this one handles what happens when that member turns out to be
banned. They are independent changes to different files and merge cleanly together.

While confirming this, one unrelated warning shows up on the same path —
Undefined array key "email_address" at Sources/User.php:2087, when a ban matches
by member id rather than by email. Not addressed here; noted only so it is not
mistaken for a side effect.

Issues References (Fixes|Related|Closes)

  1. Related: [3.0] Only enforce bans that actually exist #9310, [3.0] Load permissions before applying bans in User::enforceBans() #9319

ActionTrait declares $obj as a static property, and a static property is
shared with every descendant class that does not redeclare it. None of
the eleven action classes that extend another action redeclare it, so
they all share one slot with their parent.

Once the parent has been loaded, load() finds that slot occupied and
returns the parent's instance, which does not satisfy the "static"
return type:

    SMF\Actions\Login2::load(): Return value must be of type
    SMF\Actions\Logout, SMF\Actions\Login2 returned

This is reachable during login: User::enforceBans() calls Logout::call()
to kick a banned member, by which point Login2 has already been loaded,
so a banned member gets a fatal error instead of being logged out.

Checks that the cached instance is of the class being loaded, rather than
merely present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants