Skip to content

Security hardening: block bypasses, unblock mails, usernames, IPv6, .htaccess - #231

Merged
codeling merged 10 commits into
mainfrom
security-hardening
Oct 6, 2026
Merged

codeling merged 10 commits into
mainfrom
security-hardening

Conversation

@codeling

@codeling codeling commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

What this does

Fixes the findings of a security review of the plugin and the component. The component side is in codeling/com_bfstop#15.

High severity

  • Unblock token bypass: any request with view=tokenunblock and an existing token let a blocked IP through, and such a request never used the token up, so anybody who got one emailed (e.g. by getting their own account blocked) could keep guessing passwords from a blocked IP. The pass now only applies to com_bfstop's unblock view without a task, to an unexpired token issued for one of the requesting IP's own blocks. The token is no longer written to the log.
  • Forged client IP behind a proxy: the first public entry of X-Forwarded-For was used, but proxies append to what the client sent, so it was attacker-controlled (block evasion, framing other addresses, impersonating an allowlisted one). The address is now taken from the right, skipping trusted proxies. Several trusted proxies and CIDR subnets can be configured; the Forwarded header and ports are parsed; an unparseable last hop falls back to REMOTE_ADDR.

Medium severity

  • Delay before recording: the failed login is now recorded, counted and checked against the threshold before the delay, and a request whose IP just got (or is) blocked isn't delayed. Before, a burst of parallel attempts all ran before the first was counted and each held a worker for the whole delay.
  • Passwords typed as usernames: usernames that don't belong to an account (and aren't on the common-usernames list) are stored, shown, logged and mailed as a keyed hash by default. New setting unknownUsernameMode (hash|plain).
  • Unblock mails: no unblock link if the failed logins that led to the block also went against other existing accounts; the notifyBlockedUser setting now has three values (off / only if the user has logged in from the blocked IP or its IPv6 network before / any IP, but at most one usable link per user at a time). The username a link was sent to is stored with its token (unblock_token.username).

Low severity

  • .htaccess: only valid IPs/subnets are written, the 403 message is escaped, and read-modify-write cycles hold a lock (24 parallel writers lost entries without it).
  • Block page: never cached (Cache-Control: no-store), HTTP 403 by default, set with http_response_code() (not a raw HTTP/1.0 status line, which hard-coded the protocol version).
  • IPv6 clients are tracked per network (default /64, setting ipv6PrefixLength); IPv4-mapped, NAT64, 6to4 and Teredo addresses stay per address.
  • Log rotation at 5 MB; username statistics capped at 10000 rows; expired unblock tokens are deleted by the daily maintenance (before: only if somebody visited the unblock page, or by the purge by age, which is off by default).
  • The daily maintenance only updates its own lastPurge value instead of writing back stale settings, and runs even with purge age 0.
  • Known IPs (where users logged in from) are remembered by network for IPv6, forgotten after a year and capped at 100000 rows.
  • GeoIP path: no stream wrappers. SECURITY.md rewritten.

Things to know when reviewing

  • Behaviour changes: notifyBlockedUser = 1 now means "only for IPs the user logged in from before"; the old behaviour is value 2. IPv6 tracking per /64 is on by default. The block page answers 403 if useHttpError was never saved (the form already defaulted to that); the setting's help text now says when to turn it off, and that it also covers the unblock page's error statuses (component PR). Details are in the CHANGELOG.
  • Schema: one new nullable column, unblock_token.username, added to the 2.0.0 install and update scripts (2.0.0 is unreleased, so no extra migration). The PostgreSQL install script was run against a scratch database; the MySQL scripts are exercised by CI.
  • Not covered: an attacker with an account of their own can still get blocked and use the unblock link of that account from their own IP (the designed feature). The confirmation step, IP binding and the other-accounts check limit what that gains them.

Testing

