Sanitizing, Escaping, Nonces, Capabilities im Plugin-Review

Inhalt
- Die Regel: sanitize early, escape late, always validate
- Das Beispiel-Plugin in Version 0.1
- Befund 1: Eingaben ungeprüft übernommen
- Befund 2: Ausgaben ohne Escaping
- Befund 3: keine Nonce im Formular
- Befund 4: keine Capability-Prüfung, warum die Nonce nicht reicht
- REST-Routen: permission_callback ist Pflicht
- Grenzen und offene Punkte
- Fragen und Antworten
- Quellen
Wer ein Plugin im Verzeichnis auf WordPress.org einreicht, bekommt bei Sicherheitsmängeln selten exotische Befunde zurück. Die Seite „Common issues“ des Plugin-Review-Teams beginnt mit dem Abschnitt „Security“, und dort geht es um Grundlagen: Eingaben werden nicht bereinigt, Ausgaben nicht escaped, Nonce-Werte unbereinigt weitergereicht, SQL entsteht ohne prepare(). Das Security-Kapitel des Developer Handbook ergänzt Nonces und Capabilities. Gerade der letzte Punkt fällt leicht durch: Ein Formular-Handler prüft die Nonce, aber nicht, ob der angemeldete Nutzer die Aktion überhaupt ausführen darf, und die Security-Prüfungen von Plugin Check melden das nicht.
Dieser Beitrag geht die vier Befunde an einem kleinen Admin-Plugin durch. Version 0.1 enthält alle Fehler zugleich, jede folgende Version behebt einen davon; zu jedem Befund stehen der fehlerhafte und der korrigierte Code nebeneinander. Am Ende kommt der REST-Teil dazu, denn dort heißt die Berechtigungsprüfung permission_callback und ist seit WordPress 5.5 formal Pflicht. Alle Funktionsbeschreibungen stammen aus dem Developer Handbook und der Code Reference auf developer.wordpress.org, Stand WordPress 7.1.2.
Die Regel: sanitize early, escape late, always validate
Die „Common issues“ fassen die Anforderung in einem Satz zusammen: „Sanitize early, Escape Late, Always Validate“. Das Security-Kapitel im Common APIs Handbook formuliert dieselbe Haltung als Leitsätze, darunter „Never trust user input“, „Escape as late as possible“ und „Escape everything from untrusted sources“; ausdrücklich zählt die Datenbank zu diesen unvertrauten Quellen. Ein weiterer Leitsatz lautet sinngemäß: Bereinigen ist in Ordnung, Validieren und Zurückweisen ist besser.
Die drei Begriffe bezeichnen verschiedene Handgriffe. Validieren prüft Daten gegen ein festes Muster mit eindeutigem Ergebnis, gültig oder ungültig; das Handbook empfiehlt Allowlists, verglichen mit in_array() im strikten Modus. Bereinigen (Sanitizing) filtert eine Eingabe so, dass sie gespeichert werden kann, etwa mit sanitize_text_field(). Escapen macht einen Wert für genau einen Ausgabekontext unschädlich, also HTML-Text, Attribut, URL oder Textarea. Die „Common issues“ betonen, dass sich die Funktionsgruppen nicht gegenseitig ersetzen: Escape-Funktionen taugen nicht zum Bereinigen, Sanitize-Funktionen nicht zum Escapen.
Vor alle drei Stufen gehört bei schreibenden Anfragen eine Torprüfung: Darf dieser Nutzer das (Capability), und kommt die Anfrage aus dem eigenen Formular (Nonce)? Das Diagramm zeigt den Weg eines Werts mit den Funktionen je Stufe.

