Skip to content

Security hardening: unblock confirmation, escaping, settings validation - #15

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

codeling merged 4 commits into
mainfrom
security-hardening

Conversation

@codeling

@codeling codeling commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

What this does

The component side of a security review; the plugin side, with the full list of findings, is codeling/bfstop#231.

  • Unblock link needs confirmation and the blocked IP: opening the link from the email (a GET request) only shows a confirmation button; the unblock itself is a POST and only works from the IP address the token was issued for (or, for a client blocked by its IPv6 network, from within that network). Mail security scanners and link previews that open every link in a message can no longer unblock an attacker automatically. The logic is in TokenunblockModel::process().
  • Unblock page status and headers: the page answers with an error status if the link can't be used (400 without a token, 403 from another IP address, 404 for an unknown, expired or used token, 500 if unblocking failed), set with Joomla's setHeader('status', …), unless the plugin's "Use HTTP Error" setting is off (a web server or CDN replacing error pages with its own would hide the message). Opening the link (GET) stays a 200 for every token, so scanners learn nothing and don't flag the link as dead. Since the link contains a secret, the page is also never cached (allowCache(false)), sends Referrer-Policy: no-referrer and X-Robots-Tag: noindex, nofollow.
  • Escaping: the log view (date, priority, message; the message can contain visitor-supplied usernames), the IP information view (request parameter and GeoIP data, including the toolbar title) and the raw log line in the "invalid entry" message are escaped.
  • Subnet validation: the prefix length has to be plain digits; is_numeric() also accepted values like 1e1, which got as far as the .htaccess file.
  • Settings: the .htaccess path and the GeoIP database path are validated (no stream wrappers such as phar://, a .mmdb file name; the directory has to exist if .htaccess blocking is used). Sending the test email now needs the permission to change the settings. New settings: "Usernames Not Matching an Account", "IPv6 Tracking Granularity", and two new values for "User Block Message" (the plugin's PR explains them).
  • ParamHelper no longer fails if the plugin's settings can't be read. SECURITY.md added.

Things to know when reviewing

  • New language strings are in en-GB and de-DE; other languages fall back to English.
  • The plugin's DatabaseHelper::$UNBLOCK_TOKEN_VALID_DAYS and TokenunblockModel::TokenValidDays must stay in sync (both 3); there is a comment in each.
  • Joomla's "System - Page Cache" plugin caches any guest GET page whatever its status or allowCache(); for the confirmation page that is harmless (same URL, same token, same page).
  • The admin check on the test-email action is not covered by an automated test; the rest is covered by the integration tests in the plugin repository, which load this repository through COM_BFSTOP_ROOT.

Testing

The plugin's test suite (283 tests) passes with this branch as COM_BFSTOP_ROOT, including tests for the confirmation/IP-bound unblock, the escaped log view, the settings rules and the subnet validation. UnblockPageHttpTest (in the plugin repository) 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; it was checked to fail against the previous view.

Merge together

Needs the plugin PR (codeling/bfstop#231): it uses the plugin's IpHelper::isInSubnet() and settings. Merge both, and release them together.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q

claude added 3 commits October 5, 2026 20:44
- The unblock link from the email now only asks for confirmation on GET; the
  unblock itself is a POST and only works from the IP address the token was
  issued for. Mail security scanners and link previews that open every link
  can no longer unblock an attacker automatically. The decision logic is in
  TokenunblockModel::process().
- Escape the log view (date, priority, message - the message can contain
  visitor-supplied usernames), the IP information view (request parameter and
  GeoIP data, incl. the toolbar title) and the raw log line in the "invalid
  entry" message.
- New setting "Usernames Not Matching an Account" (unknownUsernameMode) for
  the plugin's hashing of usernames that aren't an existing account.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q
- Subnet validation: the prefix length has to be plain digits (is_numeric()
  accepted "1e1" and similar).
- Sending the test email needs the permission to change the settings.
- The .htaccess path and GeoIP database path settings are validated (no
  stream wrappers, a .mmdb file name).
- New setting for the plugin's IPv6 tracking granularity; unblock tokens
  work for clients blocked by their IPv6 network.
- ParamHelper no longer fails if the plugin's settings can't be read.
- SECURITY.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q
"Enabled" now means: only if the user has logged in from the blocked IP
address before; the former behaviour is a new value, "enabled, for any IP
address" (see the plugin's CHANGELOG).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q
400 without a token, 403 from another IP address, 404 for an unknown, expired
or used token, 500 if unblocking failed; opening the link (GET) is a 200 for
every token, as is success. Only if the plugin's "Use HTTP Error" setting is
on, as a web server or CDN replacing error pages with its own would hide the
message. The status is set with Joomla's setHeader('status', ...).

The link contains a secret, so the page is never cached (allowCache(false)),
sends Referrer-Policy: no-referrer and X-Robots-Tag: noindex, nofollow.

TokenunblockModel tells "no such token" (ResultNotFound) from a failure to
unblock (ResultFailed).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q
@codeling
codeling merged commit 2b6307c into main Oct 6, 2026
7 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