LW IT Solutions
« Przegląd bloga /Wtyczki i triki WordPress / Sanityzacja, escaping, Nonce i Capability w przeglądzie...
Ten artykuł w innych językach:

Sanityzacja, escaping, Nonce i Capability w przeglądzie wtyczek

Sanityzacja, escaping, Nonce i Capability w przeglądzie wtyczek
Spis treści
  1. Zasada: sanitize early, escape late, always validate
  2. Przykładowa wtyczka w wersji 0.1
  3. Błąd 1: dane wejściowe przyjęte bez kontroli
  4. Błąd 2: dane wyjściowe bez escapingu
  5. Błąd 3: brak Nonce w formularzu
  6. Błąd 4: brak kontroli Capability, czyli dlaczego Nonce nie wystarcza
  7. Trasy REST: permission_callback jest obowiązkowy
  8. Ograniczenia i otwarte kwestie
  9. Pytania i odpowiedzi
  10. Źródła

Uwagi dotyczące bezpieczeństwa, które wracają z przeglądu wtyczek na WordPress.org, rzadko dotyczą czegoś egzotycznego. Strona „Common issues” zespołu Plugin Review zaczyna się od sekcji „Security”, a jej punkty są elementarne: dane wejściowe bez sanityzacji, dane wyjściowe bez escapingu, wartości Nonce przekazywane dalej bez sanityzacji, zapytania SQL budowane bez prepare(). Rozdział Security w Developer Handbook uzupełnia ten obraz o Nonce i Capability. Ostatni z tych punktów najłatwiej przeoczyć: handler formularza sprawdza Nonce, ale nie sprawdza, czy zalogowany użytkownik w ogóle ma prawo wykonać daną akcję, a testy bezpieczeństwa w Plugin Check tej luki nie zgłaszają.

Artykuł omawia cztery błędy na przykładzie niewielkiej wtyczki z panelem administracyjnym. Wersja 0.1 zawiera wszystkie błędy naraz, każda kolejna usuwa jeden z nich, więc przy każdym błędzie stoi kod wadliwy i kod poprawiony. Na końcu dochodzi część o trasach REST, w których kontrola uprawnień nazywa się permission_callback i od WordPressa 5.5 jest formalnie obowiązkowa. Opisy funkcji pochodzą z Developer Handbook i Code Reference na developer.wordpress.org, według stanu dla WordPressa 7.1.2.

Zasada: sanitize early, escape late, always validate

Strona „Common issues” streszcza wymaganie w jednym zdaniu: „Sanitize early, Escape Late, Always Validate”. Rozdział Security w Common APIs Handbook ujmuje to samo w postaci zasad przewodnich, wśród nich „Never trust user input”, „Escape as late as possible” oraz „Escape everything from untrusted sources”, przy czym do niezaufanych źródeł wprost zalicza bazę danych. Kolejna zasada mówi, że sanityzacja jest w porządku, ale walidacja i odrzucenie danych są lepsze.

Te trzy pojęcia oznaczają różne czynności. Walidacja porównuje dane z ustalonym wzorcem i daje jednoznaczny wynik: poprawne albo niepoprawne; podręcznik zaleca listy dozwolonych wartości porównywane przez in_array() w trybie ścisłym. Sanityzacja filtruje dane wejściowe tak, aby dało się je zapisać, na przykład przez sanitize_text_field(). Escaping zabezpiecza wartość dla dokładnie jednego kontekstu wyjścia: tekstu HTML, atrybutu, adresu URL lub pola textarea. Strona „Common issues” podkreśla, że obie grupy funkcji nie są wymienne: funkcje escapujące nie nadają się do sanityzacji, a funkcje sanityzujące do escapingu.

Przy żądaniach zmieniających dane przed wszystkimi trzema etapami stoi jeszcze bramka: czy ten użytkownik ma do tego prawo (Capability) i czy żądanie pochodzi z własnego formularza wtyczki (Nonce)? Diagram pokazuje drogę wartości przez te etapy wraz z funkcjami używanymi na każdym z nich.

