Skip to content

Fixes from Codex review rounds 2-4 and the deferred ban-group tracking - #38

Open
bonelifer wants to merge 15 commits into
masterfrom
codex-review-round-2
Open

bonelifer wants to merge 15 commits into
masterfrom
codex-review-round-2

Conversation

@bonelifer

Copy link
Copy Markdown
Contributor

Summary

Three more Codex-assisted review passes against master after #36 (each explicitly told to independently re-verify all prior fixes, not just look for new things), plus the ban-side tracking fix that rounds 2/3 both explicitly deferred as out of scope. Every finding across all three rounds was independently re-verified against actual phpBB core source or a real running phpBB 3.3.x install before being acted on — several of Codex's own claims turned out to be false leads and weren't acted on; several others caught real regressions in my own prior fixes.

Round 2 — 2 regressions in #36's fixes, 4 new issues

  • register m_banhammer_del_posts_all with core.permissions — the ACP permission editor only shows permissions registered that way; the permission worked at the ACL-check level but was invisible/unrevokable in the UI.
  • re-validate move/restrict groups at use time, not just save — a group already configured as the target could later become founder-managed without either action-handler re-checking.
  • undo_bh_group compares the restriction's own recorded group — the Security and correctness fixes from a Codex-assisted review #36 shared-group fix compared current config, which stopped protecting an active restriction the moment either setting changed.
  • check group_user_add()'s result and guard against concurrent restrictions — a failed/no-op group action still got recorded as a successful restriction; two moderators restricting the same user in a narrow window could create conflicting tracking rows (new unique index + result-checking).
  • stop wiping unrelated account data when del_posts has no effect — the Security and correctness fixes from a Codex-assisted review #36 permission filter only gated which posts got touched, not the account-wide cleanup below it.
  • reject self-service restrict groups, preserve the SFS key without cURL — Open/Free groups let a member resign unilaterally via UCP; also fixed the SFS key getting silently blanked on any settings save while cURL is disabled.

Round 3 — re-verified all of round 2, found 5 more

  • deduplicate leftover restriction rows before adding the unique index — round 2's new unique index would fail outright against duplicate rows the very race it closes off could already have created.
  • reject Open/Free restrict groups at use time too, not just save — the use-time re-check didn't carry over the Open/Free check from the same round's save-time fix.
  • track whether a restriction created its own group membership — GROUP_USERS_EXIST handling didn't distinguish "this restriction added the membership" from "the user already had it", so expiry/purge could strip a pre-existing membership.
  • the same fallback didn't check its own result — a pending join request was recorded as a successful restriction.
  • del_posts cleanup still ran for a target with zero posts — the round 2 fix only covered "posts existed but were all filtered out."
  • hide Open/Free groups from the restrict-group dropdown — cosmetic follow-up so the dropdown doesn't offer choices its own validator rejects.

Round 4 — re-verified all of round 3, found 4 more (2 of them genuine errors in my own round 3 reasoning)

  • corrected legacy-row default and added a purge fallback for restrict_new_membership — round 3's backfill wrongly assumed pre-fix code "always resulted in a fresh membership" (it didn't — the result was ignored entirely before round 2), and moving purge-restoration into the new migration assumed it would always be installed by purge time, which isn't guaranteed if a site upgrades files without re-enabling first.
  • applied the empty-posts guard unconditionally, stopped wiping topics_posted — the guard was nested inside one permission branch only; and a blanket TOPICS_POSTED_TABLE delete undid phpBB's own correct per-topic bookkeeping for posts that survived permission filtering.

The deferred issue, now fixed: undo_bh_group per-ban group tracking

