diff --git a/bin/smoke-test.sh b/bin/smoke-test.sh index 0b76c1b..f5d29cb 100755 --- a/bin/smoke-test.sh +++ b/bin/smoke-test.sh @@ -280,6 +280,12 @@ else "$WP" eval-file "$PLUGIN_DIR/tests/smoke/scenario-comment-type-blocklist.php" fi +echo "" +echo "── Scenario 23: external services match the disclosure ────────" +# Intercepts every outbound request the opt-in usage tracker makes (nothing is +# really sent) and checks it against the opt-in notice and readme (#164). +"$WP" eval-file "$PLUGIN_DIR/tests/smoke/scenario-external-services.php" + echo "" echo "── Scenario 21: uninstall leaves nothing behind ───────────────" # Runs last: it executes the real uninstall.php, which removes the settings diff --git a/disable-comments.php b/disable-comments.php index 058f834..247a25b 100644 --- a/disable-comments.php +++ b/disable-comments.php @@ -315,7 +315,12 @@ public function start_plugin_usage_tracking() { ]); $tracker->set_notice_options(array( 'notice' => __('Want to help make Disable Comments even better?', 'disable-comments'), - 'extra_notice' => __('We collect non-sensitive diagnostic data and plugin usage information. Your site URL, WordPress & PHP version, plugins & themes and email address to send you the discount coupon. This data lets us make sure this plugin always stays compatible with the most popular plugins and themes. No spam, I promise.', 'disable-comments'), + 'extra_notice' => sprintf( + // translators: 1: service host name, 2: privacy policy URL. + __('If you agree, the plugin sends this to WPInsight (%1$s), WPDeveloper\'s usage-analytics service: your site URL and site name, WordPress version, site language and charset, PHP version, server software, whether it is a multisite, text direction, the server path of the plugin, your active and inactive plugins, your active theme and its version, the Disable Comments version, and your user account\'s email address so we can send you the discount coupon. It is sent once when you agree, then about once a day (only what changed). When you deactivate the plugin, these details are sent again with your email address and the reason and note you enter. Like any web request, it also reveals your server\'s IP address. No other service is contacted. This data lets us make sure this plugin always stays compatible with the most popular plugins and themes. No spam, I promise. Privacy Policy', 'disable-comments'), + 'send.wpinsight.com', + esc_url('https://wpdeveloper.com/privacy-policy/') + ), )); $tracker->init(); } diff --git a/includes/class-plugin-usage-tracker.php b/includes/class-plugin-usage-tracker.php index cc5de5e..d44bdd3 100644 --- a/includes/class-plugin-usage-tracker.php +++ b/includes/class-plugin-usage-tracker.php @@ -469,14 +469,6 @@ public function send_data( $body ) { * Send Initial Data to API */ if( $site_id == false && $this->item_id !== false ) { - if( isset( $_SERVER['REMOTE_ADDR'] ) && ! empty( $_SERVER['REMOTE_ADDR'] && $_SERVER['REMOTE_ADDR'] != '127.0.0.1' ) ) { - $country_request = wp_remote_get( 'http://ip-api.com/json/'. sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) .'?fields=country'); - if( ! is_wp_error( $country_request ) && $country_request['response']['code'] == 200 ) { - $ip_data = json_decode( $country_request["body"] ); - $body['country'] = isset( $ip_data->country ) ? $ip_data->country : 'NOT SET'; - } - } - $body['plugin_slug'] = $this->plugin_name; $body['url'] = $site_url; $body['item_id'] = $this->item_id; diff --git a/readme.txt b/readme.txt index 3721341..340c5bc 100644 --- a/readme.txt +++ b/readme.txt @@ -232,6 +232,22 @@ Both the sources and the build configuration live in the [GitHub development rep `npm run build` +== External services == + +Disable Comments connects to one external service, and only if you opt in. + += WPInsight usage analytics (opt-in, off by default) = + +WPInsight is WPDeveloper's usage-analytics service. We use it to learn which WordPress versions, PHP versions, plugins and themes people run alongside Disable Comments, so we can keep the plugin compatible with them. + +* **When data is sent:** only after an administrator clicks "Sure, I'd like to help" in the opt-in notice. It is sent once at that moment, then about once a day by WP-Cron (only the details that changed since the last report), and once more when the plugin is deactivated. If you click "No Thanks." or ignore the notice, nothing is ever sent. +* **What is sent:** your site URL and site name, WordPress version, site language and charset, PHP version, server software, whether the site is a multisite, text direction, the server path of the plugin, the lists of active and inactive plugins, the active theme and its version, the Disable Comments version, and the account email address of the administrator who clicks the opt-in button (used to send the discount coupon). On deactivation it sends the same details again, plus the account email address of the administrator who deactivates the plugin and the reason and any note they enter in the deactivation form. Like any web request, the connection also reveals your server's IP address to the service. +* **Where it is sent:** `https://send.wpinsight.com/process-plugin-data` +* **Terms and privacy:** [Terms and Conditions](https://wpdeveloper.com/terms-and-conditions/), [Privacy Policy](https://wpdeveloper.com/privacy-policy/) + +The "Still Seeing a Comment Form?" check on the plugin's settings page also requests pages of your own site to check what your theme outputs. Those requests go to your own site only and send nothing to a third party. + + == Screenshots == 1. Setting Screen for Disable Comments diff --git a/tests/smoke/scenario-external-services.php b/tests/smoke/scenario-external-services.php new file mode 100644 index 0000000..0df96f3 --- /dev/null +++ b/tests/smoke/scenario-external-services.php @@ -0,0 +1,163 @@ +tracker; +$slug = 'disable-comments'; + +dc_check( 'usage tracker is loaded', $tracker instanceof DisableComments_Plugin_Tracker ); +if ( ! $tracker instanceof DisableComments_Plugin_Tracker ) { + dc_smoke_finish( 'External services' ); + return; +} + +$GLOBALS['dc_outbound'] = array(); +add_filter( + 'pre_http_request', + function ( $pre, $args, $url ) { + $GLOBALS['dc_outbound'][] = $url; + $GLOBALS['dc_outbound_bodies'][] = isset( $args['body'] ) ? $args['body'] : null; + return array( + 'headers' => array(), + 'body' => wp_json_encode( array( 'siteId' => 'dc-smoke-site' ) ), + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'cookies' => array(), + ); + }, + 10, + 3 +); + +/** + * Forget any earlier send so the tracker takes its first-contact path, which + * is where the IP lookup used to happen. + * + * @param string $slug Tracker plugin slug. + */ +function dc_reset_tracker_state( $slug ) { + delete_option( "wpins_{$slug}_site_id" ); + delete_option( "wpins_{$slug}_original_url" ); + // The stub's siteId makes send_data() store the whole payload under it. + delete_option( "wpins_{$slug}_dc-smoke-site" ); + delete_option( "wpins_{$slug}_dc-smoke-site_send_failed" ); + delete_option( 'wpins_last_track_time' ); + delete_option( "wpins_deactivation_reason_{$slug}" ); + delete_option( "wpins_deactivation_details_{$slug}" ); + $GLOBALS['dc_outbound'] = array(); + $GLOBALS['dc_outbound_bodies'] = array(); +} + +$block_notice_before = get_option( 'wpins_block_notice' ); + +// A routable address, so the old guard against 127.0.0.1 would not skip it. +$_SERVER['REMOTE_ADDR'] = '203.0.113.7'; +wp_set_current_user( 1 ); + +// --------------------------------------------------------------------------- +echo "\n-- before opt-in --\n"; + +dc_reset_tracker_state( $slug ); +delete_option( 'wpins_allow_tracking' ); +$tracker->do_tracking( true ); +dc_check( 'nothing is sent without consent', array() === $GLOBALS['dc_outbound'] ); + +// --------------------------------------------------------------------------- +echo "\n-- after opt-in, first send --\n"; + +dc_reset_tracker_state( $slug ); +update_option( 'wpins_allow_tracking', array( $slug => $slug ) ); +$tracker->do_tracking( true ); + +$hosts = array_values( array_unique( array_map( 'wp_parse_url', $GLOBALS['dc_outbound'], array_fill( 0, count( $GLOBALS['dc_outbound'] ), PHP_URL_HOST ) ) ) ); +dc_check( 'the tracker sends its report', count( $GLOBALS['dc_outbound'] ) > 0 ); +dc_check( 'only send.wpinsight.com is contacted (got: ' . implode( ', ', $hosts ) . ')', array( 'send.wpinsight.com' ) === $hosts ); +dc_check( 'no IP lookup at ip-api.com', false === strpos( implode( ' ', $GLOBALS['dc_outbound'] ), 'ip-api.com' ) ); +foreach ( $GLOBALS['dc_outbound'] as $url ) { + dc_check( "request is HTTPS: $url", 0 === strpos( $url, 'https://' ) ); +} + +// --------------------------------------------------------------------------- +echo "\n-- the opt-in notice discloses what is sent and where --\n"; + +dc_reset_tracker_state( $slug ); +delete_option( 'wpins_block_notice' ); +ob_start(); +$tracker->notice(); +$notice = strtolower( wp_strip_all_tags( ob_get_clean() ) ); + +dc_check( 'notice is rendered for an administrator', '' !== trim( $notice ) ); +foreach ( array( 'send.wpinsight.com', 'site url', 'site name', 'email', 'php', 'server software', 'plugins', 'theme', 'text direction', 'deactivat', 'ip address' ) as $needle ) { + dc_check( "notice mentions \"$needle\"", false !== strpos( $notice, $needle ) ); +} + +// --------------------------------------------------------------------------- +echo "\n-- deactivation sends only with consent --\n"; + +dc_reset_tracker_state( $slug ); +delete_option( 'wpins_allow_tracking' ); +update_option( "wpins_deactivation_reason_{$slug}", 'smoke reason' ); +$tracker->deactivate_this_plugin(); +dc_check( 'deactivation sends nothing without consent', array() === $GLOBALS['dc_outbound'] ); + +dc_reset_tracker_state( $slug ); +update_option( 'wpins_allow_tracking', array( $slug => $slug ) ); +update_option( "wpins_deactivation_reason_{$slug}", 'smoke reason' ); +update_option( "wpins_deactivation_details_{$slug}", 'smoke note' ); +$tracker->deactivate_this_plugin(); +$sent = (array) end( $GLOBALS['dc_outbound_bodies'] ); +dc_check( 'deactivation report goes only to https://send.wpinsight.com', count( $GLOBALS['dc_outbound'] ) > 0 && array() === array_diff( $GLOBALS['dc_outbound'], array( DisableComments_Plugin_Tracker::API_URL ) ) ); +dc_check( 'deactivation report carries the reason and the note, as disclosed', isset( $sent['deactivation_reason'], $sent['deactivation_details'] ) ); +dc_check( 'deactivation report carries the deactivating admin\'s email, as disclosed', isset( $sent['email'] ) && wp_get_current_user()->user_email === $sent['email'] ); + +// --------------------------------------------------------------------------- +echo "\n-- shipped code has no plain-HTTP remote calls --\n"; + +$root = dirname( dirname( __DIR__ ) ); +$offences = array(); +$iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ) ); +foreach ( $iterator as $file ) { + $path = $file->getPathname(); + if ( ! preg_match( '/\.(php|js)$/', $path ) || preg_match( '#/(\.git|node_modules|vendor|tests|\.wp-sandbox|wp-sandbox[^/]*)/#', substr( $path, strlen( $root ) ) ) ) { + continue; + } + $code = file_get_contents( $path ); + if ( preg_match( '/(wp_remote_\w+|wp_safe_remote_\w+|fetch)\s*\(\s*[\'"]http:\/\//', $code ) || false !== strpos( $code, 'ip-api.com' ) ) { + $offences[] = substr( $path, strlen( $root ) + 1 ); + } +} +dc_check( 'no http:// remote call or ip-api.com reference (found: ' . implode( ', ', $offences ) . ')', array() === $offences ); + +// --------------------------------------------------------------------------- +echo "\n-- readme discloses the service --\n"; + +$readme = file_get_contents( $root . '/readme.txt' ); +dc_check( 'readme has an External services section', false !== strpos( $readme, '== External services ==' ) ); +$section = (string) substr( $readme, (int) strpos( $readme, '== External services ==' ) ); +$section = (string) preg_replace( '/\n== (?!External services).*$/s', '', $section ); +foreach ( array( 'send.wpinsight.com', 'Terms', 'Privacy' ) as $needle ) { + dc_check( "External services section mentions \"$needle\"", false !== strpos( $section, $needle ) ); +} + +dc_reset_tracker_state( $slug ); +delete_option( 'wpins_allow_tracking' ); +if ( false === $block_notice_before ) { + delete_option( 'wpins_block_notice' ); +} else { + update_option( 'wpins_block_notice', $block_notice_before ); +} +dc_smoke_finish( 'External services' );