— Artikel — № 125

125 —WordPress

WordPress nonces: wat wp_verify_nonce werkelijk doet

Het formulier werkt 's ochtends, breekt 's middags, niemand kan het reproduceren. De bug zit in hoe je leest wat wp_verify_nonce teruggeeft.

Foto van boven op linnen: WordPress beveiligingsblad, tijddiagram, map NONCES, indexkaarten, messing plaatje, vulpen, rood lakzegel met label STALE.
Hero · gestileerd stilleven№ 125

Het Slack-bericht komt binnen op woensdag, 14:02. "Ons inzendformulier werkt 's ochtends en breekt na de lunch willekeurig. Niks in de error log." We openen het plugin-bestand en vinden dit:

if (wp_verify_nonce($_POST['_wpnonce'], 'submit_form') === 1) {
    process_form();
}

De plugin werkte 's ochtends omdat de WordPress nonce minder dan 12 uur oud was. Tegen 14:02 stuurden sommige gebruikers nonces in die nog van gistermiddag waren, en wp_verify_nonce gaf 2 terug in plaats van 1. Met de strikte vergelijking === 1 komen alleen verse nonces door, en het formulier begint requests willekeurig te laten vallen. De auteur merkte het nooit, omdat zijn eigen browser-sessie altijd in de verse helft van het venster zat.

Dit gebeurt als je wp_verify_nonce van Stack Overflow kopieert zonder te lezen wat de functie eigenlijk doet. Laten we dat dus lezen.

Wat WordPress een nonce noemt

Een nonce in de cryptografie is een getal dat één keer wordt gebruikt. Een WordPress nonce is dat niet. Het is een hash gekoppeld aan een user-sessie, een action-string en een venster van 12 of 24 uur. Dezelfde gebruiker krijgt voor dezelfde action twee keer dezelfde nonce als hij binnen het venster opnieuw laadt. Dat is geen bug, dat is het ontwerp.

De hash wordt gegenereerd door wp_create_nonce(), dat onder de motorkap wp_hash() aanroept over $tick|$action|$uid|$token. $tick is ceil(time() / (nonce_life / 2)). Standaard is nonce_life 86400 seconden, dus $tick rolt elke 12 uur door. Op elk moment zijn er twee geldige nonces: die van de huidige tick en die van de vorige.

Daarom kan wp_verify_nonce drie verschillende dingen teruggeven:

  • 1 als de nonce in de laatste 0 tot 12 uur is gegenereerd
  • 2 als de nonce in de laatste 12 tot 24 uur is gegenereerd
  • false als hij met geen van beide overeenkomt

Als je code de return als boolean behandelt met een losse == of met if(), slagen zowel 1 als 2. Als je code === 1 doet, faalt de tweede helft van het venster geruisloos. Beide patronen komen veel voor in verouderde plugins. Geen van beide is op zichzelf een correcte security-check. De officiële referentie documenteert de drievoudige return, maar het staat verstopt onder het voorbeeld en de meeste lezers scrollen er overheen.

Waartegen een nonce eigenlijk beschermt

Een WordPress nonce is geen CSRF-token in de strikte OWASP-zin. Hij beschermt wel tegen CSRF, omdat de hash afhangt van het session-token van de gebruiker en een aanvaller op een andere origin dat niet kent. Maar hij fungeert ook als sessieslot: een nonce die voor user A is aangemaakt is niet geldig voor user B, en een nonce die ingelogd is aangemaakt overleeft geen logout.

Dat laat twee dingen over die een nonce niet doet.

Hij controleert geen capability. Een subscriber met een geldige nonce voor de action delete_post komt nog steeds door wp_verify_nonce heen. Je hebt daarna current_user_can('delete_post', $post_id) nodig. En hij autoriseert niet. Hij bevestigt dat de request kwam van een plek die het juiste session-token kende. Hij bevestigt niet dat de request mag doen wat hij vraagt.

De action-string is het contract

Hier is de tweede gekopieerde fout. De plugin genereert dit:

$nonce = wp_create_nonce('update');

En verifieert dit:

wp_verify_nonce($_REQUEST['_wpnonce'], 'update');

De action-string 'update' wordt gedeeld door elk formulier in de plugin. Elke geldige nonce voor elk update-formulier slaagt voor elke andere verificatie die dezelfde string gebruikt. Een nonce die is aangemaakt om user-metadata bij te werken slaagt op het formulier dat factuuradressen wijzigt. De bescherming klapt in elkaar tot "kwam je ergens vandaan op deze site die kortgeleden iets heeft gedaan."

De fix is om de action te namespacen met het object waarop hij werkt:

$nonce = wp_create_nonce('update_invoice_' . $invoice_id);

Nu is de nonce gekoppeld aan die specifieke factuur. Een nonce voor factuur 14 kan niet hergebruikt worden tegen factuur 15. Dit telt zwaarder dan mensen denken. Het is het verschil tussen het formulier beschermen en de rij beschermen.

Een werkend patroon voor admin-post.php

Dit is de vorm waar we voor bijna elk admin-formulier op uitkomen. Hij scheidt de vier controles die in deze volgorde moeten gebeuren: authenticatie, intentie, capability, payload.