Jedna wartość, sześć etapów: od żądania do bezpiecznego wyjścia
Najpierw bramka, potem wczesna sanityzacja i walidacja, zapis z symbolami zastępczymi, późny escaping

Przykładowa wtyczka w wersji 0.1

Wtyczka „LW Notice Box” dodaje stronę w menu Ustawienia. Zapisuje etykietę, link, komunikat mogący zawierać HTML oraz liczbę wyświetlanych wierszy dziennika. Każda zmiana trafia jako wiersz do własnej tabeli, shortcode wyświetla komunikat na stronie, a trasa REST pozwala zmienić etykietę. Plik jest kompletny i działa, ale celowo jest niebezpieczny:

<?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 );
}

Komentarze „FLAW” wskazują, gdzie tkwi który błąd. Funkcje lw_nb_install(), lw_nb_defaults() i lw_nb_menu() pozostają takie same we wszystkich wersjach; pozostałe zostają w kolejnych sekcjach zastąpione funkcjami o tej samej nazwie. Po podmianie wszystkich poprawionych funkcji i podniesieniu numeru wersji powstaje wersja 1.0.

Błąd 1: dane wejściowe przyjęte bez kontroli

Handler z wersji 0.1 zapisuje w opcji całą tablicę $_POST. Do bazy trafia więc także pole action i każde dodatkowe pole, jakie wyśle atakujący, i to wciąż z ukośnikami, które WordPress podczas ładowania dokleja w wp_magic_quotes() do $_GET, $_POST, $_COOKIE i $_SERVER. Strona „Common issues” zdecydowanie odradza przetwarzanie całego stosu $_POST, $_REQUEST czy $_GET.

// 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())" );

Wersja 0.2 odczytuje wyłącznie cztery potrzebne pola. Każde przechodzi najpierw przez wp_unslash(), która według Code Reference usuwa ukośniki z ciągu znaków albo rekurencyjnie z ciągów w tablicy, a potem przez odpowiednią funkcję sanityzującą. sanitize_text_field() sprawdza poprawność UTF-8, zamienia pojedyncze znaki < na encje, usuwa wszystkie znaczniki, a także podziały wierszy, tabulatory i nadmiarowe spacje. sanitize_url() otrzymuje listę dozwolonych protokołów, a wp_kses_post() zostawia tylko HTML dozwolony w treści wpisów. Dla liczby wierszy wystarczy absint(), która zamienia dowolną wartość na nieujemną liczbę całkowitą.

