Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bin/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion disable-comments.php
Original file line number Diff line number Diff line change
Expand Up @@ -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. <a href="%2$s" target="_blank" rel="noopener noreferrer">Privacy Policy</a>', 'disable-comments'),
'send.wpinsight.com',
esc_url('https://wpdeveloper.com/privacy-policy/')
),
));
$tracker->init();
}
Expand Down
8 changes: 0 additions & 8 deletions includes/class-plugin-usage-tracker.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 163 additions & 0 deletions tests/smoke/scenario-external-services.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php
/**
* External services disclosure (WordPress.org Guidelines 6 and 7, issue #164).
*
* The opt-in usage tracker is the only code path that contacts a third party.
* This scenario intercepts every outbound request it makes and holds it to
* what the opt-in notice and the readme promise: nothing before consent, and
* after consent only send.wpinsight.com, over HTTPS. It used to look up the
* requester's IP at ip-api.com over plain HTTP, which neither disclosed.
*
* @package Disable_Comments
*/

require_once __DIR__ . '/helpers.php';

$dc = Disable_Comments::get_instance();
$tracker = $dc->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' );
Loading