283 tests, 1069 assertions, all passing locally against Joomla 5.4.8 with PostgreSQL 16 and the component from the paired branch (1 skipped: a file-permission test that can't fail as root); CI covers MySQL, MariaDB, PostgreSQL, Joomla 5 and 6. Regression tests were checked to fail without the corresponding fix. UnblockPageHttpTest runs the installed Joomla site on PHP's built-in web server to check the real status and headers of the unblock page (CLI processes can't see them); it tests the plugin and component as installed in the site, which tests/README.md now says.

Merge together

Needs codeling/com_bfstop#15 from the branch of the same name (CI picks the component branch by name). Merge both, and release them together.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q

claude added 7 commits October 5, 2026 20:24
1. Unblock token bypass: a blocked IP was let through on any request with
   view=tokenunblock and an existing token. The token was neither consumed
   by such a request (e.g. option=com_users&task=user.login) nor checked for
   expiry or for belonging to the requester's block, so anyone able to get
   one token emailed (by getting their own account blocked) could keep
   guessing passwords from a blocked IP. The pass now only applies to
   com_bfstop's unblock view without a task, to an unexpired token issued
   for one of the requesting IP's own blocks. The token is no longer logged.

2. Forged client IP behind a proxy: the first public entry of the forwarding
   header was used, but proxies append to a client-supplied header, so the
   leftmost entry is attacker-controlled (block evasion, framing other
   addresses, impersonating allowlisted ones). The address is now taken from
   the right, skipping trusted proxies; several trusted proxies and CIDR
   subnets can be configured. The Forwarded header and ports are parsed, and
   an unparseable last hop falls back to REMOTE_ADDR.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q
- Delay ordering: the failed login is now inserted, counted and checked
  against the block threshold before the configured delay, and a request
  whose IP address just got (or already is) blocked isn't delayed at all.
  Before, a burst of parallel attempts all ran before the first was counted,
  and each held a worker for the whole delay.
- Username storage: usernames of failed logins that don't belong to an
  existing account (and aren't on the common-usernames list) are stored,
  shown, logged and mailed as a keyed hash by default, since users often type
  their password into the username field. Same input gives the same value, so
  attempts are still counted together. New setting unknownUsernameMode
  (hash|plain) restores the old behaviour.
- Tests: FailedLoginTest (runs failed logins in separate processes, incl. a
  check that the attempt is recorded while the request is still delayed),
  UsernameHelperTest, and component tests for the confirmation/IP-bound
  unblock and for the escaped log view (needs com_bfstop on the same branch).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q
- .htaccess: only valid IP addresses/subnets are written (a prefix like
  "1e1" passed the old validation and made Apache reject the file), the 403
  message is escaped, and read-modify-write cycles hold a lock so that
  concurrent blocks don't overwrite each other.
- Block page: never cached (Cache-Control: no-store), HTTP 403 by default.
- IPv6: failed logins and blocks are tracked per network (default /64,
  setting ipv6PrefixLength); IPv4-mapped, NAT64, 6to4 and Teredo addresses
  stay per address.
- Bounded growth: log rotation at 5 MB; username statistics capped at
  10000 rows, trimmed by the daily maintenance.
- Maintenance only updates its own lastPurge value in the stored settings
  instead of writing back a possibly stale copy of all of them, and runs
  even if no purge age is set.
- GeoIP database path: no stream wrappers.
- SECURITY.md: supported versions and how to report.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q
The block page is now never cached whatever this is set to, so the setting
only decides the status code (403 or 200). Say that 403 is the right choice
unless the web server, CDN or firewall replaces 403 pages with its own error
page (so blocked users would never see the block message).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q
The unblock email goes to the owner of the account of the last failed login.
If the failed logins from the blocked IP which led to the block also went
against other existing accounts, an attacker with an account of their own
could end each series of guesses at those with an attempt at their own,
receive the link and continue. The link is now only sent if all those
attempts were for that account or for names which don't exist (typos).
Logins by email address count as attempts at the account too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q
Anybody can make the plugin send an unblock email to a user, by failing to
log in with that username from an address until it is blocked. The
notifyBlockedUser setting now has three values:

- 0: off.
- 1 (what "enabled" now means): only if the user has logged in successfully
  from the blocked IP address before (for IPv6: from its network), which an
  attacker's address never has.
- 2 (the former "enabled"): for any address, but a user gets at most one
  unblock link at a time which can still be used (not expired, not used up).

The username an unblock link was sent to is stored with its token
(unblock_token.username). 2.0.0 is not released yet, so the column is part of
the 2.0.0 install and update scripts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q
- Successful logins are remembered under the key the client is tracked by
  (the address for IPv4, the network for IPv6), like failed logins. An IPv6
  client switching to a new privacy address every day stays a known client
  and no longer adds a row per address. The lookup (hasLoggedInFrom(), which
  replaces isKnownIpUsername() and is used by the risk score and the unblock
  message) still finds entries recorded per address, in another spelling or
  at another granularity, and ignores the case of the username.
- Known IP addresses are forgotten a year after the last login from them,
  and the table is kept below 100000 rows, oldest first, by the daily
  maintenance, which now also runs if no purge age is configured.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q
claude added 2 commits October 6, 2026 15:22
On MySQL/MariaDB the username column's collation is case-insensitive, so
"Admin" and "admin" share a row in the username statistics; on PostgreSQL
they don't. Count the attempts instead of the rows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q
…pired tokens

- The status line "HTTP/1.0 403 Forbidden" hard-coded the protocol version
  (and HTTP/2 has no status line); use http_response_code(403) instead.
- Unblock tokens which have expired were only deleted when somebody visited
  the unblock page or if a purge age is configured; the daily maintenance now
  deletes them regardless.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q

codeling commented Oct 6, 2026

Copy link
Copy Markdown
Owner Author

Addressed the two review comments in c3ecae6 (replying here because the threads belong to a pending review, which doesn't allow replies):

  • Bfstop.php, "does http/1.0 apply for all http versions?" No, it hard-coded the protocol version in a raw status header, and HTTP/2 has no status line at all. The block page now sets its status with http_response_code(403) (new Bfstop::blockedResponseCode(), null when "use HTTP error" is off), which leaves the protocol to the web server. The existing tests of the actual status (403 by default, not when switched off) cover it.
  • DatabaseHelper.php, "tokens that have outlived their usefulness are not removed?" They weren't, reliably: used tokens were deleted on use and expired ones when somebody visited the unblock page or by the purge by age, which only runs if a purge age is configured (default 0). The daily maintenance now also calls the new DatabaseHelper::purgeExpiredUnblockTokens(), which deletes every token older than the 3 days of validity, whatever the purge age is. Covered by a test of the method and by the maintenance test (checked to fail without the call).

I resolved both threads; reopen them if you'd like to look at them first.


Generated by Claude Code

- UnblockPageHttpTest runs the installed Joomla site on PHP's built-in web
  server and checks the real status and headers of every outcome of the
  unblock page (400 without a token, 200 for the confirmation whatever the
  token, 403 for another IP address, 404 for an unknown or used token, 200 on
  success, 200 for all of them with "Use HTTP Error" off, and the cache,
  referrer and robots headers). Status and headers can't be observed from a
  CLI process.
- Component tests for the new outcome (not found vs. failed) and the status
  mapping.
- The help text of "Use HTTP Error" mentions the unblock page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q
@codeling codeling self-assigned this Oct 6, 2026
@codeling codeling added this to the BFStop 2.0 milestone Oct 6, 2026
@codeling
codeling merged commit c13a53c into main Oct 6, 2026
9 checks passed
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