From 74211b47d1db34a178b421653d378f5630536008 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 9 Aug 2026 23:14:20 +0600 Subject: [PATCH 1/2] fix(pick): evaluate magic tags in pick_where and pick_having PodsField_Pick::get_object_data() was passing the legacy positional true to pods_evaluate_tags() for the pick_where and pick_having clauses, so the evaluator had no pod/id context. Tags like {@id}, {@post_title}, and {@name} fell through to a superglobal-only resolver and came out blank in the SQL, breaking the related-items filter. Build a tag-eval args array that, when an id and a resolvable pod reference are available, instantiates (or reuses) a Pods object for the current item and passes it as the 'pod' arg. Falls back to the previous no-pod behaviour for empty ids and unresolvable pod refs. Fixes #7406 --- classes/fields/pick.php | 23 +++- readme.txt | 4 + .../wpunit/Pods/Field/PickWhereTagsTest.php | 124 ++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 tests/codeception/wpunit/Pods/Field/PickWhereTagsTest.php diff --git a/classes/fields/pick.php b/classes/fields/pick.php index f3185a4063..e966f4ebf1 100644 --- a/classes/fields/pick.php +++ b/classes/fields/pick.php @@ -2730,12 +2730,31 @@ public function get_object_data( $object_params = null ) { $params['select'] .= ', `t`.`path`'; } + $evaluate_tag_args = [ + 'sanitize' => true, + ]; + + if ( + ! empty( $id ) + && ( is_string( $pod ) || is_array( $pod ) || $pod instanceof Pod || $pod instanceof Pods ) + ) { + if ( $pod instanceof Pods && (int) $pod->id() === (int) $id ) { + $current_pods_obj = $pod; + } else { + $current_pods_obj = pods( $pod, $id ); + } + + if ( $current_pods_obj instanceof Pods ) { + $evaluate_tag_args['pod'] = $current_pods_obj; + } + } + if ( ! empty( $params['where'] ) && (array) $table_info['where_default'] !== $params['where'] ) { - $params['where'] = pods_evaluate_tags( $params['where'], true ); + $params['where'] = pods_evaluate_tags( $params['where'], $evaluate_tag_args ); } if ( ! empty( $params['having'] ) ) { - $params['having'] = pods_evaluate_tags( $params['having'], true ); + $params['having'] = pods_evaluate_tags( $params['having'], $evaluate_tag_args ); } if ( empty( $params['where'] ) || ( ! is_array( $params['where'] ) && '' === trim( $params['where'] ) ) ) { diff --git a/readme.txt b/readme.txt index f018b828f8..0b132c793a 100644 --- a/readme.txt +++ b/readme.txt @@ -182,6 +182,10 @@ Pods really wouldn't be where it is without all the contributions from our [dono == Changelog == += 3.4.0 - TBD = + +* Fixed: Resolve magic tags like `{@id}`, `{@post_title}`, and `{@name}` in Relationship / Pick field `pick_where` and `pick_having` clauses against the current item context. #7406 + = 3.3.9 - May 20th, 2026 = * Security: Resolve a XSS vulnerability in the Pods UI forms in the admin area. Props to Bonds through Patchstack for responsibly reporting this. (@sc0ttkclark) diff --git a/tests/codeception/wpunit/Pods/Field/PickWhereTagsTest.php b/tests/codeception/wpunit/Pods/Field/PickWhereTagsTest.php new file mode 100644 index 0000000000..8f3407017d --- /dev/null +++ b/tests/codeception/wpunit/Pods/Field/PickWhereTagsTest.php @@ -0,0 +1,124 @@ +save_pod( [ + 'name' => $target_pod_name, + 'type' => 'post_type', + ] ); + + // Save two target items. The pick_where will only match one of them. + $keep_title = 'MATCHED_' . wp_generate_password( 8, false ); + $drop_title = 'OTHER_' . wp_generate_password( 8, false ); + + $keep_item_id = $this->factory()->post->create( [ + 'post_type' => $target_pod_name, + 'post_title' => $keep_title, + 'post_status' => 'publish', + ] ); + + $drop_item_id = $this->factory()->post->create( [ + 'post_type' => $target_pod_name, + 'post_title' => $drop_title, + 'post_status' => 'publish', + ] ); + + // A "main" pod holding a pick field into the target pod. + $main_pod_name = 'pick_where_tag_main'; + + $api->save_pod( [ + 'name' => $main_pod_name, + 'type' => 'post_type', + ] ); + + $api->save_field( [ + 'pod' => $main_pod_name, + 'name' => 'related', + 'type' => 'pick', + 'pick_object' => 'post_type-' . $target_pod_name, + 'pick_where' => "`t`.`post_title` = '{@post_title}'", + 'pick_format_type' => 'single', + 'pick_format_single' => 'dropdown', + ] ); + + // Save a main item titled with the keep title so the where matches it. + $main_item_id = $this->factory()->post->create( [ + 'post_type' => $main_pod_name, + 'post_title' => $keep_title, + 'post_status' => 'publish', + ] ); + + $pod = pods( $main_pod_name, $main_item_id ); + + $field = new PodsField_Pick(); + + $field_object = $pod->fields( 'related' ); + + $object_params = [ + 'name' => 'related', + 'value' => '', + 'options' => $field_object, + 'pod' => $pod, + 'id' => $main_item_id, + 'context' => 'data', + 'data_params' => [ 'query' => '' ], + ]; + + $data = $field->get_object_data( $object_params ); + + // In the 'data' context get_object_data() returns [item_id => item_label]. + // The magic tag must resolve to the current item's title, so only the + // matching target item is present as a key. + $this->assertIsArray( $data, 'get_object_data() should return an array.' ); + $this->assertArrayHasKey( $keep_item_id, $data, 'Resolved pick_where should include the matching target item.' ); + $this->assertArrayNotHasKey( $drop_item_id, $data, 'Resolved pick_where should exclude non-matching target items.' ); + } + + /** + * Boundary: when get_object_data() is called with an empty id, the fix + * must still evaluate magic tags (not leave them raw), even though no + * Pods instance context is available. {@get.test_7406_boundary} should + * resolve from $_GET and the rest of the tag should not leak through. + */ + public function test_pick_where_evaluates_magic_tags_without_id() { + $_GET['test_7406_boundary'] = '5797'; + + try { + $where = "id = '{@get.test_7406_boundary}'"; + + // pods_evaluate_tags() is the same evaluator the fix uses. Assert + // the tag is replaced rather than left as the raw {@...} token. + $evaluated = pods_evaluate_tags( $where, [ + 'sanitize' => true, + ] ); + + $this->assertStringNotContainsString( '{@get.test_7406_boundary}', $evaluated ); + $this->assertStringContainsString( '5797', $evaluated ); + } finally { + unset( $_GET['test_7406_boundary'] ); + } + } + +} \ No newline at end of file From 921a96a42f6475146032d330e5981b763382c087 Mon Sep 17 00:00:00 2001 From: faisalahammad Date: Wed, 19 Aug 2026 21:09:36 +0600 Subject: [PATCH 2/2] fix: keep ambient-context magic tags off the Pod in pick_where/pick_having Passing a Pods object so {@id} and {@post_title} resolve also changed how {@get.X} behaves, because pods_evaluate_tag() routes everything through Pods::do_magic_tags() once a pod is present. Two problems follow. Pods::process_magic_tags() only falls back to the general resolver when PODS_SHORTCODE_ALLOW_EVALUATE_TAGS is defined and true, so with the default configuration {@get.X} in a where clause silently resolves to nothing where it previously read the query string. And when that constant *is* enabled, the fallback calls pods_evaluate_tag() with no arguments, dropping the sanitize flag the caller asked for and putting raw request data straight into a SQL clause. pods_tag_is_context_scoped() identifies tags that address an ambient context (superglobals, options, transients, url helpers) and pods_evaluate_tags_in_context() evaluates those without the pod argument, so they reach the general resolver with sanitize intact. A field that genuinely exists on the Pod still wins, so traversal such as {@author.display_name} is unchanged. Refs #7406 --- classes/fields/pick.php | 4 +- includes/data.php | 156 ++++++++++++++++ .../functions/EvaluateTagContextTest.php | 171 ++++++++++++++++++ 3 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 tests/codeception/wpunit/functions/EvaluateTagContextTest.php diff --git a/classes/fields/pick.php b/classes/fields/pick.php index 5bfd033b14..3c1bfebfb3 100644 --- a/classes/fields/pick.php +++ b/classes/fields/pick.php @@ -2750,11 +2750,11 @@ public function get_object_data( $object_params = null ) { } if ( ! empty( $params['where'] ) && (array) $table_info['where_default'] !== $params['where'] ) { - $params['where'] = pods_evaluate_tags( $params['where'], $evaluate_tag_args ); + $params['where'] = pods_evaluate_tags_in_context( $params['where'], $evaluate_tag_args ); } if ( ! empty( $params['having'] ) ) { - $params['having'] = pods_evaluate_tags( $params['having'], $evaluate_tag_args ); + $params['having'] = pods_evaluate_tags_in_context( $params['having'], $evaluate_tag_args ); } if ( empty( $params['where'] ) || ( ! is_array( $params['where'] ) && '' === trim( $params['where'] ) ) ) { diff --git a/includes/data.php b/includes/data.php index a9428ad54c..9a580f114f 100644 --- a/includes/data.php +++ b/includes/data.php @@ -1772,6 +1772,162 @@ function ( $tag ) use ( $args ) { ); } +/** + * Determine whether a magic tag addresses an ambient context rather than the current item. + * + * Tags such as `{@get.foo}`, `{@user.ID}` or `{@option.blogname}` read from + * superglobals, options and similar sources. They must not be resolved through a + * Pods object, because Pods::process_magic_tags() only falls back to the general + * resolver when PODS_SHORTCODE_ALLOW_EVALUATE_TAGS is enabled, and that fallback + * calls pods_evaluate_tag() with no arguments -- dropping the sanitize flag and + * putting raw request data into whatever the caller builds, including SQL clauses. + * + * A field that actually exists on the Pod always wins, so item traversal such as + * `{@author.display_name}` keeps resolving against the item. + * + * @since 3.4.0 + * + * @param string|array $tag The magic tag (with or without the surrounding `{@}`). + * @param null|Pods $pod Optional. Pod used to check whether the prefix is a real field. + * + * @return bool Whether the tag addresses an ambient context. + */ +function pods_tag_is_context_scoped( $tag, $pod = null ) { + if ( is_array( $tag ) ) { + $tag = isset( $tag[2] ) ? $tag[2] : ''; + } + + $tag = trim( (string) $tag, ' {@}' ); + + if ( '' === $tag ) { + return false; + } + + // Only the tag name matters; helper/before/after are comma separated. + $parts = pods_trim( explode( ',', $tag ) ); + $name = isset( $parts[0] ) ? $parts[0] : ''; + + if ( '' === $name ) { + return false; + } + + $segments = pods_trim( explode( '.', $name ) ); + $prefix = strtolower( $segments[0] ); + + // These resolve on their own, with no second segment. + $standalone = array( + 'template-url', + 'stylesheet-url', + 'site-url', + 'home-url', + 'admin-url', + 'includes-url', + 'content-url', + 'plugins-url', + 'network-site-url', + 'network-home-url', + 'network-admin-url', + 'user-admin-url', + 'prefix', + ); + + if ( in_array( $prefix, $standalone, true ) ) { + return true; + } + + // Everything else needs an explicit context prefix plus a variable name. + if ( 2 > count( $segments ) ) { + return false; + } + + $contexts = array( + 'get', + 'post', + 'request', + 'query', + 'url', + 'uri', + 'url-relative', + 'server', + 'session', + 'global', + 'globals', + 'cookie', + 'constant', + 'user', + 'option', + 'site-option', + 'transient', + 'site-transient', + 'cache', + 'pods-transient', + 'pods-site-transient', + 'pods-cache', + 'pods-option-cache', + 'date', + 'pods', + 'pods_display', + 'post_id', + ); + + if ( ! in_array( $prefix, $contexts, true ) ) { + return false; + } + + // A real field on the Pod takes precedence over the ambient context. + if ( $pod instanceof Pods && $pod->fields( $segments[0] ) ) { + return false; + } + + return true; +} + +/** + * Evaluate magic tags, routing ambient-context tags away from the Pod object. + * + * Behaves like {@see pods_evaluate_tags()} except that any tag identified by + * {@see pods_tag_is_context_scoped()} is evaluated without the `pod` argument, so + * it reaches the general resolver with the caller's sanitize setting intact. + * + * @since 3.4.0 + * + * @param string|array|object $tags The content to evaluate. + * @param array $args Arguments passed through to pods_evaluate_tag(). + * + * @return string|array|object Evaluated content. + */ +function pods_evaluate_tags_in_context( $tags, $args = array() ) { + if ( ! is_array( $args ) ) { + $args = array(); + } + + if ( is_array( $tags ) ) { + foreach ( $tags as $k => $tag ) { + $tags[ $k ] = pods_evaluate_tags_in_context( $tag, $args ); + } + + return $tags; + } + + if ( is_object( $tags ) ) { + return (object) pods_evaluate_tags_in_context( get_object_vars( $tags ), $args ); + } + + $pod = isset( $args['pod'] ) ? $args['pod'] : null; + + return preg_replace_callback( + '/({@(.*?)})/m', + static function ( $tag ) use ( $args, $pod ) { + if ( pods_tag_is_context_scoped( $tag, $pod ) ) { + unset( $args['pod'] ); + } + + return pods_evaluate_tag( $tag, $args ); + }, + (string) $tags + ); +} + /** * Evaluate tag like magic tag but sanitized. * diff --git a/tests/codeception/wpunit/functions/EvaluateTagContextTest.php b/tests/codeception/wpunit/functions/EvaluateTagContextTest.php new file mode 100644 index 0000000000..dfc4060f94 --- /dev/null +++ b/tests/codeception/wpunit/functions/EvaluateTagContextTest.php @@ -0,0 +1,171 @@ + [ 'get.foo' ], + 'post var' => [ 'post.foo' ], + 'request' => [ 'request.foo' ], + 'cookie' => [ 'cookie.foo' ], + 'server' => [ 'server.HTTP_HOST' ], + 'user' => [ 'user.ID' ], + 'option' => [ 'option.blogname' ], + 'constant' => [ 'constant.ABSPATH' ], + 'site-url alone' => [ 'site-url' ], + 'prefix alone' => [ 'prefix' ], + 'with helper' => [ 'get.foo,some_helper' ], + 'braced' => [ '{@get.foo}' ], + ]; + } + + /** + * @dataProvider provider_context_scoped + * + * @param string $tag The magic tag. + */ + public function test_context_scoped_tags_are_detected( $tag ) { + $this->assertTrue( pods_tag_is_context_scoped( $tag ) ); + } + + /** + * @return array[] + */ + public function provider_item_scoped() { + return [ + 'bare id' => [ 'id' ], + 'bare post_title' => [ 'post_title' ], + 'bare name' => [ 'name' ], + 'unknown prefix' => [ 'something.else' ], + 'traversal' => [ 'author.display_name' ], + 'empty' => [ '' ], + ]; + } + + /** + * @dataProvider provider_item_scoped + * + * @param string $tag The magic tag. + */ + public function test_item_scoped_tags_are_not_context_scoped( $tag ) { + $this->assertFalse( pods_tag_is_context_scoped( $tag ) ); + } + + /** + * A real field on the Pod must win over the ambient context, so existing + * traversal keeps working. + */ + public function test_real_pod_field_takes_precedence_over_context_prefix() { + $api = pods_api(); + + $pod_id = $api->save_pod( [ + 'type' => 'post_type', + 'name' => 'ctx_tag_pod', + ] ); + + $api->save_field( [ + 'pod_id' => $pod_id, + 'name' => 'user', + 'type' => 'text', + ] ); + + $pod = pods( 'ctx_tag_pod' ); + + $this->assertFalse( pods_tag_is_context_scoped( 'user.something', $pod ) ); + + // Without the Pod, the same tag is ambient. + $this->assertTrue( pods_tag_is_context_scoped( 'user.something' ) ); + } + + /** + * The regression this guards: a request value routed through a Pod-scoped + * evaluation must still be sanitized rather than emitted raw. + */ + public function test_request_values_are_sanitized_when_evaluated_with_a_pod() { + $_GET['evil'] = "1' OR '1'='1"; + + $api = pods_api(); + + $pod_id = $api->save_pod( [ + 'type' => 'post_type', + 'name' => 'ctx_tag_pod_2', + ] ); + + $api->save_field( [ + 'pod_id' => $pod_id, + 'name' => 'some_field', + 'type' => 'text', + ] ); + + $pod = pods( 'ctx_tag_pod_2' ); + + $result = pods_evaluate_tags_in_context( + '`t`.`id` = {@get.evil}', + [ + 'sanitize' => true, + 'pod' => $pod, + ] + ); + + $this->assertStringNotContainsString( "OR '1'='1", $result ); + $this->assertStringNotContainsString( "1' OR", $result ); + } + + /** + * Ambient tags must resolve even though a Pod was supplied. + */ + public function test_context_tag_still_resolves_when_a_pod_is_supplied() { + $_GET['foo'] = 'bar'; + + $api = pods_api(); + + $pod_id = $api->save_pod( [ + 'type' => 'post_type', + 'name' => 'ctx_tag_pod_3', + ] ); + + $api->save_field( [ + 'pod_id' => $pod_id, + 'name' => 'some_field', + 'type' => 'text', + ] ); + + $pod = pods( 'ctx_tag_pod_3' ); + + $result = pods_evaluate_tags_in_context( + 'value = {@get.foo}', + [ + 'sanitize' => true, + 'pod' => $pod, + ] + ); + + $this->assertSame( 'value = bar', $result ); + } +}