Bans never had per-action tracking of which group a ban actually moved someone into — the same class of bug the restrict feature had (and got fixed twice above), explicitly deferred until the tracking pattern was mature. New banhammer_ban_group table (unique index built in from the start this time, learning from restrict_group's retrofit pain), a backfill for anyone currently banned-and-moved under the old code, and undo_bh_group() rewritten to use per-user tracking instead of comparing against current config — which also means it now correctly restores the user's actual original default group on unban, something the old code never did at all.

Test plan

  • php -l on every changed/added PHP file after every commit
  • Full enable → purge → enable cycle against a real phpBB 3.3.x + SQLite3 + PHP 8.4 install after every batch of fixes, for the complete final migration chain (13 migrations)
  • Verified the unique index rejects a concurrent duplicate insert via phpBB's own DBAL, not an uncaught error
  • Seeded a real pre-existing-membership scenario and confirmed purge restores the default group while leaving pre-existing group membership untouched, versus a normal restriction's membership being correctly removed
  • Simulated a purge before restrict_membership_column was ever installed and confirmed the fallback in the older migration correctly restores the default group while conservatively leaving membership alone
  • Seeded a currently-banned-and-moved user with no tracking row, confirmed the backfill migration creates one correctly, and confirmed both the legacy (conservative) and a fresh ban+move both clean up correctly on unban
  • Ran the project's own AI extension validator prompt (repos/misc/validate.prompt.md) against the full branch: composer validate passes, php -l clean repo-wide, zero trailing whitespace, all 64 language keys used with no dead keys or missing definitions, all four core.* events verified against the current events list. Two non-blocking [valinfo] items noted (queries in loops on a low-volume cron/migration path; touching an already-merged migration's never-yet-executed revert path) — no [valdeny] findings.

Not fixed here: nothing remains deliberately deferred.

cc @kaileymsnay — same as #37, flagging since this is another large batch touching the extension's migration chain and permission system.


Investigated and written by Claude on behalf of William Jacoby (bonelifer).

bonelifer and others added 15 commits September 22, 2026 18:50
phpBB's ACP permission editor filters every screen's permission list
through \phpbb\permissions::permission_defined(), which only recognizes
permissions registered via the static array or a core.permissions event
- not merely anything present in phpbb_acl_options. The migration that
creates and grants this permission never registered it, so it worked
correctly at the ACL-check level but was invisible and unrevokable
through the normal ACP UI. Confirmed via a real container instance:
permission_defined('m_banhammer_del_posts_all') was false before this
listener, true after. Matches the pattern phpBB's own phpbb/pages
extension uses for its custom a_pages permission.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… save

admin_controller's founder-group check only ran when a group was
selected in the ACP. It never protected the case where a group already
configured as the move/restrict target later becomes founder-managed -
an admin flipping group_founder_manage on an existing group for
unrelated reasons - since do_ban_hammer_stuff()/do_restrict_stuff()
never re-checked before actually moving a user into it. A moderator
with only m_ban could then still grant a target account founder-managed
group membership via that stale configuration.

Both now re-validate through a shared safe_group_name() helper
immediately before use, treating a deleted or now-founder-managed group
exactly like "no group configured" rather than trusting a value only
validated when it was saved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
My earlier fix for the shared ban/restrict group bug compared the
*current* bh_restrict_group_id and bh_group_id config values. If either
setting is edited while a restriction created under the old matching
values is still active, the guard stops applying - even though the
user's own tracking row still points at the group now equal to
bh_group_id - so cleanup removes them prematurely again, just via a
different trigger than the original bug.

active_restriction() now also selects restrict_group_id, and
undo_bh_group() checks that against bh_group_id directly, independent
of whatever bh_restrict_group_id currently is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ictions

Two related bugs in do_restrict_stuff()'s final step:

