Skip to content

Commit 623e2ad

Browse files
authored
Merge pull request #832 from alleyinteractive/hotfix/term-context-secondary-term-update-guard
Protect against side effect term updates
2 parents 04d5fb7 + c2bd3a0 commit 623e2ad

File tree

3 files changed

+249
-5
lines changed

3 files changed

+249
-5
lines changed

php/context/class-fieldmanager-context-term.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ class Fieldmanager_Context_Term extends Fieldmanager_Context_Storable {
8080
*/
8181
private $current_taxonomy;
8282

83+
/**
84+
* Store data for inserted terms to ensure, as much as possible, that FM
85+
* only stores data to the term being created and not any other term created
86+
* as a side effect.
87+
*
88+
* @var array|null
89+
*/
90+
protected $inserting_term_data;
91+
8392
/**
8493
* Instantiate this context. You can either pass an array of all args
8594
* (preferred), or pass them individually (deprecated).
@@ -161,6 +170,7 @@ public function __construct( $args, $taxonomies = array(), $show_on_add = true,
161170
foreach ( $this->taxonomies as $taxonomy ) {
162171
if ( $this->show_on_add ) {
163172
add_action( $taxonomy . '_add_form_fields', array( $this, 'add_term_fields' ), 10, 1 );
173+
add_filter( 'wp_insert_term_data', array( $this, 'verify_term_data_on_insert' ), PHP_INT_MAX, 3 );
164174
add_action( 'created_term', array( $this, 'save_term_fields' ), 10, 3 );
165175
}
166176

@@ -288,6 +298,37 @@ public function term_fields( $html_template, $taxonomy, $term = null ) {
288298
* @param string $taxonomy The term taxonomy.
289299
*/
290300
public function save_term_fields( $term_id, $tt_id, $taxonomy ) {
301+
// phpcs:disable WordPress.Security.NonceVerification.Missing
302+
// Ensure that the term being created/updated is the term in the request.
303+
if ( ! empty( $_POST['tag_ID'] ) && $term_id !== (int) $_POST['tag_ID'] ) {
304+
return;
305+
} elseif ( empty( $_POST['tag_ID'] ) ) {
306+
// Ensure this was the created_term action.
307+
if ( ! doing_action( 'created_term' ) ) {
308+
return;
309+
}
310+
311+
if ( empty( $this->inserting_term_data ) ) {
312+
// Something has gone awry, this shouldn't happen, so bail.
313+
return;
314+
}
315+
316+
// Core expects terms to have a unique combination of [taxonomy, name, parent].
317+
// Therefore, this verifies that data against what was recorded prior to insert.
318+
$term = get_term( $term_id );
319+
if (
320+
$term->name !== $this->inserting_term_data['name']
321+
|| $term->parent !== $this->inserting_term_data['parent']
322+
|| $taxonomy !== $this->inserting_term_data['taxonomy']
323+
) {
324+
return;
325+
}
326+
327+
// This term appears to check out. Remove the recorded insert data.
328+
unset( $this->inserting_term_data );
329+
}
330+
// phpcs:enable WordPress.Security.NonceVerification.Missing
331+
291332
// Make sure this field is attached to the taxonomy being saved and this is the appropriate action.
292333
// phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict -- baseline
293334
if ( ! in_array( $taxonomy, $this->taxonomies ) ) {
@@ -310,6 +351,44 @@ public function save_term_fields( $term_id, $tt_id, $taxonomy ) {
310351
$this->save_to_term_meta( $term_id, $taxonomy );
311352
}
312353

354+
/**
355+
* Verify that the term being inserted is the term that Fieldmanager data
356+
* should be added to.
357+
*
358+
* This method serves to protect against a plugin or theme creating one or
359+
* more additional terms as a side effect of a term created from the form.
360+
* In order to protect against that, it's necessary to check the inserting
361+
* term's [name, taxonomy, parent], as WordPress requires the combination of
362+
* these fields to be unique. In order to guard against a plugin or theme
363+
* changing this data, it is recorded immediately prior to the term being
364+
* insert, and then checked in `save_term_fields()`.
365+
*
366+
* @param array $data {
367+
* Term data to be inserted.
368+
*
369+
* @type string $name Term name.
370+
* @type string $slug Term slug.
371+
* @type int $term_group Term group.
372+
* }
373+
* @param string $taxonomy Taxonomy slug.
374+
* @param array $args Arguments passed to wp_insert_term(), which is
375+
* raw $_POST data.
376+
* @return array Unmodified `$data`.
377+
*/
378+
public function verify_term_data_on_insert( $data, $taxonomy, $args ) {
379+
// Confirm that this data has the FM nonce for this field.
380+
if ( ! empty( $args[ $this->nonce_key() ] ) ) {
381+
// Append the data to the queued insert.
382+
$this->inserting_term_data = array(
383+
'name' => $data['name'],
384+
'taxonomy' => $taxonomy,
385+
'parent' => ! empty( $args['parent'] ) ? max( (int) $args['parent'], 0 ) : 0,
386+
);
387+
}
388+
389+
return $data;
390+
}
391+
313392
/**
314393
* Helper to save an array of data to term meta.
315394
*

php/context/class-fieldmanager-context.php

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ public function __construct() {
4141
add_filter( 'wp_refresh_nonces', array( $this, 'refresh_nonce' ) );
4242
}
4343

44+
/**
45+
* Get the nonce key for this field.
46+
*
47+
* @return string
48+
*/
49+
protected function nonce_key() {
50+
return 'fieldmanager-' . $this->fm->name . '-nonce';
51+
}
52+
4453
/**
4554
* Include a fresh nonce for this field in a response with refreshed nonces.
4655
*
@@ -50,7 +59,7 @@ public function __construct() {
5059
* @return array Updated response data.
5160
*/
5261
public function refresh_nonce( $response ) {
53-
$response['fieldmanager_refresh_nonces']['replace'][ 'fieldmanager-' . $this->fm->name . '-nonce' ] = wp_create_nonce( 'fieldmanager-save-' . $this->fm->name );
62+
$response['fieldmanager_refresh_nonces']['replace'][ $this->nonce_key() ] = wp_create_nonce( 'fieldmanager-save-' . $this->fm->name );
5463

5564
return $response;
5665
}
@@ -62,12 +71,12 @@ public function refresh_nonce( $response ) {
6271
* @return bool
6372
*/
6473
protected function is_valid_nonce() {
65-
if ( empty( $_POST[ 'fieldmanager-' . $this->fm->name . '-nonce' ] ) ) {
74+
if ( empty( $_POST[ $this->nonce_key() ] ) ) {
6675
return false;
6776
}
6877

6978
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- baseline
70-
if ( ! wp_verify_nonce( $_POST[ 'fieldmanager-' . $this->fm->name . '-nonce' ], 'fieldmanager-save-' . $this->fm->name ) ) {
79+
if ( ! wp_verify_nonce( $_POST[ $this->nonce_key() ], 'fieldmanager-save-' . $this->fm->name ) ) {
7180
$this->fm->_unauthorized_access( __( 'Nonce validation failed', 'fieldmanager' ) );
7281
}
7382

@@ -113,7 +122,7 @@ protected function render_field( $args = array() ) {
113122
$data = array_key_exists( 'data', $args ) ? $args['data'] : null;
114123
$echo = isset( $args['echo'] ) ? $args['echo'] : true;
115124

116-
$nonce = wp_nonce_field( 'fieldmanager-save-' . $this->fm->name, 'fieldmanager-' . $this->fm->name . '-nonce', true, false );
125+
$nonce = wp_nonce_field( 'fieldmanager-save-' . $this->fm->name, $this->nonce_key(), true, false );
117126
$field = $this->fm->element_markup( $data );
118127
if ( $echo ) {
119128
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- baseline
@@ -122,5 +131,4 @@ protected function render_field( $args = array() ) {
122131
return $nonce . $field;
123132
}
124133
}
125-
126134
}

tests/php/test-fieldmanager-context-term.php

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,4 +408,161 @@ public function test_unserialize_data_mixed_depth() {
408408
$this->assertStringContainsString('name="base_group[test_group][deep_text]"', $html );
409409
$this->assertStringContainsString( '>' . $data['test_group']['deep_text'] . '</textarea>', $html );
410410
}
411+
412+
/**
413+
* @see https://github.com/alleyinteractive/wordpress-fieldmanager/issues/831
414+
*/
415+
public function test_term_saving_side_effects_on_term_update() {
416+
$name = 'side-effect';
417+
$value = 'Fieldmanager was here';
418+
419+
// Register a Fieldmanager Field and add the term context.
420+
$fm = new Fieldmanager_TextField( compact( 'name' ) );
421+
$fm->add_term_meta_box( 'Testing Side Effects', [ $this->taxonomy ] );
422+
423+
// Set the POST data.
424+
$_POST = [
425+
'tag_ID' => $this->term_id,
426+
'taxonomy' => $this->taxonomy,
427+
'name' => 'News',
428+
'slug' => 'news',
429+
'description' => 'General news',
430+
'parent' => '-1',
431+
"fieldmanager-{$name}-nonce" => wp_create_nonce( "fieldmanager-save-{$name}" ),
432+
$name => $value,
433+
];
434+
435+
// Trigger the intended save.
436+
do_action(
437+
'edited_term',
438+
$this->term_id,
439+
$this->tt_id,
440+
$this->taxonomy
441+
);
442+
443+
// Fake a side effect.
444+
$side_effect_term = self::factory()->term->create_and_get(
445+
[
446+
'taxonomy' => $this->taxonomy,
447+
]
448+
);
449+
do_action(
450+
'edited_term',
451+
$side_effect_term->term_id,
452+
$side_effect_term->term_taxonomy_id,
453+
$this->taxonomy
454+
);
455+
456+
$this->assertSame( $value, get_term_meta( $this->term_id, $name, true ) );
457+
$this->assertSame( [], get_term_meta( $side_effect_term->term_id, $name ) );
458+
}
459+
460+
/**
461+
* @see https://github.com/alleyinteractive/wordpress-fieldmanager/issues/831
462+
*/
463+
public function test_term_saving_side_effects_on_term_create() {
464+
$name = 'side-effect';
465+
$value = 'Fieldmanager was here';
466+
$new_term_name = 'New Term';
467+
468+
// Register a Fieldmanager Field and add the term context.
469+
$fm = new Fieldmanager_TextField( compact( 'name' ) );
470+
$fm->add_term_meta_box( 'Testing Side Effects', [ $this->taxonomy ] );
471+
472+
// Set the POST data.
473+
$_POST = [
474+
'taxonomy' => $this->taxonomy,
475+
'tag-name' => $new_term_name,
476+
'slug' => '',
477+
'description' => '',
478+
'parent' => '-1',
479+
"fieldmanager-{$name}-nonce" => wp_create_nonce( "fieldmanager-save-{$name}" ),
480+
$name => $value,
481+
];
482+
483+
// Trigger the intended save.
484+
$new_term = wp_insert_term( $new_term_name, $this->taxonomy, $_POST );
485+
486+
// Fake a side effect.
487+
$side_effect_term = self::factory()->term->create_and_get(
488+
[
489+
'taxonomy' => $this->taxonomy,
490+
]
491+
);
492+
do_action(
493+
'edited_term',
494+
$side_effect_term->term_id,
495+
$side_effect_term->term_taxonomy_id,
496+
$this->taxonomy
497+
);
498+
499+
$this->assertSame( $value, get_term_meta( $new_term['term_id'], $name, true ) );
500+
$this->assertSame( [], get_term_meta( $side_effect_term->term_id, $name ) );
501+
}
502+
503+
/**
504+
* @see https://github.com/alleyinteractive/wordpress-fieldmanager/issues/831
505+
*/
506+
public function test_term_meta_saving_on_term_create_when_a_filter_alters_the_term_name() {
507+
$name = 'testing';
508+
$value = 'Fieldmanager was here';
509+
$new_term_name = 'New Term';
510+
511+
// Register a Fieldmanager Field and add the term context.
512+
$fm = new Fieldmanager_TextField( compact( 'name' ) );
513+
$fm->add_term_meta_box( 'Testing', [ $this->taxonomy ] );
514+
515+
// Set the POST data.
516+
$_POST = [
517+
'taxonomy' => $this->taxonomy,
518+
'tag-name' => $new_term_name,
519+
'slug' => '',
520+
'description' => '',
521+
'parent' => '-1',
522+
"fieldmanager-{$name}-nonce" => wp_create_nonce( "fieldmanager-save-{$name}" ),
523+
$name => $value,
524+
];
525+
526+
// Manipualte the term name prior to insert.
527+
add_filter(
528+
'pre_insert_term',
529+
function( $term_name ) {
530+
return "Edited: {$term_name}";
531+
}
532+
);
533+
534+
// Insert the term.
535+
$term = wp_insert_term( $new_term_name, $this->taxonomy, $_POST );
536+
537+
$this->assertSame( $value, get_term_meta( $term['term_id'], $name, true ) );
538+
}
539+
540+
/**
541+
* @see https://github.com/alleyinteractive/wordpress-fieldmanager/issues/831
542+
*/
543+
public function test_term_meta_saving_on_term_create_when_term_name_has_special_characters() {
544+
$name = 'testing';
545+
$value = 'Fieldmanager was here';
546+
$new_term_name = 'Aprés & Mañana™';
547+
548+
// Register a Fieldmanager Field and add the term context.
549+
$fm = new Fieldmanager_TextField( compact( 'name' ) );
550+
$fm->add_term_meta_box( 'Testing', [ $this->taxonomy ] );
551+
552+
// Set the POST data.
553+
$_POST = [
554+
'taxonomy' => $this->taxonomy,
555+
'tag-name' => $new_term_name,
556+
'slug' => '',
557+
'description' => '',
558+
'parent' => '-1',
559+
"fieldmanager-{$name}-nonce" => wp_create_nonce( "fieldmanager-save-{$name}" ),
560+
$name => $value,
561+
];
562+
563+
// Insert the term.
564+
$term = wp_insert_term( $new_term_name, $this->taxonomy, $_POST );
565+
566+
$this->assertSame( $value, get_term_meta( $term['term_id'], $name, true ) );
567+
}
411568
}

0 commit comments

Comments
 (0)