diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index c7562ee..af96cf9 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -17,25 +17,8 @@ jobs: uses: wordpress/plugin-check-action@v1 with: build-dir: "./rsscloud" - - phpcs: - name: Coding standards - runs-on: ubuntu-latest - steps: - - name: Check out source code - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - tools: cs2pr - - - name: Install Composer dependencies - run: composer install --no-interaction - - - name: Run PHPCS - run: vendor/bin/phpcs --standard=phpcs.xml.dist -q --report-checkstyle | cs2pr + exclude-checks: "plugin_header_fields,plugin_readme,late_escaping" + ignore-warnings: true phpunit: name: Run tests diff --git a/.wp-env.test.json b/.wp-env.test.json index da17cbb..ca39759 100644 --- a/.wp-env.test.json +++ b/.wp-env.test.json @@ -4,7 +4,7 @@ "port": 8889, "core": "WordPress/WordPress#7.0-branch", "plugins": ["./rsscloud"], - "phpVersion": "7.4", + "phpVersion": "8.2", "config": { "WP_DEBUG": false, "SCRIPT_DEBUG": false diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 1f1ceaf..cf043ac 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -2,7 +2,23 @@ Generally-applicable sniffs for WordPress plugins - + + + + + + + + + + + + + + + + + diff --git a/rsscloud/data-storage.php b/rsscloud/data-storage.php index 0a042dd..1cf7ff6 100644 --- a/rsscloud/data-storage.php +++ b/rsscloud/data-storage.php @@ -2,14 +2,13 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } - -if ( ! function_exists( 'rsscloud_get_hub_notifications' ) ) { - function rsscloud_get_hub_notifications() { +if ( !function_exists( 'rsscloud_get_hub_notifications' ) ) { + function rsscloud_get_hub_notifications( ) { return get_option( 'rsscloud_hub_notifications' ); } } -if ( ! function_exists( 'rsscloud_update_hub_notifications' ) ) { +if ( !function_exists( 'rsscloud_update_hub_notifications' ) ) { function rsscloud_update_hub_notifications( $notifications ) { return update_option( 'rsscloud_hub_notifications', (array) $notifications ); } diff --git a/rsscloud/notification-request.php b/rsscloud/notification-request.php index e61b539..bd3aeaa 100644 --- a/rsscloud/notification-request.php +++ b/rsscloud/notification-request.php @@ -2,123 +2,95 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } - -function rsscloud_hub_process_notification_request() { - // phpcs:disable WordPress.Security.NonceVerification.Missing -- Public RSSCloud hub endpoint, not a WP admin form. +function rsscloud_hub_process_notification_request( ) { // Get the current set of notifications - $notify = rsscloud_get_hub_notifications(); - if ( empty( $notify ) ) { - $notify = array(); - } + $notify = rsscloud_get_hub_notifications( ); + if ( empty( $notify ) ) + $notify = array( ); // Must provide at least one URL to get notifications about - if ( empty( $_POST['url1'] ) ) { + if ( empty( $_POST['url1'] ) ) rsscloud_notify_result( 'false', 'No feed for url1.' ); - } // Only support http-post $protocol = 'http-post'; - if ( ! empty( $_POST['protocol'] ) && strtolower( sanitize_text_field( wp_unslash( $_POST['protocol'] ) ) ) !== 'http-post' ) { + if ( !empty( $_POST['protocol'] ) && strtolower( sanitize_text_field( wp_unslash( $_POST['protocol'] ) ) ) !== 'http-post' ) { do_action( 'rsscloud_protocol_not_post' ); rsscloud_notify_result( 'false', 'Only http-post notifications are supported at this time.' ); } // Assume port 80 $port = 80; - if ( ! empty( $_POST['port'] ) ) { + if ( !empty( $_POST['port'] ) ) $port = (int) $_POST['port']; - } // Path is required - if ( empty( $_POST['path'] ) ) { + if ( empty( $_POST['path'] ) ) rsscloud_notify_result( 'false', 'No path provided.' ); - } $path = str_replace( '@', '', sanitize_text_field( wp_unslash( $_POST['path'] ) ) ); - if ( '/' !== $path[0] ) { + if ( $path[0] != '/' ) $path = '/' . $path; - } // Figure out what the blog and notification URLs are $rss2_url = get_bloginfo( 'rss2_url' ); - if ( defined( 'RSSCLOUD_FEED_URL' ) ) { + if ( defined( 'RSSCLOUD_FEED_URL' ) ) $rss2_url = RSSCLOUD_FEED_URL; - } - $remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; - $notify_url = $remote_addr . ':' . $port . $path; + if ( empty( $_POST['domain'] ) && empty( $_SERVER['REMOTE_ADDR'] ) ) + rsscloud_notify_result( 'false', 'No domain provided and REMOTE_ADDR is not available.' ); + + $notify_url = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) . ':' . $port . $path; - if ( ! empty( $_POST['domain'] ) ) { - $domain = str_replace( '@', '', sanitize_text_field( wp_unslash( $_POST['domain'] ) ) ); + if ( !empty( $_POST['domain'] ) ) { + $domain = str_replace( '@', '', sanitize_text_field( wp_unslash( $_POST['domain'] ) ) ); $notify_url = $domain . ':' . $port . $path; - if ( false === strpos( $notify_url, 'http://' ) ) { + if ( false === strpos( $notify_url, 'http://' ) ) $notify_url = 'http://' . $notify_url; - } - - $challenge = rsscloud_generate_challenge(); - - $result = wp_safe_remote_get( - $notify_url . '?url=' . esc_url( sanitize_url( wp_unslash( $_POST['url1'] ) ) ) . '&challenge=' . $challenge, - array( - 'method' => 'GET', - 'timeout' => RSSCLOUD_HTTP_TIMEOUT, - 'user-agent' => RSSCLOUD_USER_AGENT, - 'port' => $port, - ) - ); + + $challenge = rsscloud_generate_challenge( ); + + $result = wp_safe_remote_get( $notify_url . '?url=' . esc_url( wp_unslash( $_POST['url1'] ) ) . '&challenge=' . $challenge, array( 'method' => 'GET', 'timeout' => RSSCLOUD_HTTP_TIMEOUT, 'user-agent' => RSSCLOUD_USER_AGENT, 'port' => $port, ) ); } else { - if ( false === strpos( $notify_url, 'http://' ) ) { + if ( false === strpos( $notify_url, 'http://' ) ) $notify_url = 'http://' . $notify_url; - } - - $result = wp_safe_remote_post( - $notify_url, - array( - 'method' => 'POST', - 'timeout' => RSSCLOUD_HTTP_TIMEOUT, - 'user-agent' => RSSCLOUD_USER_AGENT, - 'port' => $port, - 'body' => array( 'url' => sanitize_url( wp_unslash( $_POST['url1'] ) ) ), - ) - ); - } - if ( isset( $result->errors['http_request_failed'][0] ) ) { - rsscloud_notify_result( 'false', 'Error testing notification URL : ' . $result->errors['http_request_failed'][0] ); + $result = wp_safe_remote_post( $notify_url, array( 'method' => 'POST', 'timeout' => RSSCLOUD_HTTP_TIMEOUT, 'user-agent' => RSSCLOUD_USER_AGENT, 'port' => $port, 'body' => array( 'url' => esc_url_raw( wp_unslash( $_POST['url1'] ) ) ) ) ); } + if ( is_wp_error( $result ) ) + rsscloud_notify_result( 'false', 'Error testing notification URL : ' . $result->get_error_message() ); + $status_code = (int) $result['response']['code']; - if ( $status_code < 200 || $status_code > 299 ) { + if ( $status_code < 200 || $status_code > 299 ) rsscloud_notify_result( 'false', 'Error testing notification URL. The URL returned HTTP status code: ' . $result['response']['code'] . ' - ' . $result['response']['message'] . '.' ); - } // challenge must match for domain requests - if ( ! empty( $_POST['domain'] ) ) { - if ( empty( $result['body'] ) || $result['body'] !== $challenge ) { + if ( !empty( $_POST['domain'] ) ) { + if ( empty( $result['body'] ) || $result['body'] != $challenge ) rsscloud_notify_result( 'false', 'The response body did not match the challenge string' ); - } + } // Passed all the tests, add this to the list of notifications for foreach ( $_POST as $key => $feed_url ) { - if ( ! preg_match( '|url\d+|', $key ) ) { + if ( !preg_match( '|url\d+|', $key ) ) continue; - } + + $feed_url = esc_url_raw( wp_unslash( $feed_url ) ); // Only allow requests for the RSS2 posts feed - if ( $feed_url !== $rss2_url ) { + if ( $feed_url != $rss2_url ) rsscloud_notify_result( 'false', "You can only request updates for {$rss2_url}" ); - } - $notify[ $feed_url ][ $notify_url ]['protocol'] = $protocol; - $notify[ $feed_url ][ $notify_url ]['status'] = 'active'; - $notify[ $feed_url ][ $notify_url ]['failure_count'] = 0; + $notify[$feed_url][$notify_url]['protocol'] = $protocol; + $notify[$feed_url][$notify_url]['status'] = 'active'; + $notify[$feed_url][$notify_url]['failure_count'] = 0; } do_action( 'rsscloud_add_notify_subscription' ); rsscloud_update_hub_notifications( $notify ); rsscloud_notify_result( 'true', 'Registration successful.' ); - // phpcs:enable WordPress.Security.NonceVerification.Missing } // function rsscloud_hub_notify diff --git a/rsscloud/readme.txt b/rsscloud/readme.txt index 74a28f5..e37d0b1 100644 --- a/rsscloud/readme.txt +++ b/rsscloud/readme.txt @@ -1,23 +1,18 @@ -=== RSS Cloud === +=== Plugin Name === Contributors: josephscott, automattic Tags: rss Requires at least: 2.8 -Tested up to: 7.0 -Stable tag: 0.5.1 -License: GPL-2.0-or-later -License URI: https://www.gnu.org/licenses/gpl-2.0.html +Tested up to: 6.1.1 +Stable tag: 0.5.0 -Adds RSSCloud ( http://rsscloud.co/ ) capabilities to your RSS feed. +Adds RSSCloud ( http://rsscloud.org/ ) capabilities to your RSS feed. == Description == -Adds RSSCloud ( http://rsscloud.co/ ) capabilities to your RSS feed. +Adds RSSCloud ( http://rsscloud.org/ ) capabilities to your RSS feed. == Changelog == -= 0.5.1 = -* Fix loose comparisons to use strict equality operators per WordPress coding standards - = 0.5.0 = * Updates to support PHP 8+ * Check for http scheme in the $notify_url, add it if missing diff --git a/rsscloud/rsscloud.php b/rsscloud/rsscloud.php index be8e0bd..bffe541 100644 --- a/rsscloud/rsscloud.php +++ b/rsscloud/rsscloud.php @@ -1,46 +1,38 @@ query_vars ) ) { - if ( 'notify' === $wp->query_vars['rsscloud'] ) { - rsscloud_hub_process_notification_request(); - } + if ( $wp->query_vars['rsscloud'] == 'notify' ) + rsscloud_hub_process_notification_request( ); exit; } } -if ( ! function_exists( 'rsscloud_notify_result' ) ) { - function rsscloud_notify_result( $success, $msg ) { - $success = esc_attr( ent2ncr( wp_strip_all_tags( $success ) ) ); - $msg = esc_attr( ent2ncr( wp_strip_all_tags( $msg ) ) ); +if ( !function_exists( 'rsscloud_notify_result' ) ) { +function rsscloud_notify_result( $success, $msg ) { + $success = strip_tags( $success ); + $success = ent2ncr( $success ); + $success = esc_html( $success ); - header( 'Content-Type: text/xml' ); - echo "\n"; - echo "\n"; - exit; - } + $msg = strip_tags( $msg ); + $msg = ent2ncr( $msg ); + $msg = esc_html( $msg ); + + header( 'Content-Type: text/xml' ); + echo "\n"; + echo "\n"; + exit; +} } add_action( 'rss2_head', 'rsscloud_add_rss_cloud_element' ); -function rsscloud_add_rss_cloud_element() { - if ( ! is_feed() ) { +function rsscloud_add_rss_cloud_element( ) { + if ( !is_feed() ) { return; } $cloud = parse_url( get_option( 'home' ) . '/?rsscloud=notify' ); - $cloud['port'] = isset( $cloud['port'] ) ? (int) $cloud['port'] : 0; - if ( empty( $cloud['port'] ) ) { - $cloud['port'] = 80; - } + $cloud['port'] = isset( $cloud['port'] ) ? (int) $cloud['port'] : 80; - $cloud['path'] .= "?{$cloud['query']}"; + $cloud['path'] .= "?{$cloud['query']}"; - $cloud['host'] = strtolower( $cloud['host'] ); + $cloud['host'] = strtolower( $cloud['host'] ); echo " $n ) { - if ( 'active' !== $n['status'] ) { + foreach ( $notify[$rss2_url] as $notify_url => $n ) { + if ( $n['status'] != 'active' ) continue; - } - if ( 'http-post' === $n['protocol'] ) { - $url = parse_url( $notify_url ); + if ( $n['protocol'] == 'http-post' ) { + $url = parse_url( $notify_url ); $port = 80; - if ( ! empty( $url['port'] ) ) { + if ( !empty( $url['port'] ) ) $port = $url['port']; - } - $result = wp_safe_remote_post( - $notify_url, - array( - 'method' => 'POST', - 'timeout' => RSSCLOUD_HTTP_TIMEOUT, - 'user-agent' => RSSCLOUD_USER_AGENT, - 'port' => $port, - 'body' => array( 'url' => $rss2_url ), - ) - ); + $result = wp_safe_remote_post( $notify_url, array( 'method' => 'POST', 'timeout' => RSSCLOUD_HTTP_TIMEOUT, 'user-agent' => RSSCLOUD_USER_AGENT, 'port' => $port, 'body' => array( 'url' => $rss2_url ) ) ); do_action( 'rsscloud_send_notification' ); - if ( ! is_wp_error( $result ) ) { + if ( !is_wp_error( $result ) ) $status_code = (int) $result['response']['code']; - } if ( is_wp_error( $result ) || ( $status_code < 200 || $status_code > 299 ) ) { do_action( 'rsscloud_notify_failure' ); - ++$notify[ $rss2_url ][ $notify_url ]['failure_count']; + $notify[$rss2_url][$notify_url]['failure_count']++; - if ( $notify[ $rss2_url ][ $notify_url ]['failure_count'] > RSSCLOUD_MAX_FAILURES ) { + if ( $notify[$rss2_url][$notify_url]['failure_count'] > RSSCLOUD_MAX_FAILURES ) { do_action( 'rsscloud_suspend_notification_url' ); - $notify[ $rss2_url ][ $notify_url ]['status'] = 'suspended'; + $notify[$rss2_url][$notify_url]['status'] = 'suspended'; } $need_update = true; - } elseif ( $notify[ $rss2_url ][ $notify_url ]['failure_count'] > 0 ) { + } elseif ( $notify[$rss2_url][$notify_url]['failure_count'] > 0 ) { do_action( 'rsscloud_reset_failure_count' ); - $notify[ $rss2_url ][ $notify_url ]['failure_count'] = 0; + $notify[$rss2_url][$notify_url]['failure_count'] = 0; $need_update = true; } } } // foreach - if ( $need_update ) { + if ( $need_update ) rsscloud_update_hub_notifications( $notify ); - } + } diff --git a/tests/test-notification-request.php b/tests/test-notification-request.php index 337cc09..26e18df 100644 --- a/tests/test-notification-request.php +++ b/tests/test-notification-request.php @@ -235,6 +235,29 @@ public function test_http_request_failure_returns_error() { $this->assertStringContainsString( 'Error testing notification URL', $result->msg ); } + public function test_non_standard_wp_error_returns_error() { + add_filter( + 'pre_http_request', + function () { + return new WP_Error( 'http_request_not_valid', 'A valid URL was not provided.' ); + }, + 10, + 3 + ); + + $_POST = array( + 'url1' => $this->feed_url, + 'protocol' => 'http-post', + 'port' => '80', + 'path' => '/rpc', + ); + + $result = $this->call_process_notification_request(); + + $this->assertSame( 'false', $result->success ); + $this->assertStringContainsString( 'Error', $result->msg ); + } + public function test_http_status_error_returns_error() { $this->mock_http_response( 500 ); @@ -324,6 +347,141 @@ function () use ( &$fired ) { $this->assertTrue( $fired ); } + public function test_post_data_is_unslashed() { + $this->mock_http_response( 200 ); + + // WordPress adds magic quotes to superglobals. A path containing + // an apostrophe like /subscriber's would be slashed to /subscriber\'s. + $_POST = array( + 'url1' => $this->feed_url, + 'protocol' => 'http-post', + 'port' => '80', + 'path' => "/subscriber's", + ); + + // Simulate WordPress magic quotes. + $_POST = wp_slash( $_POST ); + + $result = $this->call_process_notification_request(); + + $this->assertSame( 'true', $result->success ); + + $notify = rsscloud_get_hub_notifications(); + $sub_urls = array_keys( $notify[ $this->feed_url ] ); + $notify_url = $sub_urls[0]; + + // After proper wp_unslash(), the backslash before the apostrophe + // added by magic quotes should be removed. + $this->assertStringNotContainsString( "\\'", $notify_url, + 'Path should be unslashed to remove magic quote artifacts' ); + $this->assertStringContainsString( "subscriber's", $notify_url, + 'Path should preserve the original apostrophe' ); + } + + public function test_missing_remote_addr_handles_gracefully() { + $this->mock_http_response( 200 ); + + unset( $_SERVER['REMOTE_ADDR'] ); + + $_POST = array( + 'url1' => $this->feed_url, + 'protocol' => 'http-post', + 'port' => '80', + 'path' => '/rpc', + ); + + $result = $this->call_process_notification_request(); + + $this->assertSame( 'false', $result->success, + 'Should reject registration when REMOTE_ADDR is not available' ); + } + + public function test_empty_remote_addr_does_not_create_broken_notify_url() { + $this->mock_http_response( 200 ); + + $_SERVER['REMOTE_ADDR'] = ''; + + $_POST = array( + 'url1' => $this->feed_url, + 'protocol' => 'http-post', + 'port' => '80', + 'path' => '/rpc', + ); + + $result = $this->call_process_notification_request(); + + $this->assertSame( 'false', $result->success, + 'Should reject registration when REMOTE_ADDR is empty' ); + } + + public function test_url1_is_unslashed_before_feed_comparison() { + $this->mock_http_response( 200 ); + + // Use a feed URL containing an apostrophe. + $custom_feed = "http://example.org/?feed=rss2&author=O'Brien"; + add_filter( + 'feed_link', + function ( $output, $feed ) use ( $custom_feed ) { + if ( 'rss2' === $feed ) { + return $custom_feed; + } + return $output; + }, + 10, + 2 + ); + + $_POST = array( + 'url1' => $custom_feed, + 'protocol' => 'http-post', + 'port' => '80', + 'path' => '/rpc', + ); + + // Simulate WordPress magic quotes — apostrophe gets backslash-escaped. + $_POST = wp_slash( $_POST ); + + $result = $this->call_process_notification_request(); + + // Without wp_unslash, the slashed url1 won't match $rss2_url + // and the function returns "You can only request updates for...". + $this->assertSame( 'true', $result->success, + 'url1 should be unslashed before comparing to feed URL' ); + } + + public function test_url1_sent_to_subscriber_is_unslashed() { + $this->mock_http_response( 200 ); + + $custom_feed = "http://example.org/?feed=rss2&author=O'Brien"; + add_filter( + 'feed_link', + function ( $output, $feed ) use ( $custom_feed ) { + if ( 'rss2' === $feed ) { + return $custom_feed; + } + return $output; + }, + 10, + 2 + ); + + $_POST = array( + 'url1' => $custom_feed, + 'protocol' => 'http-post', + 'port' => '80', + 'path' => '/rpc', + ); + + $_POST = wp_slash( $_POST ); + + $this->call_process_notification_request(); + + // The URL sent to the subscriber callback should be the clean URL, + // not the magic-quoted version with backslashes. + $this->assertSame( $custom_feed, $this->http_requests[0]['args']['body']['url'], + 'Subscriber should receive the unslashed feed URL' ); + } + public function test_http_post_protocol_is_accepted() { $this->mock_http_response( 200 ); diff --git a/tests/test-rsscloud.php b/tests/test-rsscloud.php index 724634c..5ca46a1 100644 --- a/tests/test-rsscloud.php +++ b/tests/test-rsscloud.php @@ -76,6 +76,28 @@ public function test_add_rss_cloud_element_uses_port_from_home_url() { $this->assertStringContainsString( "port='" . $port . "'", $output ); } + public function test_add_rss_cloud_element_escapes_attribute_values() { + $this->go_to( get_feed_link( 'rss2' ) ); + + // Override home AFTER go_to so is_feed() still works. + add_filter( + 'option_home', + function () { + return 'http://example.com:8080/path?a=1&b=2'; + } + ); + + ob_start(); + rsscloud_add_rss_cloud_element(); + $output = ob_get_clean(); + + // With proper escaping, '&' in attribute values should become '&'. + $this->assertStringNotContainsString( '&b=2', $output, + 'Attribute values must be escaped; raw & should not appear' ); + $this->assertStringContainsString( '&', $output, + 'Attribute values should use esc_attr() which converts & to &' ); + } + public function test_parse_request_does_nothing_without_rsscloud_var() { $wp = new stdClass(); $wp->query_vars = array( 'p' => '1' );