- The tracking row was inserted before calling group_user_add(), whose
  return value was then discarded entirely. If the target happened to
  already be a member of the restrict group for an unrelated reason,
  group_user_add() returns GROUP_USERS_EXIST before reaching the code
  that sets the default group - so the tracking row would claim an
  active restriction that never actually changed the user's default
  group. Now the insert only happens after checking the result:
  GROUP_USERS_EXIST falls back to group_user_attributes('default', ...)
  directly (same fix already used in restriction_expiry's restore path);
  any other non-false result removes the just-inserted row rather than
  leaving a restriction on record that isn't actually in place.

- Two moderators confirming a restriction on the same user within the
  same narrow window could both pass the active_restriction() check
  before either commits, since checking and inserting were two separate,
  unsynchronized steps and the table's user_id index wasn't unique. Both
  would insert their own tracking row (typically for the same
  bh_restrict_group_id, since it's one shared config value); when the
  shorter of the two expires, cron would then remove that shared group
  membership out from under the still-active longer restriction.

  The new migration replaces the plain user_id index with a unique one.
  The insert in do_restrict_stuff() now runs under sql_return_on_error()
  and checks get_sql_error_triggered(): the loser of the race gets the
  same "already has an active restriction" message instead of a second,
  conflicting row (verified directly against phpBB's own DBAL - a
  duplicate insert is caught as get_sql_error_triggered() === true, not
  an uncaught SQL error).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The forum-permission filter added for the cross-forum deletion fix only
gated $posts (and, through it, the reports-closing logic). The
unconditional cleanup below - bookmarks, drafts, forum tracking/watch
rows, moderator cache, notifications, topics-posted records - still ran
regardless. A moderator without m_banhammer_del_posts_all or m_delete in
any forum the target posted in could select "delete posts", have zero
posts actually deleted, and still wipe all of that unrelated account
data as a side effect.

Now bails out before any of it when there were posts to consider but
none were left after permission filtering, leaving a fully-blocked
del_posts request with no effect at all, matching what its own
permission denial should mean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…hout cURL

Two independent gaps in admin_controller's settings handling:

- Nothing stopped an admin from selecting an Open- or Free-type group as
  the restrict target. phpBB's own UCP lets a member resign from either
  type unilaterally (includes/ucp/ucp_groups.php only blocks resignation
  for Closed/Hidden/Special groups) - a restricted user could just leave
  via UCP and regain their previous permissions while Ban Hammer's own
  tracking row still shows them as restricted. A banned user can't log
  in to do the same, so this only applies to the restrict group, not the
  ban move-to group. validate_group() now takes a $reject_self_service
  flag, set only for the restrict group.

- The SFS API key and "allow HTTP" fields are hidden from the ACP form
  entirely when cURL isn't available (see the template's <!-- IF
  SFS_CURL --> guard), so they're simply absent from the submitted data
  in that case. set_options() wrote them unconditionally regardless,
  which silently blanked the stored SFS key on any unrelated settings
  save while cURL was disabled. Both are now only written when cURL is
  available, matching what the form actually offers to submit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Codex/Lumo review docs (e.g. codex-review-2026-09-22.md) are working
notes kept locally alongside the repo, not committed - matches the
existing untracked docs/codex-review-2026-09-22.md and
docs/lumo-review-2026-09-22.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 2's founder-managed-group re-check (safe_group_name()) didn't
fetch or validate group_type at all, so it never covered the Open/Free
self-resignation gap round 2 also fixed - but only at ACP-save time.
A site upgrading with an Open restrict group already configured, or an
admin changing an existing Closed restrict group to Open afterward,
would still let do_restrict_stuff() use it: the restricted user could
then resign via UCP (confirmed against includes/ucp/ucp_groups.php,
same as the save-time fix) while the tracking row stays behind.

safe_group_name() now takes the same $reject_self_service flag
validate_group() does, passed true from the restrict-group call site
only (a banned user can't log in to self-resign, so this doesn't apply
to the ban move-to group).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two related gaps in do_restrict_stuff()'s handling of an already-existing
group membership (group_user_add() returning GROUP_USERS_EXIST):

- The tracking row never recorded whether the restriction itself put the
  user in the restrict group, or whether they already belonged to it for
  an unrelated, legitimate reason. Both restriction_expiry's expiry path
  and the purge migration's restoration unconditionally removed the
  membership either way - stripping a membership the restriction never
  granted in the first place, and one the user may have had permanent,
  independent reasons to keep. A new restrict_new_membership column
  records which case applied per restriction; expiry and purge now only
  call group_user_del() when it's set. Verified end-to-end against a
  real install: a user who already belonged to the restrict group kept
  that membership after purge, while a normal restriction's membership
  was correctly removed.

- The GROUP_USERS_EXIST fallback (group_user_attributes('default', ...))
  didn't check its own result. That function explicitly only sets the
  default group for *approved* members (confirmed by reading
  functions_user.php directly) - a pending join request returns
  NO_USERS and changes nothing, so a restriction targeting a pending
  member silently never took effect while still being recorded as
  successful. Now checked the same way the outer group_user_add() result
  already was, and treated as a failure requiring the tracking row to be
  removed rather than left claiming a restriction that isn't in place.

The purge-restoration logic moved from restrict_group_id_column.php (an
earlier migration, whose own revert runs *after* this one - migrations
revert newest-first) to the new restrict_membership_column.php, since it
needs to read restrict_new_membership before it's dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
My earlier fix for the permission-filtered-to-nothing case only guarded
against "there were posts, but permission filtered out all of them" -
it left the unrelated account-wide cleanup (bookmarks, drafts,
notifications, ...) running unconditionally whenever the target had
zero posts from the start, regardless of whether the acting moderator
had any legitimate post-deletion authority at all.

Simplified to gate on the filtered result alone (empty($posts)), which
covers both cases the same way: a moderator with no relevant delete
authority now gets no effect at all, whether that's because every post
was blocked or because there was nothing to delete in the first place.
A moderator with authority over at least one of the target's posts
still gets the full account cleanup, unchanged from how del_posts has
always worked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ndex

restrict_unique_user's unique index closes off a concurrent-restriction
race, but that race is exactly what could already have left more than
one tracking row for the same user_id on a site upgrading from before
the fix. Creating a unique index over pre-existing duplicates fails
outright and blocks the whole extension upgrade - reproduced directly:
"UNIQUE constraint failed: banhammer_restrict.user_id".

phpBB applies a migration's own update_schema() before its update_data(),
so the cleanup can't live inside restrict_unique_user itself; it has to
run in a migration that completes first. The new restrict_dedupe
migration keeps the most recently created row per duplicated user_id and
discards the rest - which one "actually won" the original race isn't
recoverable at this point, and every real install should have zero
duplicates to begin with. Verified the exact SQL directly: it correctly
collapses duplicates, and a unique index then creates successfully
against the result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both the move-group and restrict-group selects shared one generator,
which only ever hid special/founder-managed groups. Selecting an
Open/Free group for the restrict target - still offered in the
dropdown - now gets rejected by validate_group() with a generic
FORM_INVALID, with no indication of why. get_groups() takes the same
$reject_self_service flag validate_group() already has, passed true
only for the restrict-group dropdown, so it no longer offers a choice
its own validator would reject.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…_new_membership

Two problems in round 3's restrict_new_membership tracking, both found by
re-verifying its own reasoning against the actual pre-fix code history:

- The backfill defaulted every existing row to 1 (true), on the claim
  that pre-fix code "always resulted in a fresh membership". That's
  wrong: before round 2 added any group_user_add() result checking at
  all, the tracking row was inserted unconditionally and the result was
  ignored outright - a user already belonging to the restrict group
  before being "restricted" still got a tracking row, with no way to
  tell afterward which case applied. A site upgrading directly from
  master (which is exactly what happens when this branch merges) could
  have exactly such rows. Default is now 0 (assume NOT created by this
  restriction): leaving a genuinely ban-hammer-created membership in
  place a little longer than ideal is a much smaller problem than
  stripping one the restriction never granted.

- Moving the purge-restoration logic into the new migration assumed a
  site would always have it installed by the time it purges. phpBB's
  migrator only reverts migrations that are actually recorded as
  installed - a purge that happens after the extension's files were
  upgraded but before the extension was re-enabled (which is what
  actually runs new migrations) would never install
  restrict_membership_column at all, so its revert_data() never runs,
  and restrict_group_id_column (the migration that IS installed) no
  longer had any restoration logic after round 3 removed it - a purge
  in that state would silently drop the tracking table with restrictions
  never restored, worse than before either migration existed.
  restrict_group_id_column now has its own restoration fallback, which
  checks whether restrict_new_membership exists and behaves
  conservatively (never removes membership) when it doesn't. When
  restrict_membership_column DOES run normally, its own revert already
  emptied the table first, making this fallback a harmless no-op.

Verified both directly: seeded a real install missing
restrict_membership_column entirely (deleted its migration record,
dropped the column) with an active restriction, and confirmed purge
still restores the default group while conservatively leaving group
membership alone; a normal full-upgrade purge/enable cycle still works
end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…posted

Two remaining gaps in bh_del_posts(), found by re-checking round 3's own
fixes rather than assuming they covered every path:

- The empty($posts) early return was nested inside the
  !acl_get('m_banhammer_del_posts_all') branch, so a moderator who DOES
  hold that permission skipped it entirely - a target with zero posts
  still triggered the full unrelated account-data cleanup (bookmarks,
  drafts, notifications, ...) even though delete_posts() had nothing to
  do. Moved the check outside the permission branch so it applies
  regardless of which path granted access; behavior for an actual,
  non-empty deletion is unchanged either way.

- The unconditional TOPICS_POSTED_TABLE delete removed the user's
  "posted in this topic" marker for every topic they've ever posted in,
  including ones the permission filter left untouched (their post there
  survives, but the marker saying they posted there doesn't). Confirmed
  by reading functions_admin.php directly: delete_posts() already calls
  update_posted_info() to correctly rebuild this table for the topics it
  actually affects, using each topic's remaining live posts to determine
  the correct value. The extension's own blanket delete was both
  redundant for what delete_posts() already touched and actively wrong
  for what it didn't.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
undo_bh_group() has always compared a banned user's group membership
against the *current* bh_group_id ACP setting, with no record of what a
given ban actually did - the exact same class of bug the restrict
feature had (and got fixed, twice, across the last two review rounds),
flagged as deliberately deferred until now since fixing it meant the
same kind of tracking-table buildout.

Changing the ACP setting (or disabling group-moving) while a ban is
still active stranded the user in whatever group they were actually
moved into; conversely, an unrelated legitimate member of the
*currently* configured group could have their membership stripped on
their next non-banned session check, even though ban-hammer never added
them there.

New banhammer_ban_group table, mirroring banhammer_restrict but with the
unique index on user_id from the start this time - restrict_group.php's
retrofit needed a whole separate dedupe migration to add one after the
fact. do_ban_hammer_stuff()'s move_group step now records the group
actually used, the user's default group beforehand, and whether
group_user_add() created a fresh membership or found the user already
there (same GROUP_USERS_EXIST handling as do_restrict_stuff(), including
the same default-group-attribute fallback). undo_bh_group() now looks up
this per-user tracking instead of comparing against current config, and
also correctly restores the original default group on unban - which the
old code never did at all, leaving phpBB's own group_user_del() fallback
(typically REGISTERED) instead of the user's real prior group.

A currently-banned, already-moved user has no tracking row when this
migration first runs (the table didn't exist under the old code), so a
backfill step creates one - conservatively assuming the membership
predates the ban (move_new_membership = 0) and leaving original_group_id
unknown (0, so no default-group restore is attempted for these), same
reasoning as restrict_membership_column's own legacy-row default.

Verified end-to-end against a real install: a currently-banned+moved
user backfilled correctly and kept their membership on unban (no
tracking to say otherwise); a fresh ban+move recorded correctly and both
removed membership and restored the original default group on unban;
purge restores an in-progress tracking row before dropping the table.

Co-Authored-By: Claude Sonnet 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.

1 participant