diff --git a/classes/fields/pick.php b/classes/fields/pick.php index c7a53b1327..3c1bfebfb3 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_in_context( $params['where'], $evaluate_tag_args ); } if ( ! empty( $params['having'] ) ) { - $params['having'] = pods_evaluate_tags( $params['having'], true ); + $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/readme.txt b/readme.txt index 6215a2fcbc..8a33412f73 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.10 - June 8th, 2026 = * Fixed: Resolved fatal error when calling the `Whatsit::count_groups()` method in some circumstances. (@markofapproval, @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 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 ); + } +}