Repository navigation
Security hardening: block bypasses, unblock mails, usernames, IPv6, .htaccess - #231
Merged
Merged
Conversation
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
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
Owner
Author
|
Addressed the two review comments in c3ecae6 (replying here because the threads belong to a pending review, which doesn't allow replies):
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
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.
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
view=tokenunblockand 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.X-Forwarded-Forwas 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; theForwardedheader and ports are parsed; an unparseable last hop falls back toREMOTE_ADDR.Medium severity
unknownUsernameMode(hash|plain).notifyBlockedUsersetting 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).Cache-Control: no-store), HTTP 403 by default, set withhttp_response_code()(not a rawHTTP/1.0status line, which hard-coded the protocol version).ipv6PrefixLength); IPv4-mapped, NAT64, 6to4 and Teredo addresses stay per address.lastPurgevalue instead of writing back stale settings, and runs even with purge age 0.SECURITY.mdrewritten.Things to know when reviewing
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 ifuseHttpErrorwas 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.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.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.
UnblockPageHttpTestruns 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, whichtests/README.mdnow 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