Security for Developers
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), PHP 8.4 and WordPress Coding Standards (WPCS) 3.4.1, and checked on 2026-10-28. WordPress
7.1 requires PHP 7.4 at minimum, and PHP 8.3 or newer is recommended;
WordPress 7.2 (beta, due December 2026) is not expected to change anything here. All output comes from a local site
(wordpress:7.1.3-php8.4-apache, MariaDB 11.8, WP-CLI 2.12.0) at http://lumenpress.test:8080; the attacks are
minimal demonstrations to run only against a disposable site of your own.
This is the developer’s side of WordPress security; the administrator’s side (HTTPS, two factors, brute-force limits,
file permissions, headers, incident response) is in Security Hardening.
The examples extend lumen-catalog, the catalogue plugin of the fictitious publisher Lumen Press, built in
Plugin Fundamentals.
Why extension code is the weak point
WordPress core has a security team, coordinated disclosure and automatic minor releases backported to older branches. Plugins and themes run with the full privileges of core (any PHP in an active plugin can read the database, write files and act as any user) without that process, and almost every WordPress compromise starts in their code. The flaws fall into a handful of patterns, mapped here to the OWASP Top 10 (2025 edition):
| OWASP category | Typical plugin flaw, and the WordPress defense |
|---|---|
A01 Broken Access Control |
A handler that only checks login or |
A05 Injection |
Variables concatenated into SQL, values printed unescaped (XSS): |
A06 Insecure Design |
Uploads kept with the client’s extension: |
A08 Data Integrity Failures |
|
A03 Supply Chain Failures |
Bundled vulnerable libraries: pinned, audited Composer dependencies (Composer and Packages) |
The data flow: check, validate, sanitize, escape
Data crossing a trust boundary passes a fixed sequence: authorize the request, unslash, validate (reject what is not in an allowed form), sanitize (clean into a safe form), store, and escape for the exact output context as late as possible, so that values stored by an older version, another plugin or a database edit are still safe.
Validation answers yes or no and is the stronger option whenever the valid values are known; sanitizing transforms free text. A value sanitized on input is still escaped on output, because the output context decides which characters are dangerous.
Capabilities
Authorization in WordPress is capability-based. A role is only a named set of capabilities, so code checks
capabilities, never role names: current_user_can( $capability, $object_id ) for the current user and user_can() for
another one.
-
Primitive capabilities are stored in roles:
edit_posts,edit_others_posts,publish_posts,manage_options,upload_files,edit_theme_options,unfiltered_html, and custom ones a plugin adds on activation. -
Meta capabilities such as
edit_post,delete_post,read_post,edit_userandedit_termtake an object ID.map_meta_cap()translates them into the primitive capabilities that apply to that object. Checkingedit_postsinstead ofcurrent_user_can( 'edit_post', $post_id )lets an Author change anybody’s books. -
Custom post types get the same mapping, or their own capabilities, see Roles and Capabilities API.
is_admin() is often mistaken for authorization. It returns true when an admin screen is loading, including
admin-ajax.php and admin-post.php, which every logged-in user can reach; it says nothing about the user.
is_user_logged_in() is true for a Subscriber. Neither replaces a capability check. On the test site,
map_meta_cap( 'edit_post', $user_id, 4 ) for a published book written by someone else returns
edit_others_posts,edit_published_posts, so current_user_can( 'edit_post', 4 ) is true for the Editor
maria.editor and false for the Author tom.author.
Nonces
A nonce is WordPress’s protection against cross-site request forgery (CSRF): a page on another site that makes a logged-in user’s browser submit a form, load a URL or call an endpoint. The browser sends the user’s cookies, so a capability check alone passes; the nonce proves that the request came from a page WordPress generated for that user.
Despite the name, a WordPress nonce is reusable: a hash of the action, user ID, session token and time tick, valid 24
hours by default (two 12-hour ticks; filter nonce_life). Another user’s or session’s nonce fails; logged-out visitors
all share user ID 0. A nonce is not authorization: it shows that the user meant to send the request, not that they
may, and nonces printed in pages can leak, so every handler also checks current_user_can(). Include the object ID in
the action (lumen_feature_book_42) so a nonce for one book cannot be replayed against another.
| Where | Create | Verify |
|---|---|---|
Admin form, or a link that changes state |
|
|
|
|
|
REST API with cookies |
|
Core checks the |
Custom code calls wp_verify_nonce(), which returns 1 (first 12 hours), 2 (second 12 hours) or false. REST
requests authenticated with application passwords or OAuth need no nonce, since there are no cookies for another
site to ride on; see Authentication and Login API.
Validating and sanitizing input
WordPress adds slashes to $_GET, $_POST, $_COOKIE, $_SERVER and $_REQUEST at load time
(wp_magic_quotes()), so superglobals are always read through wp_unslash() first, or O'Reilly is stored as
O\'Reilly. The slashes are not a defense: they do nothing in numeric SQL or HTML contexts and disappear as soon as
code unslashes, as it must. REST parameters and get_option() values are not slashed.
| Function | Use it for |
|---|---|
|
IDs and counts; |
|
Plain text: strips tags, invalid UTF-8, extra whitespace and (single-line) line breaks |
|
Keys, slugs, file names ( |
|
A URL to store, redirect to or fetch; drops disallowed protocols such as |
|
HTML that keeps an allow-listed set of tags |
JSON Schema in |
REST arguments, registered meta and settings: one place that validates and sanitizes |
Validate against an allow-list wherever possible (a strict in_array(), a preg_match() for an ISBN, get_post_type()
equal to lp_book, is_email()) and reject invalid input with an error rather than silently "fixing" it.
Escaping output
Escape every dynamic value at the moment it is printed, with the function for that context. Late escaping makes each
echo statement show that its value is safe, which reviewers and the coding-standards sniffs can check without
tracing the data back.
| Context | Function |
|---|---|
HTML element content |
|
HTML attribute value (always quoted) |
|
URL in |
|
Data for a script |
|
HTML that keeps allowed tags |
|
<?php
$input = 'O\'Reilly <b onmouseover="alert(1)">bold</b> "quoted"';
foreach ( array( 'esc_html', 'esc_js', 'sanitize_text_field', 'wp_kses_post' ) as $fn ) {
printf( "%-20s %s\n", $fn, $fn( $input ) );
}
printf( "%-20s %s\n", 'wp_kses (em only)', wp_kses( $input . ' <em>ok</em>', array( 'em' => array() ) ) );
printf( "%-20s %s\n", 'esc_url', esc_url( 'javascript:alert(1)' ) );
printf( "%-20s %s\n", 'esc_url', esc_url( 'https://lumenpress.test/books/?a=1&b=2' ) );
printf( "%-20s %s\n", 'wp_json_encode', wp_json_encode( array( 'title' => '</script><b>' ), JSON_HEX_TAG ) );
esc_html O'Reilly <b onmouseover="alert(1)">bold</b> "quoted"
esc_js O\'Reilly <b onmouseover="alert(1)">bold</b> "quoted"
sanitize_text_field O'Reilly bold "quoted"
wp_kses_post O'Reilly <b>bold</b> "quoted"
wp_kses (em only) O'Reilly bold "quoted" <em>ok</em>
esc_url
esc_url https://lumenpress.test/books/?a=1&b=2
wp_json_encode {"title":"\u003C\/script\u003E\u003Cb\u003E"}
wp_kses_post() kept <b> but dropped the event handler. Users with unfiltered_html (Administrators and Editors,
only Super Admins on Multisite) may save any HTML in post content, which still does not make it safe elsewhere.
Translations are input too, hence esc_html__() rather than __().
Database queries
$wpdb runs whatever SQL it is given. Every query containing a variable goes through $wpdb->prepare(), which
takes sprintf()-style placeholders, never quoted by hand: %s (string, quoted and escaped), %d and %f (integer
and float), %i (identifier such as a table or column name, wrapped in backticks; added in WordPress 6.2 and detectable
with $wpdb->has_cap( 'identifier_placeholders' )) and %% (a literal %).
LIKE wildcards belong in the argument, with the user’s part passed through $wpdb->esc_like() so that a typed _
or % matches literally:
<?php
global $wpdb;
$sql = $wpdb->prepare(
'SELECT %i FROM %i WHERE meta_key = %s AND meta_value LIKE %s AND post_id = %d AND %f > 0',
'meta_value',
$wpdb->postmeta,
'lp_isbn',
'%' . $wpdb->esc_like( '978_1%' ) . '%',
'5 OR 1=1',
'3.5kg'
);
echo $wpdb->remove_placeholder_escape( $sql ), "\n"; // Printing only; pass $sql itself to $wpdb.
Source: Code Reference: wpdb::prepare()
SELECT `meta_value` FROM `lp_postmeta` WHERE meta_key = 'lp_isbn' AND meta_value LIKE '%978\\_1\\%%' AND post_id = 5 AND 3.500000 > 0
%d turned 5 OR 1=1 into 5; % signs stay masked until the query runs, hence remove_placeholder_escape().
-
$wpdb->insert(),update()anddelete()prepare their arrays themselves;query()andget_*()do not.INlists need one placeholder per value (array_fill()andimplode()). -
User-chosen identifiers (a sort column) still need an allow-list:
%iprevents injection, not unwanted columns. -
WP_Query,get_posts()and the metadata APIs escape for you; see WP_Query and the Database API.
A vulnerable admin action and its fix
The Lumen Press catalogue team wants a "Catalogue tools" screen: search books by ISBN, mark one as featured with a short note, and return to a given page. The first version works and contains five classic vulnerabilities. It is attacked and then fixed below; it must never run anywhere but a disposable local site.
The vulnerable version
<?php
// lumen-catalog/includes/admin-tools.php: DELIBERATELY VULNERABLE, for a local test site only.
defined( 'ABSPATH' ) || exit;
add_action( 'admin_menu', function () {
add_management_page( 'Catalogue tools', 'Catalogue tools', 'read', 'lumen-tools', 'lumen_catalog_tools_page' );
} );
function lumen_catalog_tools_page() {
global $wpdb;
$isbn = isset( $_GET['isbn'] ) ? wp_unslash( $_GET['isbn'] ) : '';
echo '<div class="wrap"><h1>Catalogue tools</h1>';
echo '<p>Featured note: ' . get_option( 'lumen_featured_note' ) . '</p>';
if ( $isbn ) {
echo '<p>Results for ' . $isbn . '</p>';
$rows = $wpdb->get_results(
"SELECT p.ID, p.post_title FROM {$wpdb->posts} p JOIN {$wpdb->postmeta} m ON m.post_id = p.ID
WHERE m.meta_key = 'lp_isbn' AND m.meta_value = '$isbn'"
);
foreach ( $rows as $row ) {
echo '<p>' . $row->post_title . ' <a href="admin-post.php?action=lumen_feature_book&book=' . $row->ID . '">Feature</a></p>';
}
}
echo '</div>';
}
add_action( 'admin_post_lumen_feature_book', function () {
update_option( 'lumen_featured_book', $_REQUEST['book'] );
update_option( 'lumen_featured_note', $_REQUEST['note'] ?? '' );
wp_redirect( $_REQUEST['redirect_to'] ?? admin_url( 'tools.php?page=lumen-tools' ) );
exit;
} );
The main plugin file loads it inside an is_admin() block (which is not an access check). The flaws:
-
Missing capability. The menu requires only
read, which every role has, and theadmin_post_{$action}handler checks nothing: any logged-in user can call it. -
CSRF. No nonce, so any web page can make a logged-in Editor’s browser load the handler URL.
-
SQL injection.
$isbnis concatenated into the query. Unslashing is correct; concatenating is the bug. -
XSS. The search term (reflected) and the note (stored) are printed without escaping.
-
Open redirect.
wp_redirect()follows anyredirect_to, including another site.
Attacking it on a local site
The site (Local Development Environments) has pretty
permalinks, maria.editor (Editor), tom.author (Author) and two lp_book posts, IDs 4 and 5, with lp_isbn
978-1-23456-789-7 and 978-1-23456-790-3. A Bash helper logs in and keeps the cookies in a jar, as a browser would:
SITE=http://lumenpress.test:8080
login() { # user, password, cookie jar
curl -s -o /dev/null -c "$3" -b 'wordpress_test_cookie=WP%20Cookie%20check' \
--data-urlencode "log=$1" --data-urlencode "pwd=$2" -d testcookie=1 "$SITE/wp-login.php"
}
login tom.author "$TOM_PASSWORD" tom.jar
login maria.editor "$MARIA_PASSWORD" maria.jar
Source: Code Reference: wp_signon()
The Author, who should not see the screen at all, searches with a ' that closes the string and an OR that makes
the condition true for every row:
curl -s -b tom.jar -G "$SITE/wp-admin/tools.php" -d page=lumen-tools \
--data-urlencode "isbn=x' OR '1'='1" | grep -o '<p>Results.*</p>'
<p>Results for x' OR '1'='1</p><p>Sample Page <a href="admin-post.php?action=lumen_feature_book&book=2">Feature</a></p><p>Privacy Policy <a href="admin-post.php?action=lumen_feature_book&book=3">Feature</a></p><p>The Lighthouse Keeper <a href="admin-post.php?action=lumen_feature_book&book=4">Feature</a></p><p>Salt and Ink <a href="admin-post.php?action=lumen_feature_book&book=5">Feature</a></p>
The query returned every post with any metadata, pages included; a UNION would read other tables. The same parameter
reflects markup unchanged (isbn=<script>alert(1)</script> comes back inside the results paragraph as is), so a
crafted link runs script in the browser of whoever opens it. The handler is reached by the Author without a nonce,
stores a note with an event handler, and redirects off-site:
curl -s -b tom.jar -o /dev/null -w '%{http_code} %{redirect_url}\n' -G "$SITE/wp-admin/admin-post.php" \
-d action=lumen_feature_book -d book=5 --data-urlencode 'note=<img src=x onerror=alert(1)>' \
--data-urlencode 'redirect_to=https://attacker.example/'
wp option get lumen_featured_note
302 https://attacker.example/
<img src=x onerror=alert(1)>
The Editor’s next visit prints <p>Featured note: <img src=x onerror=alert(1)></p>, running script with her session
(or an Administrator’s, who can create users). Because the handler accepts GET, an <img> tag on any other site does
the same through a logged-in Editor’s browser.
The fixed version
<?php
// lumen-catalog/includes/admin-tools.php
defined( 'ABSPATH' ) || exit;
const LUMEN_CATALOG_TOOLS_CAP = 'edit_others_posts';
add_action( 'admin_menu', function () {
add_management_page( __( 'Catalogue tools', 'lumen-catalog' ), __( 'Catalogue tools', 'lumen-catalog' ), LUMEN_CATALOG_TOOLS_CAP, 'lumen-tools', 'lumen_catalog_tools_page' );
} );
function lumen_catalog_note_tags() { // Tags allowed in the featured note.
return array( 'em' => array(), 'strong' => array(), 'a' => array( 'href' => true ) );
}
function lumen_catalog_tools_page() {
global $wpdb;
if ( ! current_user_can( LUMEN_CATALOG_TOOLS_CAP ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to use the catalogue tools.', 'lumen-catalog' ), 403 );
}
// Read-only search: no nonce needed. Unslash, then sanitize.
$isbn = isset( $_GET['isbn'] ) ? sanitize_text_field( wp_unslash( $_GET['isbn'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
echo '<div class="wrap"><h1>' . esc_html__( 'Catalogue tools', 'lumen-catalog' ) . '</h1><p>'
. esc_html__( 'Featured note:', 'lumen-catalog' ) . ' '
. wp_kses( get_option( 'lumen_featured_note', '' ), lumen_catalog_note_tags() ) . '</p>';
if ( '' !== $isbn ) {
/* translators: %s: the ISBN searched for. */
echo '<p>' . esc_html( sprintf( __( 'Results for %s', 'lumen-catalog' ), $isbn ) ) . '</p>';
$rows = $wpdb->get_results(
$wpdb->prepare(
'SELECT p.ID, p.post_title FROM %i p JOIN %i m ON m.post_id = p.ID WHERE m.meta_key = %s AND m.meta_value = %s',
$wpdb->posts,
$wpdb->postmeta,
'lp_isbn',
$isbn
)
);
foreach ( $rows as $row ) {
$url = add_query_arg( array( 'action' => 'lumen_feature_book', 'book' => $row->ID ), admin_url( 'admin-post.php' ) );
$url = wp_nonce_url( $url, 'lumen_feature_book_' . $row->ID );
printf( '<p>%s <a href="%s">%s</a></p>', esc_html( $row->post_title ), esc_url( $url ), esc_html__( 'Feature', 'lumen-catalog' ) );
}
}
echo '</div>';
}
add_action( 'admin_post_lumen_feature_book', function () {
if ( ! current_user_can( LUMEN_CATALOG_TOOLS_CAP ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to feature books.', 'lumen-catalog' ), 403 );
}
$book_id = isset( $_REQUEST['book'] ) ? absint( $_REQUEST['book'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification -- the nonce action includes the ID.
check_admin_referer( 'lumen_feature_book_' . $book_id );
if ( 'lp_book' !== get_post_type( $book_id ) || ! current_user_can( 'edit_post', $book_id ) ) {
wp_die( esc_html__( 'Not a book you can edit.', 'lumen-catalog' ), 400 );
}
$note = isset( $_REQUEST['note'] ) ? wp_kses( wp_unslash( $_REQUEST['note'] ), lumen_catalog_note_tags() ) : '';
update_option( 'lumen_featured_book', $book_id, false );
update_option( 'lumen_featured_note', $note, false );
$target = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url( 'tools.php?page=lumen-tools' );
wp_safe_redirect( $target ); // Off-site targets fall back to admin_url().
exit;
} );
What changed, flaw by flaw:
-
Capability:
edit_others_postsfor the menu and both callbacks (a menu capability only hides the entry), plus the meta capabilityedit_poston the specific book. -
Nonce: per-book, verified by
check_admin_referer()after the capability check; the read-only search needs none. -
SQL:
$wpdb->prepare()with%ifor the tables and%sfor the values. -
XSS: the search term is sanitized and then escaped with
esc_html(); the note passeswp_kses()with a three-tag allow-list when stored and when printed; titles and URLs are escaped. -
Redirect:
wp_safe_redirect()accepts only the site’s host (plusallowed_redirect_hosts) and otherwise goes toadmin_url()(filterwp_safe_redirect_fallback). The options are stored with autoload off.
The same requests against the fixed version
# The Author can no longer open the screen or call the handler.
curl -s -b tom.jar -o /dev/null -w '%{http_code}\n' "$SITE/wp-admin/tools.php?page=lumen-tools"
curl -s -b tom.jar -G "$SITE/wp-admin/admin-post.php" -d action=lumen_feature_book -d book=5 | grep -o 'Sorry[^<]*'
# The injection is now a string that matches nothing.
curl -s -b maria.jar -G "$SITE/wp-admin/tools.php" -d page=lumen-tools \
--data-urlencode "isbn=x' OR '1'='1" | grep -o '<p>Results.*</p>'
# A forged request (no nonce) from the Editor's browser is refused.
curl -s -b maria.jar -G "$SITE/wp-admin/admin-post.php" -d action=lumen_feature_book -d book=5 | grep -o 'The link[^<]*'
# With the nonce from the screen: the note is filtered and the off-site redirect is refused.
NONCE=$(curl -s -b maria.jar -G "$SITE/wp-admin/tools.php" -d page=lumen-tools \
--data-urlencode "isbn=978-1-23456-790-3" | grep -o 'book=5&_wpnonce=[0-9a-f]*' | sed 's/.*_wpnonce=//')
curl -s -b maria.jar -o /dev/null -w '%{http_code} %{redirect_url}\n' -G "$SITE/wp-admin/admin-post.php" \
-d action=lumen_feature_book -d book=5 -d _wpnonce="$NONCE" \
--data-urlencode 'note=<strong>New</strong> <img src=x onerror=alert(1)>' \
--data-urlencode 'redirect_to=https://attacker.example/'
wp option get lumen_featured_note
# The nonce for book 5 does not work for book 4.
curl -s -b maria.jar -o /dev/null -w '%{http_code}\n' -G "$SITE/wp-admin/admin-post.php" \
-d action=lumen_feature_book -d book=4 -d _wpnonce="$NONCE"
403
Sorry, you are not allowed to feature books.
<p>Results for x' OR '1'='1</p>
The link you followed has expired.
302 http://lumenpress.test:8080/wp-admin/
<strong>New</strong>
403
What the coding standards report
The WordPress Coding Standards include sniffs that find most of these flaws statically (Composer setup in Static Analysis and Refactoring):
composer require --dev wp-coding-standards/wpcs:3.4.1
vendor/bin/phpcs --standard=WordPress --report=emacs \
--sniffs=WordPress.Security.EscapeOutput,WordPress.Security.NonceVerification,WordPress.Security.ValidatedSanitizedInput,WordPress.Security.SafeRedirect,WordPress.DB.PreparedSQL,WordPress.DB.PreparedSQLPlaceholders \
includes/
For the vulnerable file they report 18 findings, among them (shortened):
admin-tools.php:11:50: error - Detected usage of a non-sanitized input variable: $_GET['isbn']
admin-tools.php:15:34: error - All output should be run through an escaping function (...), found '$isbn'.
admin-tools.php:18:1: error - Use placeholders and $wpdb->prepare(); found interpolated variable $isbn at ...
admin-tools.php:29:43: warning - Processing form data without nonce verification.
admin-tools.php:30:5: warning - wp_redirect() found. Using wp_safe_redirect(), along with the "allowed_redirect_hosts" filter ...
The fixed files on this page report nothing. The sniffs cannot see a missing capability check, and a phpcs:ignore
comment silences them, so every ignore carries a reason and is reviewed like code. The Plugin Check plugin runs the
same sniffs before a directory submission; see
Publishing to the Plugin Directory.
AJAX and REST endpoints
admin-ajax.php. Handlers are registered on wp_ajax_{$action} (logged-in users of any role) and
wp_ajax_nopriv_{$action} (visitors). Some older books post from jQuery with no nonce to a handler that checks
neither a nonce nor a capability. The handler below does both, and the nonce travels to the script with its data:
<?php
// lumen-catalog/includes/ajax.php
defined( 'ABSPATH' ) || exit;
add_action( 'admin_enqueue_scripts', function ( $hook_suffix ) {
if ( 'tools_page_lumen-tools' !== $hook_suffix ) {
return;
}
wp_enqueue_script( 'lumen-tools', plugins_url( 'assets/tools.js', __DIR__ ), array( 'jquery' ), '0.1.0', true );
wp_localize_script( 'lumen-tools', 'lumenTools', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'lumen_book_count' ) ) );
} );
add_action( 'wp_ajax_lumen_book_count', function () {
check_ajax_referer( 'lumen_book_count' ); // Dies with -1 and status 403 on failure.
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( null, 403 );
}
wp_send_json_success( array( 'count' => (int) wp_count_posts( 'lp_book' )->publish ) );
} );
Source: Plugin Handbook: AJAX
// lumen-catalog/assets/tools.js
( function ( $ ) {
$.post( lumenTools.ajaxUrl, { action: 'lumen_book_count', _ajax_nonce: lumenTools.nonce } ).done( function ( response ) {
$( '.wrap h1' ).append( document.createTextNode( ' (' + response.data.count + ')' ) );
} );
} )( jQuery );
Source: Plugin Handbook: AJAX
The wrapper receives jQuery as $ (WordPress loads jQuery in noConflict mode, so a bare $ is undefined) and
inserts text, never HTML. Without the nonce the handler answers -1 (403), with it
{"success":true,"data":{"count":2}}, and logged out 0 (400, no nopriv handler). New code prefers REST; see
JavaScript, AJAX and Heartbeat.
REST API. Every route needs a permission_callback: leaving it out triggers a _doing_it_wrong notice since
WordPress 5.5, and __return_true is only for routes meant to be public. Arguments are described with JSON Schema so
that core validates them before the callback runs:
<?php
// lumen-catalog/includes/rest.php
defined( 'ABSPATH' ) || exit;
add_action( 'rest_api_init', function () {
register_rest_route( 'lumen/v1', '/featured', array(
'methods' => WP_REST_Server::EDITABLE,
'permission_callback' => fn() => current_user_can( 'edit_others_posts' ),
'args' => array(
'book' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1 ),
),
'callback' => function ( WP_REST_Request $request ) {
$book_id = $request['book'];
if ( 'lp_book' !== get_post_type( $book_id ) ) {
return new WP_Error( 'lumen_not_a_book', __( 'Not a book.', 'lumen-catalog' ), array( 'status' => 400 ) );
}
update_option( 'lumen_featured_book', $book_id, false );
return array( 'featured' => $book_id );
},
) );
} );
curl -s -b maria.jar -X POST "$SITE/wp-json/lumen/v1/featured" -d book=4 # cookies, no nonce
RN=$(curl -s -b maria.jar "$SITE/wp-admin/admin-ajax.php?action=rest-nonce") # the wp_rest nonce
curl -s -b maria.jar -H "X-WP-Nonce: $RN" -X POST "$SITE/wp-json/lumen/v1/featured" -d book=4
curl -s -b maria.jar -H "X-WP-Nonce: $RN" -X POST "$SITE/wp-json/lumen/v1/featured" -d book=abc
{"code":"rest_forbidden","message":"Sorry, you are not allowed to do that.","data":{"status":401}}
{"featured":4}
{"code":"rest_invalid_param","message":"Invalid parameter(s): book","data":{"status":400,"params":{"book":"book is not of type integer."},"details":{"book":{"code":"rest_invalid_type","message":"book is not of type integer.","data":{"param":"book"}}}}}
The first request had valid login cookies but no wp_rest nonce, so it ran as user 0 and got 401; the Author with
his own nonce gets 403. A pitfall: a custom sanitize_callback (such as absint) replaces the default
rest_parse_request_arg(), which is what validates against the schema; with it, book=abc silently became 0 and
reached the callback. See REST API: Extending It.
Theme options and get_theme_mod
Some older books register a Customizer setting without a sanitize_callback and echo get_theme_mod() straight into
the template. Whoever can save the setting, or any code that writes the theme mod, then controls raw markup on every
page. Sanitize when the setting is saved and escape when it is printed:
<?php
// lumen-classic/functions.php
add_action( 'customize_register', function ( WP_Customize_Manager $wp_customize ) {
$wp_customize->add_setting( 'lumen_classic_footer_note', array( 'default' => '', 'sanitize_callback' => 'sanitize_text_field' ) );
$wp_customize->add_control( 'lumen_classic_footer_note', array( 'label' => __( 'Footer note', 'lumen-classic' ), 'section' => 'title_tagline' ) );
} );
<?php // lumen-classic/footer.php (excerpt) ?>
<p class="footer-note"><?php echo esc_html( get_theme_mod( 'lumen_classic_footer_note', '' ) ); ?></p>
Source: Code Reference: get_theme_mod()
URLs pair esc_url_raw with esc_url(), colours sanitize_hex_color with esc_attr(); see
Customizer API.
File uploads
An upload handler decides what ends up inside the web root. Check upload_files and a nonce as for any state change,
then let WordPress move the file with wp_handle_upload() (form uploads), wp_handle_sideload() (files already on the
server) or media_handle_upload() (which also creates an attachment). They run wp_check_filetype_and_ext(), which
compares the extension with the real content, and sanitize and de-duplicate the file name. Pass a restricted mimes
list; otherwise the site-wide get_allowed_mime_types() (filter upload_mimes) applies, which must never gain php,
svg or html. Never trust the browser’s type field or write uploads to a path built from input.
<?php
// lumen-catalog/includes/cover-upload.php: form posts lumen_cover with wp_nonce_field( 'lumen_upload_cover' ).
defined( 'ABSPATH' ) || exit;
add_action( 'admin_post_lumen_upload_cover', function () {
if ( ! current_user_can( 'upload_files' ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to upload files.', 'lumen-catalog' ), 403 );
}
check_admin_referer( 'lumen_upload_cover' );
if ( empty( $_FILES['lumen_cover'] ) ) {
wp_die( esc_html__( 'No file was sent.', 'lumen-catalog' ), 400 );
}
$result = wp_handle_upload( // Loaded by admin-post.php; elsewhere require wp-admin/includes/file.php.
$_FILES['lumen_cover'], // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- checked by wp_handle_upload().
array(
'test_form' => false, // The form does not post action=wp_handle_upload.
'mimes' => array( 'jpg|jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp' ),
)
);
if ( isset( $result['error'] ) ) {
wp_die( esc_html( $result['error'] ), 400 );
}
wp_safe_redirect( admin_url( 'tools.php?page=lumen-tools' ) );
exit;
} );
The same checks in a wp eval-file script, through wp_handle_sideload() (same code path, for files not just posted):
<?php
$png = base64_decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' );
$files = array( 'cover.png' => $png, 'shell.jpg' => "<?php echo 'not an image';", 'misnamed.jpg' => $png );
$mimes = array( 'jpg|jpeg' => 'image/jpeg', 'png' => 'image/png' );
foreach ( $files as $name => $bytes ) {
$path = get_temp_dir() . $name;
file_put_contents( $path, $bytes );
$check = wp_check_filetype_and_ext( $path, $name, $mimes );
printf( "%-13s ext=%-5s type=%-10s proper_filename=%s\n", $name, var_export( $check['ext'], true ), var_export( $check['type'], true ), var_export( $check['proper_filename'], true ) );
$file = array( 'name' => $name, 'type' => 'image/jpeg', 'tmp_name' => $path, 'error' => 0, 'size' => filesize( $path ) );
$result = wp_handle_sideload( $file, array( 'test_form' => false, 'mimes' => $mimes ) );
echo ' ', isset( $result['error'] ) ? 'error: ' . $result['error'] : 'stored as ' . basename( $result['file'] ) . ' (' . $result['type'] . ')', "\n";
}
cover.png ext='png' type='image/png' proper_filename=false
stored as cover.png (image/png)
shell.jpg ext=false type=false proper_filename=false
error: Sorry, you are not allowed to upload this file type.
misnamed.jpg ext='png' type='image/png' proper_filename='misnamed.png'
stored as misnamed.png (image/png)
The PHP file named .jpg was refused although the client claimed image/jpeg, and the PNG named .jpg was stored
under its real extension. Posted to the handler above, the PNG is stored (302 back to the tools screen) and the PHP file
gets "Sorry, you are not allowed to upload this file type." (400). The web server should also refuse PHP in uploads/
(Web Servers: Apache and nginx).
SSRF and the HTTP API
Server-side request forgery (SSRF) happens when the server fetches a URL that a user controls (an importer, a webhook
target, a "fetch the cover from this address" field) and so reaches the database container, an internal admin panel or
the cloud metadata service at 169.254.169.254, which hands out credentials. wp_remote_get() fetches anything.
wp_safe_remote_get(), wp_safe_remote_post(), wp_safe_remote_head() and wp_safe_remote_request() set
reject_unsafe_urls, so wp_http_validate_url() checks the URL and every redirect target: http or https only, no
credentials in the URL, no host resolving to a loopback, private, link-local (cloud metadata), carrier-grade NAT,
documentation, multicast or reserved address (unless it is the site’s own host or http_request_host_is_external
allows it), and port 80, 443 or 8080 only (filter http_allowed_safe_ports; a public host on port 8443 is refused too).
<?php
$urls = array(
'http://web/', // Another container on the private network.
'http://169.254.169.254/latest/meta-data/', // Cloud instance metadata.
'https://api.wordpress.org/core/version-check/1.7/',
);
foreach ( $urls as $url ) {
foreach ( array( 'wp_remote_get', 'wp_safe_remote_get' ) as $fn ) {
$response = $fn( $url, array( 'timeout' => 3, 'redirection' => 0 ) );
printf( "%-18s %-50s %s\n", $fn, $url, is_wp_error( $response ) ? 'WP_Error: ' . $response->get_error_message() : wp_remote_retrieve_response_code( $response ) );
}
}
wp_remote_get http://web/ 301
wp_safe_remote_get http://web/ WP_Error: A valid URL was not provided.
wp_remote_get http://169.254.169.254/latest/meta-data/ WP_Error: cURL error 28: Connection timed out after 3013 milliseconds
wp_safe_remote_get http://169.254.169.254/latest/meta-data/ WP_Error: A valid URL was not provided.
wp_remote_get https://api.wordpress.org/core/version-check/1.7/ 200
wp_safe_remote_get https://api.wordpress.org/core/version-check/1.7/ 200
web is the test stack’s web server container: the plain function reached it (the 301 is WordPress’s canonical
redirect), the safe one refused before connecting. On a cloud VM the metadata call would have returned data. Use the
safe variants for every URL that is not a constant, and treat responses as untrusted input (validate, escape, short
timeout, limit_response_size). See HTTP and Filesystem APIs.
Safe redirects
wp_safe_redirect() passes the target through wp_validate_redirect(), which keeps relative paths and the site’s own
host and replaces anything else with the fallback: on the test site https://attacker.example/ and
//attacker.example/ became the fallback, /wp-admin/edit.php?post_type=lp_book passed. Follow every redirect with exit;, and allow
legitimate external targets with the allowed_redirect_hosts filter, never by going back to wp_redirect().
Other pitfalls
-
Direct file access: every PHP file starts with
defined( 'ABSPATH' ) || exit;. -
Object injection and dynamic code: no
unserialize()of untrusted data (use JSON), noeval(), noextract()on request data, noincludeof a path built from input. -
Disclosure and secrets: meta and fields with
show_in_restare public;WP_DEBUG_DISPLAYis false in production; API keys live in the environment orwp-config.php(wp-config.php Reference), not in autoloaded options. -
Comparisons and roles:
hash_equals()for tokens, strict comparisons everywhere;add_role()runs once on activation, not on everyinit.
Where older books are outdated
| Older example or claim | Current practice (WordPress 7.1) |
|---|---|
An |
|
A Customizer setting without |
A |
|
It only tells that an admin screen is loading; check |
|
|
|
|
Further corrections are collected in Where Older Books and Tutorials Are Outdated.