From ed1f62ce64628bbf2197872c04f07953a396414d Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 12:23:29 +0100 Subject: [PATCH 01/16] scope dynamic records map endpoints to caller's viewable records - authorize and scope cluster_geojson, get_grid_totals, get_list_by_grid_id and points_geojson callbacks - personal map limits results to records shared with the current user; records map requires all-access or project metrics permission --- dt-metrics/records/dynamic-records-map.php | 51 ++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/dt-metrics/records/dynamic-records-map.php b/dt-metrics/records/dynamic-records-map.php index 5f0c8b625d..76650a75c5 100644 --- a/dt-metrics/records/dynamic-records-map.php +++ b/dt-metrics/records/dynamic-records-map.php @@ -220,6 +220,37 @@ public function post_type_geojson( WP_REST_Request $request ){ ]; } + /** + * Authorize the current user for a map query and scope the query to the records they may view. + * + * The 'records' instance is for project-wide maps: only users with all-access or project metrics + * permissions may query it, and their results are unscoped. The 'personal' instance requires type + * access and limits results to records shared with or assigned to the current user, unless the user + * also holds project-wide access. + * + * @param string $post_type + * @param array $query + * @return array|WP_Error The query scoped for the current user, or a WP_Error when not permitted. + */ + private function authorize_and_scope_query( $post_type, $query ) { + $has_all_access = current_user_can( 'dt_all_access_' . $post_type ) || current_user_can( 'view_project_metrics' ); + + if ( $this->base_slug === 'records' ) { + if ( !$has_all_access ) { + return new WP_Error( __METHOD__, 'You do not have permission for this.', [ 'status' => 403 ] ); + } + return $query; + } + + if ( !current_user_can( 'access_' . $post_type ) ) { + return new WP_Error( __METHOD__, 'You do not have permission for this.', [ 'status' => 403 ] ); + } + if ( !$has_all_access ) { + $query['shared_with'] = [ 'me' ]; + } + return $query; + } + public function cluster_geojson( WP_REST_Request $request ) { $params = $request->get_json_params() ?? $request->get_body_params(); if ( ! isset( $params['post_type'] ) || empty( $params['post_type'] ) ) { @@ -229,6 +260,11 @@ public function cluster_geojson( WP_REST_Request $request ) { $query = ( isset( $params['query'] ) && !empty( $params['query'] ) ) ? $params['query'] : []; $query = dt_array_merge_recursive_distinct( $query, $this->base_filter ); + $query = $this->authorize_and_scope_query( $post_type, $query ); + if ( is_wp_error( $query ) ) { + return $query; + } + return Disciple_Tools_Mapping_Queries::cluster_geojson( $post_type, $query ); } @@ -242,6 +278,11 @@ public function get_grid_totals( WP_REST_Request $request ) { $post_type = $params['post_type']; $query = ( isset( $params['query'] ) && !empty( $params['query'] ) ) ? $params['query'] : []; $query = dt_array_merge_recursive_distinct( $query, $this->base_filter ); + + $query = $this->authorize_and_scope_query( $post_type, $query ); + if ( is_wp_error( $query ) ) { + return $query; + } $results = Disciple_Tools_Mapping_Queries::query_location_grid_meta_totals( $post_type, $query ); $list = []; @@ -262,6 +303,11 @@ public function get_list_by_grid_id( WP_REST_Request $request ) { $query = ( isset( $params['query'] ) && !empty( $params['query'] ) ) ? $params['query'] : []; $query = dt_array_merge_recursive_distinct( $query, $this->base_filter ); + $query = $this->authorize_and_scope_query( $post_type, $query ); + if ( is_wp_error( $query ) ) { + return $query; + } + return Disciple_Tools_Mapping_Queries::query_under_location_grid_meta_id( $post_type, $grid_id, $query ); } @@ -278,6 +324,11 @@ public function points_geojson( WP_REST_Request $request ) { $query = ( isset( $params['query'] ) && !empty( $params['query'] ) ) ? $params['query'] : []; $query = dt_array_merge_recursive_distinct( $query, $this->base_filter ); + $query = $this->authorize_and_scope_query( $post_type, $query ); + if ( is_wp_error( $query ) ) { + return $query; + } + return Disciple_Tools_Mapping_Queries::points_geojson( $post_type, $query ); } } From a407f75a3990bf31d66ce552f9f35205945293cd Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 12:26:13 +0100 Subject: [PATCH 02/16] fix sql injection in date_range_activity user_select filter cast the user id to an integer before building the meta_value clause so a crafted value.ID can no longer break out of the query --- dt-metrics/records/date-range-activity.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dt-metrics/records/date-range-activity.php b/dt-metrics/records/date-range-activity.php index c14e788df0..9e88e5ee31 100644 --- a/dt-metrics/records/date-range-activity.php +++ b/dt-metrics/records/date-range-activity.php @@ -272,7 +272,7 @@ public function date_range_activity( WP_REST_Request $request ){ } elseif ( $field_type == 'user_select' ){ $value = $params['value']; - $meta_value_sql = ( !empty( $value ) ? "AND meta_value LIKE 'user-" . $value['ID'] . "'" : "AND meta_value LIKE '%'" ); + $meta_value_sql = ( !empty( $value['ID'] ) ? "AND meta_value LIKE 'user-" . intval( $value['ID'] ) . "'" : "AND meta_value LIKE '%'" ); } else { $meta_value_sql = "AND meta_value LIKE '" . ( empty( $params['value'] ) ? '%' : esc_sql( $params['value'] ) ) . "'"; From 03f34b6d4090d6a383c85ddf52f296b0832799f6 Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 12:36:35 +0100 Subject: [PATCH 03/16] harden contact receive-transfer against object injection - validate the transfer token before inserting or processing meta, rejecting forged or missing tokens - decode postmeta values with allowed_classes disabled so serialized objects can no longer be instantiated --- dt-contacts/contacts-transfer.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dt-contacts/contacts-transfer.php b/dt-contacts/contacts-transfer.php index 8dc320bebf..5999468d34 100644 --- a/dt-contacts/contacts-transfer.php +++ b/dt-contacts/contacts-transfer.php @@ -536,7 +536,11 @@ public static function receive_transferred_contact( $params ) { $lagging_meta_input = []; $lagging_location_meta_input = []; $errors = new WP_Error(); - $site_link_post_id = Site_Link_System::get_post_id_by_site_key( Site_Link_System::decrypt_transfer_token( $params['transfer_token'] ) ); + $site_link_post_id = empty( $params['transfer_token'] ) ? false : Site_Link_System::get_post_id_by_site_key( Site_Link_System::decrypt_transfer_token( $params['transfer_token'] ) ); + if ( empty( $site_link_post_id ) ) { + $errors->add( 'transfer_token_invalid', 'Invalid or missing transfer token' ); + return $errors; + } $field_settings = DT_Posts::get_post_field_settings( $post_args['post_type'] ); /** @@ -554,7 +558,7 @@ public static function receive_transferred_contact( $params ) { if ( $key === 'type' && $value[0] === 'media' ) { $value[0] = 'access'; } - $meta_input[ $key ] = maybe_unserialize( $value[0] ); + $meta_input[ $key ] = is_serialized( $value[0] ) ? unserialize( $value[0], [ 'allowed_classes' => false ] ) : $value[0]; } } $post_args['meta_input'] = $meta_input; From 41c21f4d76c79a15c7cd4a6290b0f1cb537be1d8 Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 12:47:04 +0100 Subject: [PATCH 04/16] require a real user for get_settings instead of a dead guard wp_get_current_user() always returns an object, so check ->exists() so the settings endpoint fails closed for logged-out requests --- dt-core/core-endpoints.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dt-core/core-endpoints.php b/dt-core/core-endpoints.php index 48e22a6d3c..e9344a571b 100644 --- a/dt-core/core-endpoints.php +++ b/dt-core/core-endpoints.php @@ -55,8 +55,8 @@ public function add_api_routes() { */ public static function get_settings() { $user = wp_get_current_user(); - if ( !$user ){ - return new WP_Error( 'get_settings', 'Something went wrong. Are you a user?', [ 'status' => 400 ] ); + if ( !$user->exists() ){ + return new WP_Error( 'get_settings', 'Something went wrong. Are you a user?', [ 'status' => 401 ] ); } $available_translations = dt_get_available_languages(); $post_types = DT_Posts::get_post_types(); From 327ae673d6545121728dfb9797cc2ae152518388 Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 12:57:38 +0100 Subject: [PATCH 05/16] stop logging magic-link request params to the php error log remove leftover debug error_log calls in the dt-home endpoint that wrote the magic-link public_key to the log on every request --- dt-apps/dt-home/magic-link-home-app.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/dt-apps/dt-home/magic-link-home-app.php b/dt-apps/dt-home/magic-link-home-app.php index f36b32c09b..07276411e3 100644 --- a/dt-apps/dt-home/magic-link-home-app.php +++ b/dt-apps/dt-home/magic-link-home-app.php @@ -491,12 +491,7 @@ public function add_endpoints() { public function endpoint_get( WP_REST_Request $request ) { $params = $request->get_params(); - // Debug logging - error_log( 'DT Home Screen REST endpoint called' ); - error_log( 'Request params: ' . print_r( $params, true ) ); - if ( ! isset( $params['parts'], $params['action'] ) ) { - error_log( 'Missing parameters - parts: ' . ( isset( $params['parts'] ) ? 'yes' : 'no' ) . ', action: ' . ( isset( $params['action'] ) ? 'yes' : 'no' ) ); return new WP_Error( __METHOD__, 'Missing parameters', [ 'status' => 400 ] ); } @@ -511,8 +506,6 @@ public function endpoint_get( WP_REST_Request $request ) { $apps_manager = DT_Home_Apps::instance(); $apps = $apps_manager->get_apps_for_user( $user_id ); - error_log( 'Apps found: ' . count( $apps ) ); - return [ 'success' => true, 'apps' => $apps, @@ -524,8 +517,6 @@ public function endpoint_get( WP_REST_Request $request ) { $training_manager = DT_Home_Training::instance(); $training_videos = $training_manager->get_videos_for_frontend(); - error_log( 'Training videos found: ' . count( $training_videos ) ); - return [ 'success' => true, 'training_videos' => $training_videos, @@ -541,9 +532,6 @@ public function endpoint_get( WP_REST_Request $request ) { $apps = $apps_manager->get_apps_for_user( $user_id ); $training_videos = $training_manager->get_videos_for_frontend(); - error_log( 'Apps found: ' . count( $apps ) ); - error_log( 'Training videos found: ' . count( $training_videos ) ); - return [ 'success' => true, 'user_id' => $user_id, From 91ddd43e080001a74395eb8cf3f1f6d7ed377a6d Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 13:00:48 +0100 Subject: [PATCH 06/16] stop logging the cleartext password on password change remove the dt_write_log call that wrote the new password to debug.log --- dt-users/users-endpoints.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/dt-users/users-endpoints.php b/dt-users/users-endpoints.php index ef038445ab..0650bcfc0e 100644 --- a/dt-users/users-endpoints.php +++ b/dt-users/users-endpoints.php @@ -221,8 +221,6 @@ public function change_password( WP_REST_Request $request ){ $user_id = get_current_user_id(); if ( isset( $params['password'] ) && $user_id ){ - dt_write_log( $params['password'] ); - wp_set_password( $params['password'], $user_id ); wp_logout(); wp_redirect( '/' ); From 3b44bf33102441c49be7cf4ca2ef531a124dabb1 Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 13:24:45 +0100 Subject: [PATCH 07/16] escape stored values when rendering revert and name dialogs render activity revert values, merge target name, and delete-filter name as text instead of html to prevent stored xss --- dt-assets/js/comments.js | 6 +++--- dt-assets/js/details.js | 2 +- dt-assets/js/modular-list.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dt-assets/js/comments.js b/dt-assets/js/comments.js index ddb70d71ca..d52c25ad2e 100644 --- a/dt-assets/js/comments.js +++ b/dt-assets/js/comments.js @@ -731,9 +731,9 @@ jQuery(document).ready(function ($) { ); } - $('.revert-field').html(field || a.meta_key); - $('.revert-current-value').html(a.meta_value); - $('.revert-old-value').html(a.old_value || 0); + $('.revert-field').text(field || a.meta_key); + $('.revert-current-value').text(a.meta_value); + $('.revert-old-value').text(a.old_value || 0); }) .catch((err) => { console.error(err); diff --git a/dt-assets/js/details.js b/dt-assets/js/details.js index 96912d839a..c223b5bc1a 100644 --- a/dt-assets/js/details.js +++ b/dt-assets/js/details.js @@ -1103,7 +1103,7 @@ jQuery(document).ready(function ($) { onClick: function (node, a, item) { $('.confirm-merge-with-post').show(); $('#confirm-merge-with-post-id').val(item.ID); - $('#name-of-post-to-merge').html(item.name); + $('#name-of-post-to-merge').text(item.name); }, onResult: function (node, query, result, resultCount) { let text = window.TYPEAHEADS.typeaheadHelpText( diff --git a/dt-assets/js/modular-list.js b/dt-assets/js/modular-list.js index e7961fd3d3..9b5087c9c6 100644 --- a/dt-assets/js/modular-list.js +++ b/dt-assets/js/modular-list.js @@ -667,7 +667,7 @@ `); delete_filter.on('click', function () { - $(`.delete-filter-name`).html(filter.name); + $(`.delete-filter-name`).text(filter.name); $('#delete-filter-modal').foundation('open'); filter_to_delete = filter.ID; }); From f5a0aabc4ea3de7aa93c32bd5c34e2db4c9e3c71 Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 13:34:47 +0100 Subject: [PATCH 08/16] require update permission for activity revert and post merge both operations write to records but were gated on can_view; check can_update so a view-only user cannot revert or merge records --- dt-contacts/duplicates-merging.php | 5 +++++ dt-posts/dt-posts.php | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/dt-contacts/duplicates-merging.php b/dt-contacts/duplicates-merging.php index c00429878d..a791a2cbfe 100644 --- a/dt-contacts/duplicates-merging.php +++ b/dt-contacts/duplicates-merging.php @@ -485,6 +485,11 @@ public static function merge_posts( $post_type, $primary_post_id, $archiving_pos return $archiving_post; } + // A merge mutates both records, so the caller must be able to update both. + if ( !DT_Posts::can_update( $post_type, $primary_post_id ) || !DT_Posts::can_update( $post_type, $archiving_post_id ) ) { + return new WP_Error( __METHOD__, 'No permissions to merge these records', [ 'status' => 403 ] ); + } + // Ignore specified fields $ignored_fields = [ 'post_date' diff --git a/dt-posts/dt-posts.php b/dt-posts/dt-posts.php index 153be4ea4a..7db747725e 100644 --- a/dt-posts/dt-posts.php +++ b/dt-posts/dt-posts.php @@ -1775,8 +1775,8 @@ private static function list_revert_post_activity_history( string $post_type, in } public static function revert_post_activity_history( string $post_type, int $post_id, array $args = [] ){ - if ( !self::can_view( $post_type, $post_id ) ){ - return new WP_Error( __FUNCTION__, 'No permissions to read: ' . $post_type, [ 'status' => 403 ] ); + if ( !self::can_update( $post_type, $post_id ) ){ + return new WP_Error( __FUNCTION__, 'No permissions to update: ' . $post_type, [ 'status' => 403 ] ); } /** From a38b233c80ce7cedbea1e887613bcd3867d2f00e Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 13:57:14 +0100 Subject: [PATCH 09/16] throttle brute-force attempts on the jwt token endpoint cap repeated failed logins per client ip on /jwt-auth/v1/token using transients; scoped to the token endpoint and disableable via filter --- dt-core/jwt-rate-limit.php | 95 ++++++++++++++++++++++++++++++++++++++ functions.php | 1 + 2 files changed, 96 insertions(+) create mode 100644 dt-core/jwt-rate-limit.php diff --git a/dt-core/jwt-rate-limit.php b/dt-core/jwt-rate-limit.php new file mode 100644 index 0000000000..64df6f6d45 --- /dev/null +++ b/dt-core/jwt-rate-limit.php @@ -0,0 +1,95 @@ +get_route() === '/jwt-auth/v1/token'; + } + $path = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; + return strpos( $path, 'jwt-auth/v1/token' ) !== false && strpos( $path, 'token/validate' ) === false; +} + +/** + * Reject token requests from a client IP that has exceeded the failure threshold. + */ +function dt_jwt_throttle_block( $result, $server, $request ) { + if ( $result !== null ) { + return $result; + } + if ( !apply_filters( 'dt_enable_jwt_login_throttle', true ) || !dt_is_jwt_token_request( $request ) ) { + return $result; + } + $max = (int) apply_filters( 'dt_jwt_login_throttle_max_attempts', 10 ); + if ( (int) get_transient( dt_jwt_throttle_key() ) >= $max ) { + return new WP_Error( + 'jwt_auth_too_many_attempts', + __( 'Too many failed login attempts. Please try again later.', 'disciple_tools' ), + [ 'status' => 429 ] + ); + } + return $result; +} +add_filter( 'rest_pre_dispatch', 'dt_jwt_throttle_block', 10, 3 ); + +/** + * Count a failed credential attempt against the client IP, for token requests only. + */ +function dt_jwt_throttle_record_failure() { + if ( !apply_filters( 'dt_enable_jwt_login_throttle', true ) || !dt_is_jwt_token_request() ) { + return; + } + $window = (int) apply_filters( 'dt_jwt_login_throttle_window', 15 * MINUTE_IN_SECONDS ); + $key = dt_jwt_throttle_key(); + set_transient( $key, (int) get_transient( $key ) + 1, $window ); +} +add_action( 'wp_login_failed', 'dt_jwt_throttle_record_failure' ); + +/** + * Clear the failure counter once a token is successfully issued. + */ +function dt_jwt_throttle_clear_on_success( $data, $user ) { + delete_transient( dt_jwt_throttle_key() ); + return $data; +} +add_filter( 'jwt_auth_token_before_dispatch', 'dt_jwt_throttle_clear_on_success', 10, 2 ); diff --git a/functions.php b/functions.php index 3aab160d6e..a20a459132 100755 --- a/functions.php +++ b/functions.php @@ -150,6 +150,7 @@ public function __construct() { if ( !class_exists( 'Jwt_Auth' ) ) { require_once( 'dt-core/libraries/wp-api-jwt-auth/jwt-auth.php' ); } + require_once( 'dt-core/jwt-rate-limit.php' ); // throttles brute-force attempts against the JWT token endpoint require_once( 'dt-core/configuration/config-site-defaults.php' ); // Force required site configurations require_once( 'dt-core/wp-async-request.php' ); // Async Task Processing require_once( 'dt-core/configuration/restrict-rest-api.php' ); // sets authentication requirement for rest end points. Disables rest for pre-wp-4.7 sites. From 496fb67927826014f76a8bf6daabfa8f344fcb3b Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 14:24:55 +0100 Subject: [PATCH 10/16] protect the assigned user's share from removal by collaborators the prior guard was unreachable; a user with only a share could remove the assigned user's share and lock them out. allow self-removal, and restrict removing the assignee's share to that user or update_any holders --- dt-posts/dt-posts.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/dt-posts/dt-posts.php b/dt-posts/dt-posts.php index 7db747725e..73de29ebf0 100644 --- a/dt-posts/dt-posts.php +++ b/dt-posts/dt-posts.php @@ -2321,15 +2321,20 @@ public static function get_shared_with( string $post_type, int $post_id, bool $c public static function remove_shared( string $post_type, int $post_id, int $user_id, $check_permissions = true ) { global $wpdb; - if ( $check_permissions && !self::can_update( $post_type, $post_id ) ) { + $assigned_to_meta = get_post_meta( $post_id, 'assigned_to', true ); + $assigned_user_id = dt_get_user_id_from_assigned_to( $assigned_to_meta ); + $is_self_removal = ( get_current_user_id() === $user_id ); + + // A user may always remove their own share; otherwise they need update permission on the record. + if ( $check_permissions && !$is_self_removal && !self::can_update( $post_type, $post_id ) ) { return new WP_Error( __FUNCTION__, 'You do not have permission to unshare', [ 'status' => 403 ] ); } - $assigned_to_meta = get_post_meta( $post_id, 'assigned_to', true ); - if ( $check_permissions && !( self::can_update( $post_type, $post_id ) || - get_current_user_id() === $user_id || - dt_get_user_id_from_assigned_to( $assigned_to_meta ) === get_current_user_id() ) - ){ + // The assigned user's foundational share may only be removed by that user, or by someone with + // record-type update authority — not by a collaborator who merely holds a share on the record. + if ( $check_permissions && !$is_self_removal + && (int) $assigned_user_id === $user_id + && !current_user_can( 'update_any_' . $post_type ) ) { $name = dt_get_user_display_name( $user_id ); return new WP_Error( __FUNCTION__, 'You do not have permission to unshare with ' . $name, [ 'status' => 403 ] ); } From f77361446fd66c1820f78980fcb5994685f4d9d0 Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 14:50:17 +0100 Subject: [PATCH 11/16] validate upload content type and prevent inline-executable files detect the real mime type from the file bytes instead of trusting the client, and store anything that is not a raster image as octet-stream --- dt-core/dt-storage.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/dt-core/dt-storage.php b/dt-core/dt-storage.php index 2445ce0731..462b66d221 100644 --- a/dt-core/dt-storage.php +++ b/dt-core/dt-storage.php @@ -165,12 +165,25 @@ public static function upload_file( string $key_prefix = '', array $upload = [], $tmp = $upload['tmp_name'] ?? ''; $type = $upload['type'] ?? ''; + // Derive the real content type from the file's bytes rather than trusting the + // client-supplied value, and never store uploads as inline-executable content: + // anything that is not a known raster image is served as a download. + if ( $tmp && function_exists( 'finfo_open' ) && ( $finfo = finfo_open( FILEINFO_MIME_TYPE ) ) ) { + $detected = finfo_file( $finfo, $tmp ); + finfo_close( $finfo ); + if ( !empty( $detected ) ) { + $type = $detected; + } + } + $safe_inline_types = [ 'image/gif', 'image/jpeg', 'image/png', 'image/webp' ]; + $content_type = in_array( strtolower( trim( $type ) ), $safe_inline_types, true ) ? $type : 'application/octet-stream'; + try { $client->putObject([ 'Bucket' => $bucket, 'Key' => $key, 'Body' => fopen( $tmp, 'r' ), - 'ContentType' => $type + 'ContentType' => $content_type ]); $uploaded_thumbnail_key = null; From 08367aafa982bedf9a0e9d053de2aa175ef1659d Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 15:11:06 +0100 Subject: [PATCH 12/16] use constant-time comparison for site-link transfer tokens replace loose == checks with hash_equals so token matching is constant-time and not subject to numeric string type juggling --- dt-core/admin/site-link-post-type.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dt-core/admin/site-link-post-type.php b/dt-core/admin/site-link-post-type.php index b8e231cde1..01ae6fa5c7 100644 --- a/dt-core/admin/site-link-post-type.php +++ b/dt-core/admin/site-link-post-type.php @@ -1419,7 +1419,7 @@ public static function decrypt_transfer_token( $transfer_token ) { */ if ( isset( $array['token'], $array['token_as_transfer_key'] ) && $array['token_as_transfer_key'] ){ - if ( $array['token'] == $transfer_token ){ + if ( hash_equals( (string) $array['token'], (string) $transfer_token ) ){ return $key; } } else { @@ -1429,9 +1429,9 @@ public static function decrypt_transfer_token( $transfer_token ) { $next = gmdate( 'Y-m-dH', strtotime( current_time( 'Y-m-d H:i:s', 1 ) . '+1 hour' ) ); $next_hour = md5( $key . $next ); - if ( $current_hour == $transfer_token - || $past_hour == $transfer_token - || $next_hour == $transfer_token ){ + if ( hash_equals( $current_hour, (string) $transfer_token ) + || hash_equals( $past_hour, (string) $transfer_token ) + || hash_equals( $next_hour, (string) $transfer_token ) ){ return $key; } From 00404afd97f3ee9d4d2333ee86009d4e0cb7f6a8 Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 15:46:16 +0100 Subject: [PATCH 13/16] validate plugin-install download url against ssrf reject non-http(s) schemes and hosts resolving to private, loopback, link-local or reserved ranges, and fetch via the http api --- dt-core/admin/admin-settings-endpoints.php | 43 +++++++++++++++------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/dt-core/admin/admin-settings-endpoints.php b/dt-core/admin/admin-settings-endpoints.php index 6439da6eb4..a35990c1d3 100644 --- a/dt-core/admin/admin-settings-endpoints.php +++ b/dt-core/admin/admin-settings-endpoints.php @@ -787,20 +787,37 @@ public static function edit_translations( WP_REST_Request $request ) { public function plugin_install( WP_REST_Request $request ) { require_once( ABSPATH . 'wp-admin/includes/file.php' ); $params = $request->get_params(); - $download_url = sanitize_text_field( wp_unslash( $params['download_url'] ) ); + $download_url = sanitize_text_field( wp_unslash( $params['download_url'] ?? '' ) ); + + // Only fetch validated, externally-routable http(s) URLs. wp_http_validate_url() rejects + // loopback, private and link-local addresses, closing server-side request forgery. + if ( empty( $download_url ) + || ! wp_http_validate_url( $download_url ) + || ! in_array( wp_parse_url( $download_url, PHP_URL_SCHEME ), [ 'http', 'https' ], true ) ) { + return new WP_Error( 'invalid_download_url', 'Invalid download URL.', [ 'status' => 400 ] ); + } + + // wp_http_validate_url() does not cover link-local (e.g. 169.254.169.254 cloud metadata) + // or all reserved ranges; reject any host that resolves into a private or reserved network. + $resolved_ip = gethostbyname( (string) wp_parse_url( $download_url, PHP_URL_HOST ) ); + if ( filter_var( $resolved_ip, FILTER_VALIDATE_IP ) + && ! filter_var( $resolved_ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { + return new WP_Error( 'invalid_download_url', 'Invalid download URL.', [ 'status' => 400 ] ); + } + set_time_limit( 0 ); - $folder_name = explode( '/', $download_url ); - $folder_name = get_home_path() . 'wp-content/plugins/' . $folder_name[4] . '.zip'; - if ( $folder_name != '' ) { - //download the zip file to plugins - file_put_contents( $folder_name, file_get_contents( $download_url ) ); - // get the absolute path to $file - $folder_name = realpath( $folder_name ); - //unzip - WP_Filesystem(); - $unzip = unzip_file( $folder_name, realpath( get_home_path() . 'wp-content/plugins/' ) ); - //remove the file - unlink( $folder_name ); + WP_Filesystem(); + + // Download to a temp file through the HTTP API rather than reading the URL directly. + $tmp_file = download_url( $download_url ); + if ( is_wp_error( $tmp_file ) ) { + return $tmp_file; + } + + $unzip = unzip_file( $tmp_file, realpath( get_home_path() . 'wp-content/plugins/' ) ); + unlink( $tmp_file ); + if ( is_wp_error( $unzip ) ) { + return $unzip; } return true; } From 6d4c4b6106e52b04608371bd5864d665422f4eda Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 16:02:50 +0100 Subject: [PATCH 14/16] keep peoplegroups locale search inside the permission group the locale clause was appended after the search parenthesis, so its OR escaped the share gate; move it inside so access filters still apply --- dt-posts/posts.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/dt-posts/posts.php b/dt-posts/posts.php index 4498dc2b74..a1e1b3f455 100644 --- a/dt-posts/posts.php +++ b/dt-posts/posts.php @@ -1347,17 +1347,20 @@ public static function search_viewable_post( string $post_type, array $query, bo AND meta_value LIKE '%" . esc_sql( $search ) . "%' ) "; } - $post_query .= ' ) '; - if ( $post_type === 'peoplegroups' ) { $locale = get_user_locale(); - $post_query .= " OR p.ID IN ( SELECT post_id + if ( substr( $post_query, -6 ) !== 'AND ( ' ) { + $post_query .= 'OR '; + } + $post_query .= "p.ID IN ( SELECT post_id FROM $wpdb->postmeta WHERE meta_key LIKE '" . esc_sql( $locale ) . "' - AND meta_value LIKE '%" . esc_sql( $search ) . "%' )"; + AND meta_value LIKE '%" . esc_sql( $search ) . "%' ) "; } + + $post_query .= ' ) '; } $sort_sql = ''; From 5a365259f4c7f400bfba13d33f4567390da38003 Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 16:20:52 +0100 Subject: [PATCH 15/16] explain why the assigned user cannot be unshared state the reason (the user is assigned to the record) and make the message translatable instead of a generic permission error --- dt-posts/dt-posts.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dt-posts/dt-posts.php b/dt-posts/dt-posts.php index 73de29ebf0..dd0497d591 100644 --- a/dt-posts/dt-posts.php +++ b/dt-posts/dt-posts.php @@ -2336,7 +2336,11 @@ public static function remove_shared( string $post_type, int $post_id, int $user && (int) $assigned_user_id === $user_id && !current_user_can( 'update_any_' . $post_type ) ) { $name = dt_get_user_display_name( $user_id ); - return new WP_Error( __FUNCTION__, 'You do not have permission to unshare with ' . $name, [ 'status' => 403 ] ); + return new WP_Error( __FUNCTION__, sprintf( + /* translators: %s is the assigned user's display name */ + __( '%s is assigned to this record, so their access cannot be removed by unsharing.', 'disciple_tools' ), + $name + ), [ 'status' => 403 ] ); } From af64607ba94da6f84e3bea6f9620392713159d41 Mon Sep 17 00:00:00 2001 From: corsac Date: Wed, 10 Jun 2026 16:24:57 +0100 Subject: [PATCH 16/16] phpcs --- dt-core/dt-storage.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/dt-core/dt-storage.php b/dt-core/dt-storage.php index 462b66d221..d77659024d 100644 --- a/dt-core/dt-storage.php +++ b/dt-core/dt-storage.php @@ -168,11 +168,14 @@ public static function upload_file( string $key_prefix = '', array $upload = [], // Derive the real content type from the file's bytes rather than trusting the // client-supplied value, and never store uploads as inline-executable content: // anything that is not a known raster image is served as a download. - if ( $tmp && function_exists( 'finfo_open' ) && ( $finfo = finfo_open( FILEINFO_MIME_TYPE ) ) ) { - $detected = finfo_file( $finfo, $tmp ); - finfo_close( $finfo ); - if ( !empty( $detected ) ) { - $type = $detected; + if ( $tmp && function_exists( 'finfo_open' ) ) { + $finfo = finfo_open( FILEINFO_MIME_TYPE ); + if ( $finfo ) { + $detected = finfo_file( $finfo, $tmp ); + finfo_close( $finfo ); + if ( !empty( $detected ) ) { + $type = $detected; + } } } $safe_inline_types = [ 'image/gif', 'image/jpeg', 'image/png', 'image/webp' ];