Das Beispiel-Plugin in Version 0.1
Das Plugin „LW Notice Box“ legt unter „Einstellungen“ eine Seite an. Dort lassen sich eine Beschriftung, ein Link, eine Nachricht mit HTML und die Zahl der angezeigten Protokollzeilen speichern. Jede Änderung landet als Zeile in einer eigenen Tabelle, ein Shortcode gibt den Hinweis im Frontend aus, und eine REST-Route erlaubt das Ändern der Beschriftung. Die Datei ist vollständig und läuft, ist aber absichtlich unsicher:
<?php
/**
* Plugin Name: LW Notice Box
* Description: Demo plugin for the security review. Version 0.1 contains deliberate flaws.
* Version: 0.1.0
* Requires at least: 6.2
* Requires PHP: 7.4
* License: GPL-2.0-or-later
* Text Domain: lw-notice-box
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
register_activation_hook( __FILE__, 'lw_nb_install' );
/**
* Creates the log table (unchanged in all versions).
*/
function lw_nb_install() {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table = $wpdb->prefix . 'lw_nb_log';
$charset = $wpdb->get_charset_collate();
dbDelta(
"CREATE TABLE $table (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
label varchar(100) NOT NULL DEFAULT '',
saved_at datetime NOT NULL,
PRIMARY KEY (id)
) $charset;"
);
}
/**
* Default values of the stored option (unchanged in all versions).
*/
function lw_nb_defaults() {
return array(
'label' => '',
'link' => '',
'message' => '',
'rows' => 10,
);
}
add_action( 'admin_menu', 'lw_nb_menu' );
/**
* Adds the settings page (unchanged in all versions).
*/
function lw_nb_menu() {
add_options_page( 'Notice Box', 'Notice Box', 'manage_options', 'lw-notice-box', 'lw_nb_render_page' );
}
// FLAW (findings 1 and 2): $_GET value inside SQL, unescaped output.
function lw_nb_render_page() {
global $wpdb;
$opt = wp_parse_args( get_option( 'lw_nb_notice', array() ), lw_nb_defaults() );
$orderby = isset( $_GET['orderby'] ) ? $_GET['orderby'] : 'saved_at';
$rows = $wpdb->get_results( "SELECT user_id, label, saved_at FROM {$wpdb->prefix}lw_nb_log ORDER BY $orderby DESC LIMIT {$opt['rows']}" );
?>
<div class="wrap">
<h1>Notice Box</h1>
<form method="post" action="<?php echo admin_url( 'admin-post.php' ); ?>">
<input type="hidden" name="action" value="lw_nb_save">
<p><input type="text" name="label" value="<?php echo $opt['label']; ?>"></p>
<p><input type="url" name="link" value="<?php echo $opt['link']; ?>"></p>
<p><textarea name="message"><?php echo $opt['message']; ?></textarea></p>
<p><input type="number" name="rows" value="<?php echo $opt['rows']; ?>"></p>
<p><button type="submit" class="button button-primary">Save</button></p>
</form>
<ul>
<?php foreach ( $rows as $row ) : ?>
<li><?php echo $row->saved_at . ' | ' . $row->label; ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php
}
add_action( 'admin_post_lw_nb_save', 'lw_nb_save' );
// FLAW (findings 1, 3 and 4): no capability, no nonce, raw $_POST, SQL by concatenation.
function lw_nb_save() {
global $wpdb;
update_option( 'lw_nb_notice', $_POST );
$wpdb->query( "INSERT INTO {$wpdb->prefix}lw_nb_log (user_id, label, saved_at) VALUES (" . get_current_user_id() . ", '" . $_POST['label'] . "', NOW())" );
wp_redirect( admin_url( 'options-general.php?page=lw-notice-box' ) );
exit;
}
add_shortcode( 'lw_notice', 'lw_nb_shortcode' );
// FLAW (finding 2): stored values go to the front end unescaped.
function lw_nb_shortcode() {
$opt = wp_parse_args( get_option( 'lw_nb_notice', array() ), lw_nb_defaults() );
return '<div class="lw-notice"><a href="' . $opt['link'] . '">' . $opt['label'] . '</a>' . $opt['message'] . '</div>';
}
add_action( 'rest_api_init', 'lw_nb_register_routes' );
// FLAW (REST): no permission_callback, the route is public.
function lw_nb_register_routes() {
register_rest_route(
'lw-nb/v1',
'/notice',
array(
'methods' => 'POST',
'callback' => 'lw_nb_rest_update',
)
);
}
function lw_nb_rest_update( WP_REST_Request $request ) {
$opt = wp_parse_args( get_option( 'lw_nb_notice', array() ), lw_nb_defaults() );
$opt['label'] = $request['label'];
update_option( 'lw_nb_notice', $opt );
return rest_ensure_response( $opt );
}
Die Kommentare mit „FLAW“ markieren, wo welcher Befund steckt. Die Funktionen lw_nb_install(), lw_nb_defaults() und lw_nb_menu() bleiben in allen Versionen gleich; die übrigen werden in den folgenden Abschnitten durch gleichnamige Fassungen ersetzt. Wer alle korrigierten Funktionen in die Datei übernimmt und die Versionsnummer anhebt, erhält Version 1.0.
Befund 1: Eingaben ungeprüft übernommen
Der Handler aus Version 0.1 schreibt das komplette $_POST-Array in die Option. Damit landen auch action und jedes beliebige Zusatzfeld in der Datenbank, und zwar noch mit den Backslashes, die WordPress beim Laden über wp_magic_quotes() an $_GET, $_POST, $_COOKIE und $_SERVER anhängt. Die „Common issues“ raten ausdrücklich davon ab, den ganzen $_POST-, $_REQUEST– oder $_GET-Stapel zu verarbeiten.
// Version 0.1: the whole, still slashed $_POST array lands in the option,
// the label is concatenated into SQL.
update_option( 'lw_nb_notice', $_POST );
$wpdb->query( "INSERT INTO {$wpdb->prefix}lw_nb_log (user_id, label, saved_at) VALUES (" . get_current_user_id() . ", '" . $_POST['label'] . "', NOW())" );
Version 0.2 liest nur die vier benötigten Felder. Jedes davon durchläuft zuerst wp_unslash(), das laut Code Reference Backslashes aus einer Zeichenkette oder rekursiv aus einem Array entfernt, und danach die passende Sanitize-Funktion. sanitize_text_field() prüft auf ungültiges UTF-8, wandelt einzelne < in Entitäten, entfernt alle Tags, Zeilenumbrüche, Tabs und überzählige Leerzeichen. sanitize_url() bekommt eine Liste erlaubter Protokolle; wp_kses_post() lässt nur das HTML stehen, das auch in Beitragsinhalten erlaubt ist. Für die Zeilenzahl genügt absint(), das jeden Wert in eine nicht negative Ganzzahl umwandelt.
/**
* Handles the settings form (version 0.2: input is cleaned and validated).
* Still missing: nonce (finding 3) and capability check (finding 4).
*/
function lw_nb_save() {
// Sanitize early: read only the fields we need, unslash, then clean.
$label = isset( $_POST['label'] ) ? sanitize_text_field( wp_unslash( $_POST['label'] ) ) : '';
$link = isset( $_POST['link'] ) ? sanitize_url( wp_unslash( $_POST['link'] ), array( 'http', 'https' ) ) : '';
$message = isset( $_POST['message'] ) ? wp_kses_post( wp_unslash( $_POST['message'] ) ) : '';
$rows = isset( $_POST['rows'] ) ? absint( $_POST['rows'] ) : 10;
// Always validate: reject what does not fit instead of storing it.
if ( '' === $label || mb_strlen( $label ) > 100 ) {
wp_die(
esc_html__( 'The label must contain 1 to 100 characters.', 'lw-notice-box' ),
'',
array( 'back_link' => true )
);
}
if ( $rows < 1 || $rows > 50 ) {
$rows = 10;
}
update_option(
'lw_nb_notice',
array(
'label' => $label,
'link' => $link,
'message' => $message,
'rows' => $rows,
)
);
lw_nb_log_change( $label );
wp_safe_redirect( admin_url( 'options-general.php?page=lw-notice-box' ) );
exit;
}
/**
* Writes one log row. $wpdb->insert() prepares the values via the format list.
*
* @param string $label Already sanitized label.
*/
function lw_nb_log_change( $label ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table without a core API.
$wpdb->insert(
$wpdb->prefix . 'lw_nb_log',
array(
'user_id' => get_current_user_id(),
'label' => $label,
'saved_at' => current_time( 'mysql' ),
),
array( '%d', '%s', '%s' )
);
}
Nach dem Bereinigen folgt die Validierung. Eine leere oder zu lange Beschriftung wird nicht stillschweigend gekürzt, sondern abgewiesen. Die Zeilenzahl muss zwischen 1 und 50 liegen, sonst gilt der Standardwert. Diese Grenzen sind fachliche Regeln des Plugins; keine Sanitize-Funktion kennt sie. Das Protokoll schreibt jetzt $wpdb->insert() mit einer Formatliste, die jeden Wert als Ganzzahl oder Zeichenkette ausweist.
Der Kommentar mit phpcs:ignore gehört zu einer Warnung, die Plugin Check bei jedem direkten Datenbankzugriff ausgibt. Bei einer eigenen Tabelle gibt es keine Core-API, die Begründung steht deshalb im Kommentar. Wie Plugin Check solche Meldungen einordnet, behandelt ein eigener Beitrag dieser Reihe.
Sonderfall SQL: $wpdb->prepare() mit %s, %d und %i
Bereinigen schützt nicht vor SQL-Injection. Die „Common issues“ verlangen bei eigenen Abfragen wpdb-Methoden zusammen mit prepare(). Der Fehler in Version 0.1 steckt in der Sortierung:
// Version 0.1: ?orderby=... goes straight into the query. ORDER BY needs no
// quotes, so the slashes added by wp_magic_quotes() do not help here.
$orderby = isset( $_GET['orderby'] ) ? $_GET['orderby'] : 'saved_at';
$rows = $wpdb->get_results( "SELECT user_id, label, saved_at FROM {$wpdb->prefix}lw_nb_log ORDER BY $orderby DESC LIMIT {$opt['rows']}" );
Der Parameter landet ohne Anführungszeichen in ORDER BY, die zusätzlichen Backslashes von wp_magic_quotes() bewirken dort nichts. Laut Code Reference kennt $wpdb->prepare() die Platzhalter %d (Ganzzahl), %f (Gleitkommazahl), %s (Zeichenkette) und %i (Bezeichner wie Tabellen- oder Spaltennamen). %i kam mit WordPress 6.2 dazu; das Beispiel-Plugin nennt deshalb im Header „Requires at least: 6.2“. Alle Platzhalter bleiben ohne Anführungszeichen, zu jedem gehört genau ein Argument.
/**
* Returns the latest log rows.
*
* @param string $orderby Requested sort column (untrusted).
* @param int $limit Number of rows.
* @return array
*/
function lw_nb_get_log( $orderby, $limit ) {
global $wpdb;
// An identifier placeholder quotes a name, it does not restrict it: allowlist first.
$allowed = array( 'saved_at', 'label', 'user_id' );
if ( ! in_array( $orderby, $allowed, true ) ) {
$orderby = 'saved_at';
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, small admin-only list.
return $wpdb->get_results(
$wpdb->prepare(
'SELECT user_id, label, saved_at FROM %i ORDER BY %i DESC LIMIT %d',
$wpdb->prefix . 'lw_nb_log',
$orderby,
min( 50, absint( $limit ) )
)
);
}
Wichtig ist die Reihenfolge in dieser Funktion. %i setzt einen Namen korrekt in Backticks, beschränkt aber nicht, welcher Name ankommt; ohne Allowlist ließe sich weiterhin nach jeder Spalte der Tabelle sortieren. Die Allowlist ist die Validierung, der Platzhalter die technische Absicherung. Für LIKE-Suchen gilt eine weitere Regel aus der Code Reference: Das vollständige Muster samt Prozentzeichen wird als Argument übergeben und der Suchbegriff vorher mit $wpdb->esc_like() behandelt, etwa $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE label LIKE %s', $table, '%' . $wpdb->esc_like( $term ) . '%' ). Für Listen in IN ( … ) zeigen die „Common issues“ ein Muster, das je Element einen eigenen Platzhalter erzeugt.
Befund 2: Ausgaben ohne Escaping
Version 0.1 gibt jeden gespeicherten Wert roh aus, im Admin-Formular wie im Shortcode. Enthält die Beschriftung ein Anführungszeichen, bricht sie aus dem value-Attribut aus; enthält die Nachricht ein </textarea>, endet das Textfeld vorzeitig. Die Bereinigung beim Speichern macht das Escaping nicht überflüssig, denn gespeicherte Werte können auch auf anderem Weg in die Datenbank gelangen, etwa über einen Import, ein anderes Plugin oder eine ältere Version des eigenen Codes.
// Version 0.1: attribute, textarea content and HTML block all printed raw.
<input type="text" name="label" value="<?php echo $opt['label']; ?>">
<textarea name="message"><?php echo $opt['message']; ?></textarea>
return '<div class="lw-notice"><a href="' . $opt['link'] . '">' . $opt['label'] . '</a>' . $opt['message'] . '</div>';
„Escape late“ heißt: direkt beim echo und passend zum Kontext. Das Escaping-Kapitel des Handbooks nennt die Zuordnung, und die „Common issues“ verlangen, alle Variablen, Optionen und erzeugten Daten zum Zeitpunkt der Ausgabe zu escapen, nicht beim Zusammenbauen einer Variable.
| Ausgabekontext | Funktion | Beispiel im Plugin |
|---|---|---|
| Text zwischen HTML-Tags | esc_html() |
Seitentitel, Protokollzeilen |
| Wert eines HTML-Attributs | esc_attr() |
value von Beschriftung und Zeilenzahl |
URL in href, src, action |
esc_url() |
Formularziel, Sortierlinks, Hinweislink |
| Inhalt einer Textarea | esc_textarea() |
Nachricht im Formular |
| HTML, das erhalten bleiben soll | wp_kses_post() |
Nachricht im Shortcode |
| Übersetzter Text | esc_html_e(), esc_attr__() |
Feldbeschriftungen |
/**
* Renders the settings page (version 1.0: every value escaped at output).
*/
function lw_nb_render_page() {
$opt = wp_parse_args( get_option( 'lw_nb_notice', array() ), lw_nb_defaults() );
// Read-only sort parameter: it changes nothing and is checked in lw_nb_get_log().
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$orderby = isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : 'saved_at';
$rows = lw_nb_get_log( $orderby, absint( $opt['rows'] ) );
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="lw_nb_save">
<?php wp_nonce_field( 'lw_nb_save', 'lw_nb_nonce' ); // Finding 3. ?>
<p>
<label for="lw-nb-label"><?php esc_html_e( 'Label', 'lw-notice-box' ); ?></label><br>
<input type="text" id="lw-nb-label" name="label" class="regular-text" value="<?php echo esc_attr( $opt['label'] ); ?>">
</p>
<p>
<label for="lw-nb-link"><?php esc_html_e( 'Link', 'lw-notice-box' ); ?></label><br>
<input type="url" id="lw-nb-link" name="link" class="regular-text" value="<?php echo esc_url( $opt['link'] ); ?>">
</p>
<p>
<label for="lw-nb-message"><?php esc_html_e( 'Message (HTML allowed)', 'lw-notice-box' ); ?></label><br>
<textarea id="lw-nb-message" name="message" rows="4" class="large-text"><?php echo esc_textarea( $opt['message'] ); ?></textarea>
</p>
<p>
<label for="lw-nb-rows"><?php esc_html_e( 'Log rows (1-50)', 'lw-notice-box' ); ?></label><br>
<input type="number" id="lw-nb-rows" name="rows" min="1" max="50" value="<?php echo esc_attr( $opt['rows'] ); ?>">
</p>
<?php submit_button(); ?>
</form>
<p>
<a href="<?php echo esc_url( add_query_arg( 'orderby', 'saved_at' ) ); ?>"><?php esc_html_e( 'Sort by date', 'lw-notice-box' ); ?></a> |
<a href="<?php echo esc_url( add_query_arg( 'orderby', 'label' ) ); ?>"><?php esc_html_e( 'Sort by label', 'lw-notice-box' ); ?></a>
</p>
<ul>
<?php foreach ( $rows as $row ) : ?>
<li><?php echo esc_html( $row->saved_at . ' | ' . $row->label ); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php
}
/**
* Front-end output of the notice (version 1.0).
*
* @return string
*/
function lw_nb_shortcode() {
$opt = wp_parse_args( get_option( 'lw_nb_notice', array() ), lw_nb_defaults() );
if ( '' === $opt['label'] ) {
return '';
}
return sprintf(
'<div class="lw-notice"><a href="%1$s">%2$s</a>%3$s</div>',
esc_url( $opt['link'] ),
esc_html( $opt['label'] ),
wp_kses_post( $opt['message'] )
);
}
Drei Details in dieser Fassung verdienen Beachtung. esc_url() liefert laut Code Reference eine leere Zeichenkette, wenn die URL ein Protokoll außerhalb der erlaubten Liste verwendet; ein gespeichertes javascript: wird so im Link nicht wirksam. add_query_arg() ohne dritten Parameter arbeitet mit der aktuellen Anfrage-URL und muss deshalb ebenfalls escaped werden. Und esc_url_raw() ist nach den „Common issues“ keine Escape-Funktion, sondern ein Sanitizer für Datenbank und Weiterleitung, vergleichbar mit sanitize_url(). Plugin Check prüft diesen Befund mit dem Sniff WordPress.Security.EscapeOutput, allerdings nur bei direkter Ausgabe wie echo oder printf(): Für Version 0.1 meldete er sieben Stellen in der Einstellungsseite, den unescapten Rückgabewert des Shortcodes dagegen nicht.
Befund 3: keine Nonce im Formular
Ohne Nonce kann jede fremde Website ein Formular bauen, das an admin-post.php sendet. Öffnet ein angemeldeter Administrator diese Seite, schickt sein Browser das Anmelde-Cookie mit, und die Änderung läuft unter seinem Konto. Das Nonces-Kapitel beschreibt Nonces als Schutz gegen solche Angriffe, darunter CSRF; gegen Replay-Angriffe schützen sie nicht, weil WordPress nicht prüft, ob eine Nonce schon benutzt wurde. Eine Nonce ist nach wp_verify_nonce() standardmäßig zwischen 12 und 24 Stunden gültig. Der Quelltext von wp_create_nonce() bildet sie aus Aktion, Nutzer-ID, Session-Token und Zeitfenster.
Die Korrektur besteht aus zwei Teilen. Das Formular gibt mit wp_nonce_field( 'lw_nb_save', 'lw_nb_nonce' ) ein verstecktes Feld aus; die Zeile steht bereits in der Fassung von lw_nb_render_page() aus Befund 2. Der Handler prüft das Feld mit check_admin_referer(), bevor er irgendetwas liest:
/**
* Handles the settings form (version 0.3: nonce checked).
* Still missing: capability check (finding 4).
*/
function lw_nb_save() {
// Intent: did the request come from our own form? Dies with a 403 error page otherwise.
check_admin_referer( 'lw_nb_save', 'lw_nb_nonce' );
$label = isset( $_POST['label'] ) ? sanitize_text_field( wp_unslash( $_POST['label'] ) ) : '';
$link = isset( $_POST['link'] ) ? sanitize_url( wp_unslash( $_POST['link'] ), array( 'http', 'https' ) ) : '';
$message = isset( $_POST['message'] ) ? wp_kses_post( wp_unslash( $_POST['message'] ) ) : '';
$rows = isset( $_POST['rows'] ) ? absint( $_POST['rows'] ) : 10;
if ( '' === $label || mb_strlen( $label ) > 100 ) {
wp_die(
esc_html__( 'The label must contain 1 to 100 characters.', 'lw-notice-box' ),
'',
array( 'back_link' => true )
);
}
if ( $rows < 1 || $rows > 50 ) {
$rows = 10;
}
update_option(
'lw_nb_notice',
array(
'label' => $label,
'link' => $link,
'message' => $message,
'rows' => $rows,
)
);
lw_nb_log_change( $label );
wp_safe_redirect( admin_url( 'options-general.php?page=lw-notice-box' ) );
exit;
}
check_admin_referer() beendet die Anfrage bei ungültiger Nonce über wp_nonce_ays() mit einer Fehlerseite (Status 403, „The link you followed has expired.“); eine Rückfrage zeigt wp_nonce_ays() nur für die Aktion log-out. Ohne Aktionsnamen löst der Aufruf seit WordPress 3.2.0 einen _doing_it_wrong()-Hinweis aus; ein eindeutiger Aktionsname je Formular ist deshalb Pflicht. Wo die Prüfung von Hand mit wp_verify_nonce() geschieht, verlangen die „Common issues“, das Feld vorher mit wp_unslash() und sanitize_text_field() zu behandeln, weil die Funktion pluggable ist: wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['lw_nb_nonce'] ) ), 'lw_nb_save' ).
Befund 4: keine Capability-Prüfung, warum die Nonce nicht reicht
Version 0.3 wirkt sicher, ist es aber nicht. Die Code Reference beschreibt check_admin_referer() mit den Worten, die Funktion „verifies intent, not authorization“ und prüfe die Capabilities des Nutzers nicht; dafür sei current_user_can() zuständig. Das Nonces-Kapitel wird noch deutlicher: „Nonces should never be relied on for authentication, authorization, or access control.“ Funktionen seien mit current_user_can() zu schützen, und es sei stets anzunehmen, dass Nonces kompromittiert werden können.
Im Beispiel wird das konkret. add_options_page() mit manage_options schützt nur die Anzeige der Einstellungsseite. Der Handler hängt dagegen an admin_post_lw_nb_save, und admin-post.php feuert diesen Hook laut Quelltext für jeden angemeldeten Nutzer, ohne eigene Rechteprüfung. Eine Nonce hängt an Aktion, Nutzer und Sitzung. Gibt irgendein Teil des Plugins eine Nonce für lw_nb_save auch an Nutzer mit geringeren Rechten aus, etwa in einem Frontend-Formular, oder gelangt eine gültige Nonce auf anderem Weg nach außen, kann damit auch ein angemeldeter Abonnent die Einstellungen ändern. Eine Nonce beantwortet die Frage „Wollte dieser Nutzer diese Anfrage aus diesem Formular senden?“, nicht die Frage „Darf er das?“.
Version 1.0 stellt deshalb die Capability-Prüfung an den Anfang und behält die Nonce als zweite, unabhängige Prüfung:
/**
* Handles the settings form (version 1.0).
*/
function lw_nb_save() {
// Authorization: may this user change the settings at all?
if ( ! current_user_can( 'manage_options' ) ) {
wp_die(
esc_html__( 'You are not allowed to change these settings.', 'lw-notice-box' ),
'',
array( 'response' => 403 )
);
}
// Intent: did the request come from our own form?
check_admin_referer( 'lw_nb_save', 'lw_nb_nonce' );
// Sanitize early.
$label = isset( $_POST['label'] ) ? sanitize_text_field( wp_unslash( $_POST['label'] ) ) : '';
$link = isset( $_POST['link'] ) ? sanitize_url( wp_unslash( $_POST['link'] ), array( 'http', 'https' ) ) : '';
$message = isset( $_POST['message'] ) ? wp_kses_post( wp_unslash( $_POST['message'] ) ) : '';
$rows = isset( $_POST['rows'] ) ? absint( $_POST['rows'] ) : 10;
// Always validate.
if ( '' === $label || mb_strlen( $label ) > 100 ) {
wp_die(
esc_html__( 'The label must contain 1 to 100 characters.', 'lw-notice-box' ),
'',
array( 'back_link' => true )
);
}
if ( $rows < 1 || $rows > 50 ) {
$rows = 10;
}
update_option(
'lw_nb_notice',
array(
'label' => $label,
'link' => $link,
'message' => $message,
'rows' => $rows,
)
);
lw_nb_log_change( $label );
wp_safe_redirect( admin_url( 'options-general.php?page=lw-notice-box' ) );
exit;
}
Geprüft wird eine Capability, keine Rolle. Die Code Reference zu current_user_can() nennt die Prüfung auf Rollennamen „discouraged“, weil sie unzuverlässige Ergebnisse liefern kann. Für Aktionen an einzelnen Objekten gibt es Meta-Capabilities mit Objekt-ID, etwa current_user_can( 'edit_post', $post_id ), die WordPress über map_meta_cap() auf primitive Capabilities abbildet. Plugin Check hilft bei diesem Befund wenig: Die fünf Prüfungen im Security-Ordner des Plugin-Check-Repositorys behandeln Datenbankzugriffe, Escaping, Nonces und Weiterleitungen, eine fehlende Capability-Prüfung erkennt keine davon.
REST-Routen: permission_callback ist Pflicht
Bei REST-Routen verschiebt sich die Aufgabenteilung. Die Nonce übernimmt der Core: Bei Cookie-Authentifizierung erwartet die REST-API eine Nonce mit der Aktion wp_rest, übergeben als _wpnonce oder im Header X-WP-Nonce. Fehlt sie, setzt die API laut Handbook den aktuellen Nutzer auf 0, und die Anfrage gilt als nicht angemeldet. Die Berechtigung dagegen gehört in den permission_callback der Route. Version 0.1 lässt ihn weg:
// Version 0.1: since WordPress 5.5 this raises a _doing_it_wrong() notice,
// but the route is still registered - without any check, for everyone.
register_rest_route(
'lw-nb/v1',
'/notice',
array(
'methods' => 'POST',
'callback' => 'lw_nb_rest_update',
)
);
Seit WordPress 5.5 meldet register_rest_route() in diesem Fall einen _doing_it_wrong()-Hinweis. Bei aktivem WP_DEBUG erhält eine REST-Anfrage ihn als Antwort-Header X-WP-DoingItWrong; in debug.log landet er nur, wenn die Routen außerhalb einer REST-Anfrage registriert werden, etwa beim Laden des Block-Editors oder in WP-CLI. Registriert wird die Route trotzdem, und der REST-Server ruft die Berechtigungsprüfung nur auf, wenn ein Callback gesetzt ist. Die Dev-Note zu 5.5 begründet den Hinweis damit, dass ein vergessener oder falsch geschriebener Callback eine Route unbeabsichtigt öffentlich macht. Für absichtlich öffentliche Routen empfehlen Handbook und Hinweistext __return_true, damit die Absicht im Code steht.
/**
* Registers GET (public) and POST (administrators only) for /lw-nb/v1/notice.
*/
function lw_nb_register_routes() {
register_rest_route(
'lw-nb/v1',
'/notice',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'lw_nb_rest_get',
'permission_callback' => '__return_true', // Public on purpose.
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => 'lw_nb_rest_update',
'permission_callback' => 'lw_nb_rest_can_edit',
'args' => array(
'label' => array(
'type' => 'string',
'required' => true,
'minLength' => 1,
'maxLength' => 100,
// A custom sanitize_callback replaces the default schema check,
// so the validation callback is set explicitly.
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_text_field',
),
),
),
)
);
}
/**
* Authorization for write access.
*
* @return bool
*/
function lw_nb_rest_can_edit() {
return current_user_can( 'manage_options' );
}
/**
* Public read access: only the fields the front end needs.
*
* @return WP_REST_Response
*/
function lw_nb_rest_get() {
$opt = wp_parse_args( get_option( 'lw_nb_notice', array() ), lw_nb_defaults() );
return rest_ensure_response(
array(
'label' => $opt['label'],
'link' => $opt['link'],
)
);
}
/**
* Write access; runs only after lw_nb_rest_can_edit() returned true.
*
* @param WP_REST_Request $request Request with the sanitized label.
* @return WP_REST_Response
*/
function lw_nb_rest_update( WP_REST_Request $request ) {
$opt = wp_parse_args( get_option( 'lw_nb_notice', array() ), lw_nb_defaults() );
$opt['label'] = $request->get_param( 'label' );
update_option( 'lw_nb_notice', $opt );
lw_nb_log_change( $opt['label'] );
return rest_ensure_response( array( 'label' => $opt['label'] ) );
}
Gibt der Callback false zurück, antwortet der Server mit dem Fehler rest_forbidden, und zwar mit Status 401 für nicht angemeldete und 403 für angemeldete Nutzer. Eine Falle steckt in der Argumentdefinition: Ist ein eigener sanitize_callback gesetzt, verwendet WordPress nicht mehr die Standardbehandlung rest_parse_request_arg(), die das Schema prüft. Deshalb steht rest_validate_request_arg ausdrücklich als validate_callback in der Definition, sonst blieben minLength und maxLength wirkungslos. Wie sich REST-Endpunkte schnell und mit OAuth-2.0-Autorisierung bauen lassen, zeigen die Beiträge zu hochperformanten eigenen REST-Endpunkten und zur sicheren REST-API mit autorisierten Endpunkten.
Grenzen und offene Punkte
Der Code lief auf einer Testinstallation mit WordPress 7.1.2 und PHP 8.4, als Administrator, als Abonnent und ohne Anmeldung, und wurde mit Plugin Check 2.1.0 geprüft. Für Version 1.0 meldet Plugin Check außer der fehlenden readme.txt nichts; ohne die phpcs:ignore-Kommentare blieben fünf Warnungen: zweimal NonceVerification.Recommended für den Sortierparameter, DirectQuery für $wpdb->insert() und für die Log-Abfrage sowie NoCaching für die Log-Abfrage. Für Version 0.1 listet er 10 Fehler und 17 Warnungen im Code, keine davon zur fehlenden Capability-Prüfung oder zum fehlenden permission_callback.
Einige Themen bleiben bewusst außen vor: AJAX-Handler mit check_ajax_referer(), Datei-Uploads, eigene Rollen und Capabilities sowie Content Security Policy. Die vier Befunde decken die häufigen Grundlagen ab, ersetzen aber kein vollständiges Sicherheitskonzept. Und die Allowlist für Sortierspalten ist eine Validierung, die nur so gut ist wie ihre Pflege; kommt eine Spalte hinzu, muss auch die Liste wachsen.
Fragen und Antworten
Braucht auch ein GET-Parameter, der nur die Sortierung ändert, eine Nonce?
Nonces schützen Aktionen, die etwas verändern. Ein Parameter wie orderby, der nur die Anzeige sortiert, ändert keinen Zustand. Plugin Check meldet den Zugriff trotzdem als Warnung WordPress.Security.NonceVerification.Recommended; ein phpcs:ignore-Kommentar mit Begründung dokumentiert die Entscheidung. Validiert werden muss der Wert dennoch, im Beispiel über eine Allowlist erlaubter Spalten.
Reicht sanitize_text_field() als Schutz gegen SQL-Injection?
Nein. sanitize_text_field() entfernt Tags, Zeilenumbrüche und ungültiges UTF-8, macht einen Wert aber nicht SQL-sicher. Werte gehören als Platzhalter in $wpdb->prepare() oder als Formatliste in $wpdb->insert(). Spalten- und Tabellennamen brauchen seit WordPress 6.2 %i und zusätzlich eine Allowlist.
Warum nicht einfach prüfen, ob der Nutzer die Rolle „administrator“ hat?
Rollen lassen sich umbauen, Capabilities sind die eigentliche Rechteeinheit. Die Code Reference zu current_user_can() nennt die Prüfung auf Rollennamen „discouraged“, weil sie unzuverlässige Ergebnisse liefern kann. Zu beachten ist außerdem: Für Super-Admins in einem Multisite-Netzwerk liefert current_user_can() immer true, sofern die Capability nicht ausdrücklich verweigert wird.
Muss eine eigene REST-Route die Nonce selbst prüfen?
Bei Cookie-Authentifizierung prüft der Core die Nonce mit der Aktion wp_rest. Fehlt sie, gilt die Anfrage als nicht angemeldet, der aktuelle Nutzer ist 0. Die Route selbst kümmert sich um die Berechtigung im permission_callback, etwa mit current_user_can( 'manage_options' ); öffentliche Routen setzen ausdrücklich __return_true.
Quellen
Abgerufen am 29.09.2026.
- Common APIs Handbook: Security
- Common APIs Handbook: Sanitizing Data
- Common APIs Handbook: Data Validation
- Common APIs Handbook: Escaping Data
- Common APIs Handbook: Nonces
- Common APIs Handbook: User Roles and Capabilities
- Plugin Handbook: Common issues (Plugin-Review-Team)
- REST API Handbook: Adding Custom Endpoints
- REST API Handbook: Authentication
- Make WordPress Core: REST API changes in WordPress 5.5
- Code Reference: sanitize_text_field()
- Code Reference: absint()
- Code Reference: wp_unslash()
- Code Reference: esc_html()
- Code Reference: esc_attr()
- Code Reference: esc_url()
- Code Reference: wp_kses_post()
- Code Reference: wp_nonce_field()
- Code Reference: check_admin_referer()
- Code Reference: wp_verify_nonce()
- Code Reference: current_user_can()
- Code Reference: wpdb::prepare()
- Code Reference: register_rest_route()
- Code Reference: admin_post_{$action}
- wordpress-develop: wp-admin/admin-post.php
- wordpress-develop: wp-includes/pluggable.php (wp_create_nonce, check_admin_referer)
- wordpress-develop: wp-includes/load.php (wp_magic_quotes)
- wordpress-develop: class-wp-rest-server.php
- wordpress-develop: class-wp-rest-request.php
- Plugin Check: Security-Prüfungen
- WordPress Releases (aktuell 7.1.2 vom 22.09.2026)