MDL Shield

Compromised password blocking

tool_mupwned

Print Report
Plugin Information

A security-focused plugin that integrates with the Have I Been Pwned (HIBP) API using the k-Anonymity model to detect compromised passwords during Moodle login and password change flows. When a compromised password is detected, the plugin can block login, force a password reset, destroy active sessions, and expire web service/mobile tokens. It hooks into Moodle's `check_password_policy` and `print_password_policy` callbacks and is configured entirely through admin settings.

Version:2026032950
Release:v5.0.6.06
Reviewed for:5.1
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-04-15
10 files·741 lines
Grade Justification

The plugin is well-structured, focused, and follows Moodle security and coding standards throughout. It uses Moodle's download_file_content() for HTTP requests (not raw curl), parameterized $DB methods for all database operations, get_string() for all user-facing text, and get_config() for settings. There are no XSS, SQL injection, CSRF, or authentication/authorization vulnerabilities.

The only findings are low-severity code quality observations: the plugin directly modifies core user and external_tokens tables using $DB->set_field(). In both cases, no suitable core API exists for the specific operations needed (invalidating a password by setting it to AUTH_PASSWORD_NOT_CACHED, and batch-expiring all tokens for a user). This is a recognized pattern in Moodle plugins when core lacks appropriate methods.

An info-level observation notes that the check_password_policy callback can terminate execution via redirect() or throw, which prevents subsequent plugins' callbacks from running. This is an intentional security design choice.

The plugin includes both PHPUnit and Behat tests, implements the Privacy API (null_provider, appropriate since it owns no user data tables), and has proper GPL license headers on all files. No third-party libraries are bundled.

AI Summary

Plugin Overview

tool_mupwned is a compact security plugin that checks user passwords against the Have I Been Pwned database during login and password change operations. It uses the k-Anonymity model — only the first 5 characters of the SHA1 hash are sent to the API, preserving password privacy.

Architecture

The plugin consists of:

  • lib.php — Two Moodle callbacks: tool_mupwned_print_password_policy() adds breach-check info to password policy display, and tool_mupwned_check_password_policy() performs the actual HIBP check and blocks compromised passwords
  • classes/local/blocker.php — Core logic: is_password_compromised() calls the HIBP range API via download_file_content(), and guess_service() determines the login context from $SCRIPT
  • classes/event/user_login_blocked.php — Standard Moodle event for audit logging
  • settings.php — Three admin toggles: enable/disable, force password reset, expire tokens
  • classes/privacy/provider.phpnull_provider (no plugin-owned user data)

Security Assessment