/**
 * 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' )
	);
}

Po sanityzacji następuje walidacja. Pusta lub zbyt długa etykieta nie jest po cichu przycinana, lecz odrzucana. Liczba wierszy musi mieścić się między 1 a 50, w przeciwnym razie obowiązuje wartość domyślna. Te granice to reguły biznesowe wtyczki i żadna funkcja sanityzująca ich nie zna. Wpis w dzienniku zapisuje teraz $wpdb->insert() z listą formatów, która określa każdą wartość jako liczbę całkowitą lub ciąg znaków.

Komentarz phpcs:ignore odpowiada na ostrzeżenie, które Plugin Check zgłasza przy każdym bezpośrednim dostępie do bazy. Dla własnej tabeli nie istnieje API w rdzeniu, dlatego uzasadnienie znajduje się w komentarzu. Tym, jak Plugin Check klasyfikuje takie komunikaty, zajmuje się osobny artykuł z tej serii.

Przypadek szczególny SQL: $wpdb->prepare() z %s, %d i %i

Sanityzacja nie chroni przed SQL injection. Przy własnych zapytaniach strona „Common issues” wymaga metod wpdb w połączeniu z prepare(). Błąd w wersji 0.1 kryje się w sortowaniu:

// 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']}" );

Parametr trafia do ORDER BY bez cudzysłowów, więc dodatkowe ukośniki z wp_magic_quotes() niczego tam nie zmieniają. Według Code Reference $wpdb->prepare() obsługuje symbole zastępcze %d (liczba całkowita), %f (liczba zmiennoprzecinkowa), %s (ciąg znaków) oraz %i (identyfikator, czyli nazwa tabeli lub kolumny). %i pojawił się w WordPressie 6.2, dlatego przykładowa wtyczka deklaruje w nagłówku „Requires at least: 6.2”. Symbole zastępcze pozostają w zapytaniu bez cudzysłowów, a każdemu odpowiada dokładnie jeden 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 ) )
		)
	);
}

Kolejność kroków w tej funkcji ma znaczenie. %i poprawnie ujmuje nazwę w grawisy, ale nie ogranicza tego, jaka nazwa przychodzi; bez listy dozwolonych wartości nadal dałoby się sortować po dowolnej kolumnie tabeli. Lista dozwolonych wartości jest walidacją, symbol zastępczy zabezpieczeniem technicznym. Dla wyszukiwania przez LIKE Code Reference podaje dodatkową regułę: cały wzorzec razem ze znakami procentu przekazuje się jako argument, a szukany ciąg należy wcześniej przepuścić przez $wpdb->esc_like(), na przykład $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE label LIKE %s', $table, '%' . $wpdb->esc_like( $term ) . '%' ). Dla list w IN ( … ) strona „Common issues” pokazuje wzorzec, który tworzy osobny symbol zastępczy dla każdego elementu.

Błąd 2: dane wyjściowe bez escapingu

Wersja 0.1 wypisuje każdą zapisaną wartość w surowej postaci, zarówno w formularzu administracyjnym, jak i w shortcode. Cudzysłów w etykiecie wyrywa się z atrybutu value, a </textarea> w komunikacie przedwcześnie zamyka pole tekstowe. Sanityzacja przy zapisie nie czyni escapingu zbędnym, bo zapisane wartości mogą trafić do bazy także inną drogą: przez import, inną wtyczkę albo starszą wersję własnego kodu.

// 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” oznacza escaping bezpośrednio przy echo i w sposób dopasowany do kontekstu. Rozdział o escapingu w podręczniku podaje przyporządkowanie, a strona „Common issues” wymaga, aby wszystkie zmienne, opcje i generowane dane escapować w chwili wypisywania, a nie podczas budowania zmiennej.

Kontekst wyjścia Funkcja Zastosowanie we wtyczce
Tekst między znacznikami HTML esc_html() Tytuł strony, wiersze dziennika
Wartość atrybutu HTML esc_attr() value etykiety i liczby wierszy
URL w href, src, action esc_url() Cel formularza, linki sortowania, link komunikatu
Zawartość pola textarea esc_textarea() Komunikat w formularzu
HTML, który ma zostać zachowany wp_kses_post() Komunikat w shortcode
Tekst tłumaczony esc_html_e(), esc_attr__() Etykiety pól
/**
 * 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'] )
	);
}

Trzy szczegóły tej wersji zasługują na uwagę. Według Code Reference esc_url() zwraca pusty ciąg, gdy URL używa protokołu spoza dozwolonej listy, więc zapisany link javascript: nie zadziała. add_query_arg() bez trzeciego parametru pracuje na adresie bieżącego żądania i dlatego również wymaga escapingu. Z kolei esc_url_raw() według strony „Common issues” nie jest funkcją escapującą, tylko sanityzującą, przeznaczoną dla bazy danych lub przekierowania, podobnie jak sanitize_url(). Plugin Check wykrywa ten błąd za pomocą reguły WordPress.Security.EscapeOutput, ale tylko przy bezpośrednim wypisywaniu, takim jak echo lub printf(): dla wersji 0.1 zgłosił siedem miejsc na stronie ustawień, a niezabezpieczonej wartości zwracanej przez shortcode nie.

Błąd 3: brak Nonce w formularzu

Bez Nonce dowolna obca strona może zbudować formularz wysyłany do admin-post.php. Gdy zalogowany administrator ją otworzy, przeglądarka dołączy ciasteczko logowania i zmiana zostanie wykonana na jego koncie. Rozdział o Nonce opisuje je jako ochronę przed kilkoma rodzajami ataków, w tym CSRF; przed atakami typu replay nie chronią, bo WordPress nie sprawdza, czy dana Nonce została już użyta. Według opisu wp_verify_nonce() Nonce jest domyślnie ważna od 12 do 24 godzin, a kod źródłowy wp_create_nonce() wylicza ją z akcji, identyfikatora użytkownika, tokenu sesji i przedziału czasu.

Poprawka składa się z dwóch części. Formularz wypisuje ukryte pole przez wp_nonce_field( 'lw_nb_save', 'lw_nb_nonce' ); ta linia jest już w wersji lw_nb_render_page() z opisu błędu 2. Handler sprawdza pole przez check_admin_referer(), zanim odczyta cokolwiek innego:

/**
 * 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;
}

Przy nieprawidłowej Nonce check_admin_referer() przerywa żądanie przez wp_nonce_ays() stroną błędu (status 403, „The link you followed has expired.”); pytanie z potwierdzeniem wp_nonce_ays() pokazuje tylko dla akcji log-out. Wywołanie bez nazwy akcji od WordPressa 3.2.0 wywołuje komunikat _doing_it_wrong(), dlatego osobna nazwa akcji dla każdego formularza jest obowiązkowa. Tam, gdzie kontrola odbywa się ręcznie przez wp_verify_nonce(), strona „Common issues” wymaga wcześniejszego przepuszczenia pola przez wp_unslash() i sanitize_text_field(), ponieważ funkcja jest typu pluggable: wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['lw_nb_nonce'] ) ), 'lw_nb_save' ).

Błąd 4: brak kontroli Capability, czyli dlaczego Nonce nie wystarcza

Wersja 0.3 wygląda na bezpieczną, ale taka nie jest. Code Reference opisuje check_admin_referer() jako funkcję, która „verifies intent, not authorization” i nie sprawdza uprawnień użytkownika; do tego służy current_user_can(). Rozdział o Nonce ujmuje to jeszcze dobitniej: „Nonces should never be relied on for authentication, authorization, or access control.” Funkcje należy chronić przez current_user_can() i zawsze zakładać, że Nonce może zostać przejęta.

W przykładowej wtyczce widać to wyraźnie. add_options_page() z manage_options chroni jedynie wyświetlanie strony ustawień. Handler jest natomiast podpięty pod admin_post_lw_nb_save, a admin-post.php według kodu źródłowego uruchamia ten Hook dla każdego zalogowanego użytkownika, bez własnej kontroli uprawnień. Nonce jest powiązana z akcją, użytkownikiem i sesją. Jeśli jakakolwiek część wtyczki wydaje Nonce dla lw_nb_save także użytkownikom o mniejszych uprawnieniach, na przykład w formularzu na stronie, albo jeśli ważna Nonce wycieknie inną drogą, zalogowany subskrybent zmieni za jej pomocą ustawienia. Nonce odpowiada na pytanie „Czy ten użytkownik chciał wysłać to żądanie z tego formularza?”, a nie na pytanie „Czy wolno mu to zrobić?”.

Wersja 1.0 stawia więc kontrolę Capability na początku i zachowuje Nonce jako drugą, niezależną kontrolę:

/**
 * 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;
}

Sprawdzana jest Capability, a nie rola. Code Reference dla current_user_can() określa sprawdzanie nazw ról jako „discouraged”, bo może dawać niewiarygodne wyniki. Dla działań na pojedynczych obiektach istnieją meta Capability z identyfikatorem obiektu, na przykład current_user_can( 'edit_post', $post_id ), które WordPress mapuje na Capability podstawowe przez map_meta_cap(). Plugin Check niewiele tu pomoże: pięć testów w folderze Security repozytorium Plugin Check dotyczy dostępu do bazy, escapingu, Nonce i przekierowań, i żaden z nich nie wykrywa brakującej kontroli Capability.

Trasy REST: permission_callback jest obowiązkowy

W trasach REST podział zadań wygląda inaczej. Nonce obsługuje rdzeń: przy uwierzytelnianiu ciasteczkiem REST API oczekuje Nonce z akcją wp_rest, przekazanej jako _wpnonce albo w nagłówku X-WP-Nonce. Jeśli jej brakuje, według podręcznika API ustawia bieżącego użytkownika na 0 i traktuje żądanie jako nieuwierzytelnione. Uprawnienia natomiast należą do permission_callback trasy. Wersja 0.1 go pomija:

// 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',
	)
);

Od WordPressa 5.5 register_rest_route() zgłasza w takim przypadku komunikat _doing_it_wrong(). Przy włączonym WP_DEBUG żądanie REST otrzymuje go w nagłówku odpowiedzi X-WP-DoingItWrong; do debug.log trafia on tylko wtedy, gdy trasy są rejestrowane poza żądaniem REST, na przykład przy ładowaniu edytora bloków lub w WP-CLI. Trasa i tak zostaje zarejestrowana, a serwer REST uruchamia kontrolę uprawnień tylko wtedy, gdy callback jest ustawiony. Notatka deweloperska do wersji 5.5 uzasadnia ten komunikat tym, że zapomniany lub błędnie wpisany callback przypadkiem czyni endpoint publicznym. Dla tras celowo publicznych zarówno podręcznik, jak i treść komunikatu zalecają __return_true, aby zamiar był widoczny w kodzie.

/**
 * 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'] ) );
}

Gdy callback zwróci false, serwer odpowiada błędem rest_forbidden ze statusem 401 dla niezalogowanych i 403 dla zalogowanych użytkowników. Pułapka kryje się w definicji argumentów: po ustawieniu własnego sanitize_callback WordPress nie stosuje już domyślnej obsługi rest_parse_request_arg(), która sprawdza schemat. Dlatego rest_validate_request_arg jest wprost podany jako validate_callback; bez tego minLength i maxLength nie miałyby żadnego skutku. O budowie szybkich endpointów REST i endpointów z autoryzacją OAuth 2.0 traktują artykuły o wydajnych własnych endpointach REST API oraz o bezpiecznym REST API z autoryzowanymi endpointami.

Ograniczenia i otwarte kwestie

Kod uruchomiono na instalacji testowej z WordPressem 7.1.2 i PHP 8.4, jako administrator, jako subskrybent i bez logowania, oraz sprawdzono przez Plugin Check 2.1.0. Dla wersji 1.0 Plugin Check poza brakującym plikiem readme.txt nie zgłasza niczego; bez komentarzy phpcs:ignore pozostałoby pięć ostrzeżeń: dwa razy NonceVerification.Recommended dla parametru sortowania, DirectQuery dla $wpdb->insert() i dla zapytania o log oraz NoCaching dla zapytania o log. Dla wersji 0.1 wymienia 10 błędów i 17 ostrzeżeń w kodzie, żadne z nich nie dotyczy brakującej kontroli Capability ani brakującego permission_callback.

Niektóre tematy zostały świadomie pominięte: handlery AJAX z check_ajax_referer(), przesyłanie plików, własne role i Capability oraz Content Security Policy. Cztery omówione błędy obejmują częste podstawy, ale nie zastępują pełnej koncepcji bezpieczeństwa. Lista dozwolonych kolumn sortowania jest walidacją tak dobrą, jak jej utrzymanie: gdy przybywa kolumna, lista też musi się wydłużyć.

Pytania i odpowiedzi

Czy parametr GET, który zmienia tylko sortowanie, też wymaga Nonce?

Nonce chroni akcje, które coś zmieniają. Parametr taki jak orderby, służący wyłącznie do sortowania widoku, nie zmienia stanu. Plugin Check i tak zgłasza dostęp jako ostrzeżenie WordPress.Security.NonceVerification.Recommended, a komentarz phpcs:ignore z uzasadnieniem dokumentuje decyzję. Wartość nadal trzeba zwalidować, w przykładzie przez listę dozwolonych kolumn.

Czy sanitize_text_field() wystarcza jako ochrona przed SQL injection?

Nie. sanitize_text_field() usuwa znaczniki, podziały wierszy i niepoprawne UTF-8, ale nie czyni wartości bezpieczną dla SQL. Wartości należy przekazywać jako symbole zastępcze w $wpdb->prepare() albo przez listę formatów w $wpdb->insert(). Nazwy kolumn i tabel wymagają %i, dostępnego od WordPressa 6.2, oraz listy dozwolonych wartości.

Dlaczego nie sprawdzać po prostu, czy użytkownik ma rolę „administrator”?

Role da się przebudować, a właściwą jednostką uprawnień są Capability. Code Reference dla current_user_can() określa sprawdzanie nazw ról jako „discouraged”, bo może dawać niewiarygodne wyniki. Warto też pamiętać, że dla superadministratorów w sieci Multisite current_user_can() zawsze zwraca true, o ile dana Capability nie została wyraźnie zablokowana.

Czy własna trasa REST musi sama sprawdzać Nonce?

Przy uwierzytelnianiu ciasteczkiem Nonce z akcją wp_rest sprawdza rdzeń. Bez niej żądanie jest traktowane jako nieuwierzytelnione, a bieżący użytkownik ma identyfikator 0. Sama trasa odpowiada za uprawnienia w permission_callback, na przykład przez current_user_can( 'manage_options' ); trasy publiczne ustawiają wprost __return_true.

Lukas Wójcik

Lukas Wójcik

Systems architect and technology enthusiast specializing in scalable tracking solutions, GMP Stack (GA4 & GTM), and robust backend architectures. Advocate for clean code and privacy-first design.

Get in Touch

Briefly describe your project or inquiry for a tailored response. This site is protected by reCAPTCHA.

Napisanie komentarza

Doświadczenia z innymi wtyczkami lub środowiskami hostingowymi oraz pytania o konfigurację są tu mile widziane.

Adres e-mail nie jest publikowany. Pola obowiązkowe oznaczono gwiazdką.

Artykuły i kategorie

CCTV

Śledź tę kategorię przez RSS

Cloud & AI

Wszystkie artykuły w tej kategorii (11) Śledź tę kategorię przez RSS

Data Privacy

Wszystkie artykuły w tej kategorii (18) Śledź tę kategorię przez RSS

Digital Analytics

Wszystkie artykuły w tej kategorii (58) Śledź tę kategorię przez RSS

Digital Marketing

Wszystkie artykuły w tej kategorii (37) Śledź tę kategorię przez RSS

IT & Networks

Wszystkie artykuły w tej kategorii (17) Śledź tę kategorię przez RSS

Music Production

Wszystkie artykuły w tej kategorii (16) Śledź tę kategorię przez RSS

Raspberry Pi

Śledź tę kategorię przez RSS

SaaS & Internet Earning

Artykuły w przygotowaniu

Smart Home

Wszystkie artykuły w tej kategorii (18) Śledź tę kategorię przez RSS

Tworzenie stron internetowych

Wszystkie artykuły w tej kategorii (11) Śledź tę kategorię przez RSS

Wtyczki i triki WordPress

Wszystkie artykuły w tej kategorii (13) Śledź tę kategorię przez RSS