We have seen a lot of security updates recently and it shows how important it is to keep a security mindset.
Auth is a very sensitive part. It should not be left to statistical approximation.
Using AI to write sensitive parts like auth sounds like a recipe for disaster. Unless of course it is human reviewed. As humans our role is to understand and verify what’s generated, more and more. That’s the only part we can’t replace (unless we are ready for disaster).
AI can help us giving us some obvious starting points but it is no replacement for security mindset and principles (never run your own auth…). So, since we are referencing AI-coded project, I am providing AI-generated security analysis (BELOW).
My comments on those:
-
I think the first point, about using an external API to render the QR-Code is an understandable choice, but it has been discussed elsewhere that it’s a bad idea and the QR should instead be generated on the server: Security Risk: using `QRServerProvider` as default provider · Issue #104 · RobThree/TwoFactorAuth · GitHub
-
The second point suggests that the Server’s secret is passed as a hidden field in the form (a. exposing it and b. allowing a user to change the « server’s » secret). This seems fatal.
-
The third point (no rate limiting on 1 million possibilities (if I understand right it’s therefore cracked in 4h with 150 requests /sec ). The 6-number passcode can be much less secure than a long password… This seems fatal.
I am by no means suggesting to ask the LLM to fix this. I am saying that maybe the approach should be different, more security oriented altogether. It is not an easy project to outsource to LLMs hands-off.
LLM generated security review
Critical — Active Security Holes
1. OTP Secret Leaked to a Third-Party Server (configurer_mon_2fa.php L35)
$label = urlencode($GLOBALS['meta']['nom_site'] . ":" . ($GLOBALS['visiteur_session']['nom'] ?? 'Admin'));
$qrcode_url = "https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=otpauth://totp/$label?secret=$secret";
Every time a user sets up 2FA, their private TOTP secret is transmitted in plain text to api.qrserver.com as a URL query parameter. That means:
- The secret appears in
api.qrserver.com’s access logs permanently.
- Any network intermediary (proxy, WAF, CDN, ISP) can see and store it.
- The secret should never leave the server — the QR code must be generated locally (e.g., with a PHP QR library like
endroid/qr-code).
This is the most serious issue in the plugin. It functionally undermines the entire 2FA security model.
2. OTP Secret Sent as User-Controlled POST Data — Secret Substitution Attack (configurer_mon_2fa.php L56 & L82)
$code = _request('code_test');
$secret = _request('secret_temp'); // ← comes from a hidden HTML field
// ...
$checkResult = $ga->verifyCode($secret, $code, 2);
if (_request('action_2fa') == 'activer') {
$secret = _request('secret_temp'); // ← again from POST
$res_sql = sql_updateq(
'spip_auteurs',
array('otp_secret' => $secret, 'otp_actif' => 1),
"id_auteur=" . $id_auteur
);
The server trusts the client to supply the secret it should verify and store. An attacker can:
- Submit their own pre-generated secret as
secret_temp.
- Supply the correct TOTP code for that secret (since they know it).
- Activate 2FA on the account with a secret only they know — locking the legitimate user out.
The secret_temp value must be kept server-side (in $_SESSION) during the setup flow, never in the form.
High Severity
3. No Rate Limiting or Brute-Force Protection on the 2FA Gate
if (_request('valider_otp')) {
$code_saisi = _request('code_otp');
// ...
if ($ga->verifyCode($auteur['otp_secret'], $code_saisi, 2)) {
$_SESSION['2fa_ok_' . $id_auteur] = true;
There is no attempt counter, no lockout, no CAPTCHA, and no delay on the blocking screen’s OTP form. A 6-digit code has 1,000,000 possibilities. With discrepancy=2, 5 consecutive 30-second windows are valid at any moment (~150 seconds). A script can enumerate the space at hundreds of requests per second — far faster than the window expires.
4. No Replay Attack Protection (Used Codes Can Be Reused)
Once a valid TOTP code is accepted and $_SESSION['2fa_ok_...'] is set, the used code is never recorded as consumed. If an attacker observes a valid code (e.g., via shoulder surfing, phishing, or a compromised device) within its ~150-second window, they can use it again on a different session to gain access.
5. 2FA Obligation Enforcement Is Completely Broken
$auteur = sql_fetsel('otp_secret, otp_actif', 'spip_auteurs', 'id_auteur=' . $id_auteur);
// ...
$est_admin = ($auteur['statut'] == '0minirezo'); // 'statut' was NEVER selected!
The SQL query selects only otp_secret and otp_actif. The statut column is never fetched, so $auteur['statut'] is always null. Therefore $est_admin is always false, $obligation_active is always false, and users are never redirected to set up 2FA — even if the admin has made it mandatory. The 2FA gate (CAS 2) still works for users who have already enrolled, but enrollment can never be enforced.
Medium Severity
6. Missing CSRF Token on the Custom-Built Blocking Screen
<form method='post' action='$url'>
...
<input type='submit' name='valider_otp' class='btn submit' value='Valider la connexion'>
</form>
This die($html) form is hand-crafted and does not include SPIP’s #ACTION_FORMULAIRE token (the CSRF protection mechanism used everywhere else in the plugin). A Cross-Site Request Forgery attack could submit a crafted code to this endpoint. Exploitability is low (the attacker would also need a valid TOTP code), but the protection should be there.
7. The « Emergency Disable » Feature Is Dead Code
$config = array(
'statuts_obligatoires' => ...,
'desactiver_tout' => _request('desactiver_tout') // saved to DB
);
The admin UI presents a « disable 2FA for the whole site » checkbox for emergencies. The value is saved — but the authentification_2fa_autoriser function never reads it. A locked-out admin who enables this switch will remain locked out, which defeats the purpose of the emergency escape hatch entirely.
8. OTP Secrets Stored in Plain Text in the Database
$tables['spip_auteurs']['field']['otp_secret'] = "varchar(64) DEFAULT '' NOT NULL";
TOTP secrets are stored in clear text in spip_auteurs.otp_secret. A SQL injection elsewhere in SPIP, a database dump, or a compromised admin account gives an attacker all OTP seeds immediately, fully defeating the second factor for all users.
9. No Input Validation on secret_temp
The secret_temp field receives no length check, no base32 format check, and no character whitelist before being passed to verifyCode() and then stored directly in the database. A malformed or oversized input could cause unpredictable behaviour.