The plugin follows Moodle security best practices:

  • Uses download_file_content() (Moodle's curl wrapper) instead of raw HTTP functions
  • All database operations use parameterized $DB methods
  • Short HTTP timeouts (5s) prevent login flow blocking
  • Graceful fallback when the HIBP API is unreachable (allows login)
  • All user-facing strings use get_string() from language files
  • Settings restricted to moodle/site:config capability

Key Findings

Only low-severity code quality observations were found — direct writes to core tables (user.password and external_tokens.validuntil) where no core API exists for the needed operations. One info-level note about non-standard callback behavior (redirect/throw within the password policy callback chain).

Findings

code qualityLow
Direct modification of core database tables

The plugin directly modifies two core Moodle tables using $DB->set_field():

  • user.password — set to AUTH_PASSWORD_NOT_CACHED to invalidate the compromised password
  • external_tokens.validuntil — set to a magic expiration date to expire all tokens for the user

While Moodle provides update_internal_user_password() for password updates, that function always hashes the input or sets AUTH_PASSWORD_NOT_CACHED only when the auth plugin prevents local passwords. There is no core API to programmatically invalidate a user's password by setting it to AUTH_PASSWORD_NOT_CACHED. Similarly, no core API exists for batch-expiring all external tokens for a user.

This is a recognized pattern in Moodle plugins when core lacks appropriate methods, but it bypasses any event triggers or side effects that a dedicated API would handle (e.g., update_internal_user_password() triggers user_password_updated event).

Risk Assessment

Low risk. These are writes to core tables that the plugin doesn't own, which is a code quality concern rather than a security issue. The operations are correct, use parameterized queries (no injection risk), and are gated behind admin configuration. The main consequence is bypassing core event triggers (user_password_updated), which could mean other plugins monitoring password changes aren't notified. However, the plugin fires its own user_login_blocked event for audit purposes.

Context

Both writes occur inside tool_mupwned_check_password_policy(), which is a Moodle callback invoked during the login flow when a user's password is found to be compromised. The user.password write invalidates the password so the user must reset it. The external_tokens.validuntil write expires all web service and mobile tokens. Both operations are gated behind admin-controlled config settings (resetpassword and expiretokens).

Identified Code
$DB->set_field('user', 'password', AUTH_PASSWORD_NOT_CACHED, ['id' => $user->id]);
Suggested Fix

No core API currently supports this operation. If a future Moodle version provides an API for programmatic password invalidation, migrate to it. Consider also triggering \core\event\user_password_updated for consistency with core behavior.

Identified Code
$DB->set_field('external_tokens', 'validuntil', blocker::TOKEN_EXPIRATION, ['userid' => $user->id]);
Suggested Fix

No core API currently supports batch-expiring tokens for a user. If a future Moodle version provides such an API, migrate to it.

best practiceInfo
Non-standard callback behavior: redirect and throw within check_password_policy callback

The tool_mupwned_check_password_policy() callback can terminate execution in two ways instead of returning a string:

  • Calls redirect() (which sends HTTP headers and calls die()) when the login is via the web login page
  • Throws moodle_exception when the login is via login/token.php or a web service

Moodle's get_password_policy_errors() iterates over all plugins with a check_password_policy callback. When this plugin terminates execution, any subsequent plugins' callbacks in the loop will not run.

This is an intentional security design choice — the plugin prioritizes immediately blocking the compromised login over the standard callback return pattern. The approach is effective and well-tested.

Risk Assessment

Informational. This is a deliberate architectural decision, not a defect. The redirect/throw pattern ensures that a user with a compromised password cannot proceed past the login screen. The trade-off is that other plugins' password policy callbacks are skipped, but this is acceptable given the security context. If other plugins also need to act on compromised passwords, they would need to handle the case where the user never reaches them.

Context

The check_password_policy callback is invoked from get_password_policy_errors() in lib/moodlelib.php. The core code iterates over all registered plugin callbacks in a loop. Normally each callback returns a string (error message) or null. This plugin breaks that contract when it detects a compromised password during login, choosing to immediately block the login rather than just reporting an error message that the user could potentially bypass.

Identified Code
redirect(new moodle_url('/login/forgot_password.php'), $message, null, \core\output\notification::NOTIFY_ERROR);
Identified Code
throw new moodle_exception('invalidlogin');
Additional AI Notes

The plugin uses the k-Anonymity model for HIBP lookups — only the first 5 characters of the SHA1 hash of the password are sent to the API. The full password hash never leaves the server, which is a well-established privacy-preserving approach.

The plugin includes comprehensive test coverage: a PHPUnit test suite covering both the blocker class and the lib.php callbacks, plus Behat acceptance tests covering login blocking, account creation with compromised passwords, and password change rejection.

The HTTP request to the HIBP API uses a 5-second timeout for both connection and transfer, and gracefully allows login if the API is unreachable. This prevents the external service from becoming a single point of failure for the Moodle login flow.

The settings.php page properly requires the moodle/site:config capability and displays contextual warnings when prerequisite Moodle settings (passwordpolicy, passwordpolicycheckonlogin) are disabled.

This review was generated by an AI system and may contain inaccuracies. Findings should be verified by a human reviewer before acting on them.