add_action('admin_post_save_invoice', function () {
    if (!is_user_logged_in()) {
        wp_die('Not signed in.', 403);
    }

    $nonce      = $_POST['_wpnonce']    ?? '';
    $invoice_id = absint($_POST['invoice_id'] ?? 0);

    if (!wp_verify_nonce($nonce, 'save_invoice_' . $invoice_id)) {
        wp_die('Nonce expired or invalid.', 403);
    }

    if (!current_user_can('edit_post', $invoice_id)) {
        wp_die('Not allowed.', 403);
    }

    // payload validation, then the actual write
});

Drie dingen om op te merken. De nonce-check gebruikt if (!wp_verify_nonce(...)), die 1, 2 en elke niet-false waarde als geslaagd behandelt. De capability-check staat apart en loopt erna. En de action-string draagt de invoice-id, dus de nonce kan niet van het ene formulier worden geplukt en op een ander herspeeld.

Aan de formulierkant doet wp_nonce_field() precies het juiste:

<?php wp_nonce_field('save_invoice_' . $invoice->id); ?>

Voor AJAX-endpoints geeft check_ajax_referer() dezelfde drievoudige waarde terug en exit met 403 bij false als je true als derde argument meegeeft. Zelfde regels, andere wrapper.

Lifetimes, salts en wat je veilig kunt bijstellen

Twee filters en twee constanten doen het meeste tuning-werk. Het filter nonce_life bepaalt het venster. Sommige plugins korten het in tot één uur voor flows met hoge security:

add_filter('nonce_life', function () {
    return HOUR_IN_SECONDS;
});

Houd er rekening mee dat dit het venster globaal verkort, ook voor elk tabblad dat de gebruiker open heeft laten staan. Heeft hij 90 minuten op een checkout-pagina gezeten, dan is de nonce op zijn submit-knop nu verlopen en weigert het formulier hem.

NONCE_SALT en NONCE_KEY in wp-config.php seeden de hash. Roteer je ze, dan wordt elke nonce die op dat moment in transit is direct ongeldig. Dat is de juiste zet na een credential-lek. Het is de verkeerde zet op een dinsdagochtend, want de volgende duizend formulier-inzendingen in het wild gaan allemaal stuk zonder bruikbare error.

Waar dit werk meestal misgaat

De meeste nonce-bugs die we zien in legacy WordPress-sites zitten niet in de verify-aanroep zelf. Ze zitten in de code eromheen.

  • Een nonce-check die na de database-write loopt, niet ervoor.
  • Een nonce-check die wel draait, maar het resultaat weggooit met @ of een ingeslikte exception.
  • Een nonce-check gekoppeld aan een generieke action-string als save of update, hergebruikt door de hele plugin.
  • Een nonce-check die is uitgecomment omdat "het formulier steeds brak", en niemand wist van het 12-uurs-venster.

Dit zijn geen subtiele bugs. Ze zijn ook niet van buitenaf zichtbaar, en dat is waarom ze jarenlang in productie overleven.

Toen we Pier bouwden, een chat-gestuurde editor voor verouderde WordPress- en WooCommerce-sites, liepen we in de eerste week van elke audit tegen precies deze patronen aan. Wat we uiteindelijk deden is de audit-pass leren om elke wp_verify_nonce-aanroep te grep'pen en de vijf omliggende regels mee te lezen: de action-string, hoe de return wordt afgehandeld, en of er een current_user_can naast staat. Drie regels context pakt de meeste, en de version history betekent dat een verkeerd uitgepakte fix één klik terug is.

Wat je vandaag kunt doen

Open één plugin in de site die je beheert. Zoek op wp_verify_nonce. Controleer bij elke hit drie dingen: de action-string draagt de id van het object waarop wordt gewerkt, het resultaat wordt niet strict vergeleken met === 1, en een current_user_can-check staat binnen vijf regels ervan. Mist één van de drie, dan heb je een echte bug gevonden, en wel het soort dat pas in een log opduikt als iemand om 14:02 op een woensdag meldt dat hij een formulier niet kan insturen.

— Vragen —

Is een WordPress nonce hetzelfde als een CSRF-token?

Er is overlap, maar het is niet hetzelfde. Hij beschermt tegen CSRF omdat hij afhangt van het session-token van de gebruiker, en hij werkt ook als sessieslot dat de request koppelt aan één gebruiker en één action-venster.

Waarom geeft wp_verify_nonce 1 of 2 terug in plaats van true?

1 betekent dat de nonce in de laatste 12 uur is aangemaakt, 2 betekent in de 12 uur daarvoor. WordPress houdt twee geldige ticks aan, zodat een gebruiker die een lang openstaand formulier opnieuw laadt niet buitengesloten wordt.

Mag ik dezelfde action-string voor meerdere formulieren hergebruiken?

Mag wel, moet je niet doen. Een gedeelde action-string betekent dat elke geldige nonce voor het ene formulier door de verificatie van het andere komt. Namespace de action met de object-id, zoals save_invoice_14.