The admin side
Settings Registration and Sanitisation
The Settings API, what sanitize_callback hooks into, why options.php refuses a group you never registered, and what a large array option costs on every request.
Sifero Courier Timeline does not use the Settings API. Search the source for
register_setting and you get nothing. Its Settings screen is a
<form method="post"> with no action attribute, so it posts
back to admin.php?page=sfct-settings, and the callback saves the values at
the top of its own render function.
That works, and it is not automatically wrong. But the two approaches fail in
different ways, and the failure you get from skipping
register_setting is the one that ends up in a stored XSS report rather than
in a bug tracker.
The Settings API is a pipeline, not a form builder
It helps to separate what the API does from what it looks like it does. The Plugin Handbook's Settings API page covers the same ground from the other direction, starting from a screen you want to build. Four functions and one target:
register_setting( $option_group, $option_name, $args );
add_settings_section( $id, $title, $callback, $page );
add_settings_field( $id, $title, $callback, $page, $section, $args );
do_settings_sections( $page );
// and inside the form, whose action is options.php:
settings_fields( $option_group );
register_setting() stores three useful facts about an option: which group
it belongs to, how to render it for the REST API, and how to sanitise it. It does not
render anything. add_settings_section() and add_settings_field()
store layout, and do_settings_sections() walks that layout and calls your
field callbacks. The save path is entirely separate: the form posts to
wp-admin/options.php, which looks up the group you declared in the hidden
option_page input and updates the options listed for it.
Here is the whole of settings_fields() in WordPress 7.0.7, which is what
you get in the rendered form:
// wp-admin/includes/plugin.php
function settings_fields( $option_group ) {
echo "<input type='hidden' name='option_page' value='" . esc_attr( $option_group ) . "' />";
echo '<input type="hidden" name="action" value="update" />';
wp_nonce_field( "$option_group-options" );
}
I built a miniature settings screen this way on a WordPress 7.0.7 install and read the
form back. It contained exactly those three inputs, and the nonce action was
sfct_lab_group-options. The name of the nonce action is derived from your
group name, so two plugins that pick the same group name are posting to the same
nonce action. Group names need to be as specific as option names.
sanitize_callback is a filter on the option name
This is the part people miss. The callback does not run in options.php.
options.php loops the allowed options for the group and calls
update_option( $option, $value ) with the raw posted value.
register_setting() gets its turn inside update_option():
// wp-includes/option.php, WordPress 7.0.7, shortened
if ( ! empty( $args['sanitize_callback'] ) ) {
add_filter( "sanitize_option_{$option_name}", $args['sanitize_callback'] );
}
So 'sanitize_callback' => 'absint' is exactly
add_filter( 'sanitize_option_random_length', 'absint' ). Anything that calls
update_option() or add_option() for that name runs the callback,
including your own code, a WP-CLI command, and an import routine. That is a feature: one
place decides what the option may contain, and it applies to every write path.
It also means a registered setting is not protected by the screen it was designed for.
If sanitize_callback is missing, nothing filters the value on the way in, and
the option becomes a general-purpose string sink reachable by anything that can post to
options.php.
Without a callback, core sanitises almost nothing
The instinct is that WordPress will clean the value up. It will not.
sanitize_option() in wp-includes/formatting.php is a
switch over a hard-coded list of core option names: emails, page ids, sizes,
the comment moderation keys, and so on. An unknown name falls through the switch
untouched and then hits one filter:
// wp-includes/formatting.php, WordPress 7.0.7, tail of sanitize_option()
return apply_filters( "sanitize_option_{$option}", $value, $option, $original_value );
With no callback registered, that filter has no callbacks and the posted value is
stored verbatim. I registered three settings in the lab, one with no callback, one with
sanitize_text_field, one with absint, and posted the same kind
of hostile payload through options.php as an administrator. The results:
register_setting( 'sfct_lab_group', 'sfct_lab_plain' );
posted sfct_lab_plain[evil] = <script>alert(1)</script>
stored {"evil":"<script>alert(1)<\/script>","second":"<img src=x onerror=alert(2)>"}
register_setting( 'sfct_lab_group', 'sfct_lab_text', array( 'sanitize_callback' => 'sanitize_text_field' ) );
posted sfct_lab_text = <b>bold</b>
stored 'bold'
register_setting( 'sfct_lab_group', 'sfct_lab_number', array( 'sanitize_callback' => 'absint' ) );
posted sfct_lab_number = 12abc
stored '12'
Three differences in one run. The unrestricted option kept the tag opening, the closing tag, and both attributes, including the event handler. The array survived too, because nothing said it should not. That value is now a stored XSS waiting for whichever template forgets to escape it, and the store owner will reasonably assume the plugin validated a field it drew itself.
Would a nonce have saved it?
No. The lab request carried a valid _wpnonce and passed
check_admin_referer() inside options.php. A nonce proves the
request came from your screen. An administrator is allowed to submit hostile HTML from
your screen by accident, by paste, or because a second plugin injected a field into the
form. Sanitisation is the control that limits what a permitted user can store,
and it is the one that was missing.
A group you never registered is a page WordPress refuses to load
The mirror image of that mistake is not registering the group at all. The plugin's
sfct_settings option is written by its own code and never registered, so
there is no valid option_page value that would let
options.php touch it. I tested this rather than assuming it:
curl -b cookies.txt -o err.html -w '%{http_code}\n' \
--data-urlencode 'option_page=sfct_settings' \
--data-urlencode 'action=update' \
--data-urlencode '_wpnonce=<valid nonce>' \
--data-urlencode 'sfct_settings[tracking_prefix]=HACKED' \
http://127.0.0.1:8791/wp-admin/options.php
403
# and the option was unchanged: tracking_prefix was still SFCT
The check is a membership test on $allowed_options[ $option_page ], which
register_setting() is the only thing that fills. An empty or unknown group
name gets a 403 and a message saying the options page is not in the allowed options
list. That is a good boundary, and it is why posting straight to options.php
is not a shortcut anyone can take accidentally.
It also tells you what the plugin gave up. Registered settings appear in
$wp_registered_settings, which is what the REST API reads to expose an
option, and what wp-admin/options.php consults to decide what is writable.
The plugin's settings exist only in its own form handler and its own
sfct_get_settings() accessor. Nothing else in WordPress knows they are
settings.
show_in_rest is a schema promise, so only make it for values you can describe
register_setting() accepts type,
description, default and show_in_rest. The
defaults are type => 'string' and show_in_rest => false.
Setting show_in_rest => true exposes the option through
/wp/v2/settings, and the REST controller will sanitise it with the same
callback you registered.
There is one hard rule enforced in code. An option registered as
type => 'array' with show_in_rest enabled must describe its
items, or register_setting() calls _doing_it_wrong():
if ( false !== $args['show_in_rest'] && 'array' === $args['type']
&& ( ! is_array( $args['show_in_rest'] ) || ! isset( $args['show_in_rest']['schema']['items'] ) ) ) {
_doing_it_wrong( __FUNCTION__, __( 'When registering an "array" setting to show in the REST API, you must specify the schema for each array item in "show_in_rest.schema.items".' ), '5.4.0' );
}
The SFCT Settings screen keeps tracking_prefix,
tracking_format, random_length and an api_token
generated at install time in one serialised array. A REST schema for that option would
have to describe the token as part of the setting's shape, which turns every settings
write from /wp/v2/settings into a chance to overwrite it. Keeping an option
like that out of show_in_rest is the right call, and the plugin gets there by
not registering at all.
A large array option costs more than a large row
One more consequence of storing several settings in one option. Since WordPress 6.6,
an option written with no explicit autoload argument is given an auto value,
and wp_autoload_values_to_autoload() returns [ 'yes', 'on',
'auto-on', 'auto' ]. Everything in that list is fetched by
wp_load_alloptions() on the first get_option() of the request
and cached in memory.
So sfct_settings and sfct_logistics_statuses are read from
the options table on every page load, including the front end of the store and all
front-end requests that never touch the admin. On the lab install I measured them at 158
and 1,392 bytes. That is 1.5 KB out of every request for a page that displays a
shipping timeline, which is fine, and it becomes not fine if someone later stuffs a
company list into the same option.
Core does have a guard rail, added in 6.6 and worth knowing about:
// wp-includes/option.php, WordPress 7.0.7
$max_option_size = (int) apply_filters( 'wp_max_autoloaded_option_size', 150000, $option );
$size = ! empty( $serialized_value ) ? strlen( $serialized_value ) : 0;
if ( $size > $max_option_size ) {
return false;
}
I added a 330,899-byte array option with no autoload argument and checked the table
directly: the stored autoload column read auto-off, and the
option was absent from wp_load_alloptions(). Core switched it off for me.
Passing false explicitly stores off and has the same effect. If
you want a value to stop travelling on every request, those are your two options, and the
threshold is filterable when a site knows better than the default.
When a hand-rolled handler is justified
Given all of that, the plugin's choice is defensible in two situations and lazy in
one. It is justified when the thing being saved is not an option. The plugin registers
two admin_post_ actions that save template settings and handle a template
file upload, and the upload is the clear case: options.php has no story for
multipart file input, and the file needs validation that has nothing to do with
sanitize_option. That is what admin_post_{$action} is for, and
the handler does the right things in the right order:
// includes/shortcodes.php, lines 38-43
function sfct_handle_create_tracking_page() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions.', 'sifero-courier-timeline-for-woocommerce' ) );
}
check_admin_referer( 'sfct_create_tracking_page', 'sfct_create_tracking_page_nonce' );
Capability first, then nonce, then work. The same shape appears in the templates handler and in the order-save handler. If you hand-roll, that is the shape.
What is not justified is saving a plain option from inside a render callback. The
plugin's Settings screen saves on $_POST presence at the top of
sfct_render_settings_page(), and that has three concrete costs. A refresh
after saving re-submits the form, because the request that rendered the page was a POST.
There is no settings_errors() integration, so the "Settings saved" notice is
hand-built HTML inside the page rather than the standard admin notice that survives the
redirect. And there is nowhere to put the validation except inline, which is how
random_length ends up with its bounds written twice, once in the render
function and once in the import path in includes/ajax.php. The second copy
uses max( 6, min( 16, ... ) ) while the screen uses
max( 8, min( 16, ... ) ), so an imported backup can produce a value the
screen refuses to show.
That last one is the argument for the Settings API in a sentence: one registered sanitiser, one place where the bounds live, and every write path in the plugin, the REST API, WP-CLI and your own import code goes through it.
Take the plugin's sfct_settings option and convert its screen to the
Settings API without changing the stored data shape. Register the option on a group
of your own, give it a sanitiser that enforces the same bounds as
sfct_render_settings_page(), render the three fields with
add_settings_section() and add_settings_field(), and point
the form at options.php.
Keep tracking_format restricted to random and
order_number. Decide what your sanitiser does with an unknown value and
write down why. Then answer two questions: what must your sanitiser do with the
api_token key that is already in the option, and what happens to
sfct_render_settings_page()'s existing POST block once the form posts to
options.php instead of to itself?
Show the solution and the reasoning
add_action( 'admin_init', function () {
register_setting(
'sfct_settings_group',
'sfct_settings',
array(
'type' => 'array',
'sanitize_callback' => 'sfct_sanitize_settings',
'show_in_rest' => false,
)
);
add_settings_section( 'sfct_tracking', __( 'Tracking settings', 'sifero-courier-timeline-for-woocommerce' ), '__return_false', 'sfct-settings' );
add_settings_field( 'sfct_tracking_prefix', __( 'Tracking prefix', 'sifero-courier-timeline-for-woocommerce' ), 'sfct_field_tracking_prefix', 'sfct-settings', 'sfct_tracking' );
} );
function sfct_sanitize_settings( $value ) {
$existing = sfct_get_settings();
$clean = array();
$clean['tracking_prefix'] = isset( $value['tracking_prefix'] ) ? sanitize_key( $value['tracking_prefix'] ) : $existing['tracking_prefix'];
if ( '' === $clean['tracking_prefix'] ) {
$clean['tracking_prefix'] = 'SFCT';
}
$format = isset( $value['tracking_format'] ) ? sanitize_key( $value['tracking_format'] ) : '';
$clean['tracking_format'] = in_array( $format, array( 'random', 'order_number' ), true ) ? $format : 'random';
$length = isset( $value['random_length'] ) ? absint( $value['random_length'] ) : (int) $existing['random_length'];
$clean['random_length'] = max( 8, min( 16, $length ) );
// The token is not part of the form. Carry it over rather than dropping it.
$clean['api_token'] = $existing['api_token'];
return $clean;
}
The form itself becomes three lines different: method="post"
action="options.php", then settings_fields( 'sfct_settings_group' )
and do_settings_sections( 'sfct-settings' ) where the nonce and fields
were.
Two decisions drive the sanitiser. First, it treats the incoming array as untrusted
and complete, then rebuilds the stored array with only the keys it knows. That
whitelist shape is what stops a POST from creating new keys inside an option you
control. It also explains the second decision: the token is not in the form, so
update_option() would replace the whole array and delete it. Copying it
from sfct_get_settings() keeps it, and this is the classic argument against
packing unrelated values into one option. Two options, one for form-managed settings
and one for generated secrets, would remove the need for that line and the class of bug
it prevents.
For an unknown tracking_format the sanitiser falls back to
random rather than rejecting the save. The alternative, keeping the
previous value, is also defensible and is closer to what a user expects when they
mistype. What is not defensible is storing the unknown string and letting
sfct_generate_tracking_id() discover it later. Note that this is exactly
the divergence mentioned earlier: the import path in includes/ajax.php
clamps the length to a minimum of 6 while the screen clamps to 8. Registering one
sanitiser collapses both, as long as the import stops writing the option directly and
goes through update_option(), which it already does.
Removing the POST block from sfct_render_settings_page() matters for a
reason beyond tidiness. options.php handles the save, redirects back to
options-general.php or the referring page with
settings-updated=true, and core prints the notice through
settings_errors(). If you leave the old block in place, a POST that lands
on the page for any other reason still writes the option, and you have two write paths
with different bounds. Delete it, and put settings_errors( 'sfct_settings_group' )
at the top of the render callback so the standard notice appears.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.