From 00513aaf18ec06509a399067c9a20ed3c6a161a6 Mon Sep 17 00:00:00 2001 From: thisismyurl <122108986+thisismyurl@users.noreply.github.com> Date: Thu, 28 May 2026 18:59:30 -0400 Subject: [PATCH 1/5] Add WP 7 Abilities API support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register a read-only ability, thisismyurl-external-link-control/scan-external-links, that exposes the broken-link checker's most recent scan to AI agents and REST clients. The ability reads stored scan results only — it never triggers a new scan, so it returns instantly and makes no outbound HTTP requests of its own. - New includes/abilities.php registers the ability on wp_abilities_api_init, guarded by function_exists( 'wp_register_ability' ) for pre-6.9 WordPress. Category 'site'; annotations readonly/idempotent true, destructive false; show_in_rest true. permission_callback requires manage_options, matching the cap used by the checker's admin notice, dashboard widget, AJAX handlers, and the plugin's existing REST inventory route. - Input schema: optional post_id, status (broken|unverified|all), limit. - Output schema: links[] of { url, post_id, post_ids, status_code, message, is_broken, bucket } plus a summary with broken/unverified/total/returned/ urls_checked/checked_at/has_run counts. - Extract ELC_Link_Checker::get_results() as the single shaping path for the stored RESULTS_OPTION; the ability calls it. (Follow-up: the dashboard widget should be migrated onto the same accessor so there is one read path.) - readme.txt: changelog entry under 1.6149. Version header left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- includes/abilities.php | 229 ++++++++++++++++++++++++++ includes/class-elc-link-checker.php | 70 ++++++++ readme.txt | 3 + thisismyurl-external-link-control.php | 1 + 4 files changed, 303 insertions(+) create mode 100644 includes/abilities.php diff --git a/includes/abilities.php b/includes/abilities.php new file mode 100644 index 0000000..6a5ffd7 --- /dev/null +++ b/includes/abilities.php @@ -0,0 +1,229 @@ + __( 'Scan external links', 'thisismyurl-external-link-control' ), + 'description' => __( 'Returns the most recent external-link scan: every broken or unverifiable link the weekly checker found, with its HTTP status and the posts it appears on. Reads stored results only — it reports the last scan\'s state, it does not start a new scan. Optionally filter to one post or to a single status bucket.', 'thisismyurl-external-link-control' ), + 'category' => 'site', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Optional: return only links that appear on this post ID.', 'thisismyurl-external-link-control' ), + ), + 'status' => array( + 'type' => 'string', + 'enum' => array( 'broken', 'unverified', 'all' ), + 'description' => __( 'Optional: filter by verdict. "broken" = gone (404/410) or dead host; "unverified" = answered but blocked the check (bot wall, rate limit, login, server error); "all" = both. Defaults to "all". Note: links that returned OK are not retained by the checker, so they never appear here.', 'thisismyurl-external-link-control' ), + ), + 'limit' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 500, + 'description' => __( 'Optional: maximum number of links to return. Defaults to 200. The summary counts always reflect the full result set, not the truncated list.', 'thisismyurl-external-link-control' ), + ), + ), + 'additionalProperties' => false, + 'default' => array(), + ), + 'output_schema' => array( + 'type' => 'object', + 'required' => array( 'links', 'summary' ), + 'properties' => array( + 'links' => array( + 'type' => 'array', + 'description' => __( 'The links the last scan flagged, filtered by the input arguments.', 'thisismyurl-external-link-control' ), + 'items' => array( + 'type' => 'object', + 'required' => array( 'url', 'post_id', 'status_code', 'is_broken', 'bucket' ), + 'properties' => array( + 'url' => array( + 'type' => 'string', + 'description' => __( 'The external URL that was checked (lowercased, query/fragment stripped).', 'thisismyurl-external-link-control' ), + ), + 'post_id' => array( + 'type' => array( 'integer', 'null' ), + 'description' => __( 'The first post ID the URL appears on, or null if no post is recorded. See post_ids for the full list.', 'thisismyurl-external-link-control' ), + ), + 'post_ids' => array( + 'type' => 'array', + 'description' => __( 'Every post ID the URL appears on.', 'thisismyurl-external-link-control' ), + 'items' => array( 'type' => 'integer' ), + ), + 'status_code' => array( + 'type' => 'integer', + 'description' => __( 'The HTTP status code from the last check, or 0 when the request errored before a status was returned (e.g. DNS failure, timeout).', 'thisismyurl-external-link-control' ), + ), + 'message' => array( + 'type' => 'string', + 'description' => __( 'The checker\'s human-readable note for this link (HTTP reason phrase or the network error message).', 'thisismyurl-external-link-control' ), + ), + 'is_broken' => array( + 'type' => 'boolean', + 'description' => __( 'True when the link is genuinely gone (404/410 or a dead host). False for unverifiable links that merely blocked the check.', 'thisismyurl-external-link-control' ), + ), + 'bucket' => array( + 'type' => 'string', + 'enum' => array( 'broken', 'unverified' ), + 'description' => __( 'Which bucket the checker placed this link in.', 'thisismyurl-external-link-control' ), + ), + ), + 'additionalProperties' => false, + ), + ), + 'summary' => array( + 'type' => 'object', + 'required' => array( 'broken', 'unverified', 'total', 'returned', 'urls_checked', 'has_run' ), + 'description' => __( 'Counts and timing for the last scan. Counts reflect the full result set, independent of the post_id/status/limit filters.', 'thisismyurl-external-link-control' ), + 'properties' => array( + 'broken' => array( + 'type' => 'integer', + 'description' => __( 'Number of broken links found in the last scan.', 'thisismyurl-external-link-control' ), + ), + 'unverified' => array( + 'type' => 'integer', + 'description' => __( 'Number of unverifiable links found in the last scan.', 'thisismyurl-external-link-control' ), + ), + 'total' => array( + 'type' => 'integer', + 'description' => __( 'Broken plus unverified — every problem link the last scan retained.', 'thisismyurl-external-link-control' ), + ), + 'returned' => array( + 'type' => 'integer', + 'description' => __( 'Number of links in this response after the post_id/status/limit filters.', 'thisismyurl-external-link-control' ), + ), + 'urls_checked' => array( + 'type' => 'integer', + 'description' => __( 'Total external URLs the last scan checked.', 'thisismyurl-external-link-control' ), + ), + 'checked_at' => array( + 'type' => array( 'integer', 'null' ), + 'description' => __( 'Unix timestamp of the last scan, or null if no scan has run yet.', 'thisismyurl-external-link-control' ), + ), + 'has_run' => array( + 'type' => 'boolean', + 'description' => __( 'False when no scan has ever run (the weekly cron has not fired and no manual scan was triggered).', 'thisismyurl-external-link-control' ), + ), + ), + 'additionalProperties' => false, + ), + ), + 'additionalProperties' => false, + ), + 'execute_callback' => static function ( $input = array() ) { + if ( ! class_exists( 'ELC_Link_Checker' ) ) { + return new WP_Error( + 'timu_elc_unavailable', + __( 'The External Link Control link checker is not available.', 'thisismyurl-external-link-control' ), + array( 'status' => 500 ) + ); + } + + $input = is_array( $input ) ? $input : array(); + + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + $status = isset( $input['status'] ) ? (string) $input['status'] : 'all'; + if ( ! in_array( $status, array( 'broken', 'unverified', 'all' ), true ) ) { + $status = 'all'; + } + $limit = isset( $input['limit'] ) ? (int) $input['limit'] : 200; + $limit = max( 1, min( 500, $limit ) ); + + // Single source of truth for the last scan's stored results. + $checker = new ELC_Link_Checker(); + $report = $checker->get_results(); + + $links = $report['links']; + + if ( 'all' !== $status ) { + $links = array_values( + array_filter( + $links, + static function ( $link ) use ( $status ) { + return $status === $link['bucket']; + } + ) + ); + } + + if ( $post_id > 0 ) { + $links = array_values( + array_filter( + $links, + static function ( $link ) use ( $post_id ) { + return in_array( $post_id, $link['post_ids'], true ); + } + ) + ); + } + + $total_after_filter = count( $links ); + if ( count( $links ) > $limit ) { + $links = array_slice( $links, 0, $limit ); + } + + // Surface the first post ID alongside the full list, so simple + // callers get a usable scalar without indexing into post_ids. + $links = array_map( + static function ( $link ) { + $link['post_id'] = ! empty( $link['post_ids'] ) ? (int) $link['post_ids'][0] : null; + return $link; + }, + $links + ); + + return array( + 'links' => $links, + 'summary' => array( + 'broken' => (int) $report['broken_count'], + 'unverified' => (int) $report['unverified_count'], + 'total' => (int) $report['broken_count'] + (int) $report['unverified_count'], + 'returned' => count( $links ), + 'urls_checked' => (int) $report['checked'], + 'checked_at' => $report['checked_at'] > 0 ? (int) $report['checked_at'] : null, + 'has_run' => $report['checked_at'] > 0, + ), + ); + }, + 'permission_callback' => static function (): bool { + // Mirrors the cap the checker's admin notice, dashboard widget, + // AJAX handlers, and the plugin's REST inventory route all use. + return current_user_can( 'manage_options' ); + }, + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } +); diff --git a/includes/class-elc-link-checker.php b/includes/class-elc-link-checker.php index 5fc2c3d..90ff137 100644 --- a/includes/class-elc-link-checker.php +++ b/includes/class-elc-link-checker.php @@ -371,6 +371,76 @@ private function collect_external_urls() { return $all_urls; } + /** + * Return the most recent scan's stored results in a flat, read-only shape. + * + * This is the single source of truth for "what did the last broken-link + * scan find" — the dashboard widget, and the WP 7 Abilities API report, + * both read through here so there is one shaping path. It performs no + * outbound requests; it reads the stored RESULTS_OPTION and flattens the + * `broken` / `unverified` URL maps into a list of link rows. + * + * Each row carries the URL, its bucket (broken|unverified), the last HTTP + * status (0 when the request errored before a status was returned), the + * checker's human-readable message, the post IDs the URL appears on, and a + * derived `is_broken` boolean for callers that only care about the verdict. + * + * @since 1.6149 + * + * @return array{ + * checked_at: int, + * checked: int, + * broken_count: int, + * unverified_count: int, + * links: array + * } + */ + public function get_results() { + $results = get_option( self::RESULTS_OPTION ); + if ( ! is_array( $results ) ) { + return array( + 'checked_at' => 0, + 'checked' => 0, + 'broken_count' => 0, + 'unverified_count' => 0, + 'links' => array(), + ); + } + + $links = array(); + + foreach ( array( 'broken', 'unverified' ) as $bucket ) { + if ( empty( $results[ $bucket ] ) || ! is_array( $results[ $bucket ] ) ) { + continue; + } + foreach ( $results[ $bucket ] as $url => $info ) { + $info = is_array( $info ) ? $info : array(); + $post_ids = isset( $info['post_ids'] ) && is_array( $info['post_ids'] ) + ? array_values( array_map( 'intval', $info['post_ids'] ) ) + : array(); + $links[] = array( + 'url' => (string) $url, + 'bucket' => $bucket, + 'status_code' => isset( $info['status'] ) ? (int) $info['status'] : 0, + 'message' => isset( $info['message'] ) ? (string) $info['message'] : '', + 'post_ids' => $post_ids, + 'is_broken' => ( 'broken' === $bucket ), + ); + } + } + + $broken_count = isset( $results['broken'] ) && is_array( $results['broken'] ) ? count( $results['broken'] ) : 0; + $unverified_count = isset( $results['unverified'] ) && is_array( $results['unverified'] ) ? count( $results['unverified'] ) : 0; + + return array( + 'checked_at' => isset( $results['checked_at'] ) ? (int) $results['checked_at'] : 0, + 'checked' => isset( $results['checked'] ) ? (int) $results['checked'] : 0, + 'broken_count' => $broken_count, + 'unverified_count' => $unverified_count, + 'links' => $links, + ); + } + /* --------------------------------------------------------------------- * The ignore list * ------------------------------------------------------------------- */ diff --git a/readme.txt b/readme.txt index 9d9045f..859c778 100644 --- a/readme.txt +++ b/readme.txt @@ -88,6 +88,9 @@ I review PRs thoughtfully and appreciate well-tested contributions. Contributing == Changelog == += 1.6149 = +* New: WordPress 7.0 Abilities API support. The plugin registers a read-only ability, `thisismyurl-external-link-control/scan-external-links`, that returns the most recent broken-link scan — every broken or unverifiable link, its HTTP status, and the posts it appears on — for AI agents and REST clients. It reads stored results only and never starts a new scan, so it is instant and makes no outbound requests. Filter by post ID or status. Requires the `manage_options` capability. + = 1.6148 = * Fix: the broken-link admin notice now stays dismissed. Earlier versions shipped no JavaScript, so WordPress's dismiss "X" only hid the notice for that page load and never told the server — it reappeared on the next screen. Dismissing now records which links were dismissed; the notice only returns when a later scan finds a link that was not in the dismissed set. * Fix: far fewer false positives. A 401, 403, 405, 429, 451, or 999 response (login walls, bot protection, rate limits, servers that reject HEAD) is no longer reported as broken — these mean "the link exists, I just won't let an automated checker confirm it." Only 404, 410, and dead domains (a host that no longer resolves) count as broken. HEAD requests that fail now retry once with GET before any verdict, so HEAD-hostile servers stop showing up as broken. diff --git a/thisismyurl-external-link-control.php b/thisismyurl-external-link-control.php index 4bc2c75..9b06dc5 100644 --- a/thisismyurl-external-link-control.php +++ b/thisismyurl-external-link-control.php @@ -25,6 +25,7 @@ require_once plugin_dir_path( __FILE__ ) . 'includes/class-elc-link-processor.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-elc-rest.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-elc-link-checker.php'; +require_once plugin_dir_path( __FILE__ ) . 'includes/abilities.php'; if ( defined( 'WP_CLI' ) && WP_CLI ) { require_once plugin_dir_path( __FILE__ ) . 'includes/class-elc-cli.php'; From cb0da00b847fdb512e835f9cd26c476ca76c2bef Mon Sep 17 00:00:00 2001 From: thisismyurl <122108986+thisismyurl@users.noreply.github.com> Date: Thu, 28 May 2026 20:36:18 -0400 Subject: [PATCH 2/5] Harden ELC: fix authenticated SSRF, swap to hardened updater, soften activation defaults Fixes the audited findings on thisismyurl-external-link-control: - SECURITY (authenticated SSRF in ajax_recheck_link): sanitize the URL with esc_url_raw; require it to be a member of the current broken/unverified result set before fetching; reject hosts that resolve to non-public addresses (loopback, 169.254/16 cloud-metadata, RFC-1918, ::1, fe80::/10, fc00::/7) before any wp_remote_*. manage_options + nonce gate preserved. - Updater: wire the hardened TIMU_GitHub_Release_Updater (guarded after_install, timeout + UA, 200-check, 6h transient cache) via timu_boot_github_release_updater with the repo slug set; delete the buggy FWO_GitHub_Updater (updater.php) and its dead .distignore line. - P2 activation default surprise: master switch now ships OFF so activating no longer silently rewrites every external link sitewide (opt-in); readme discloses the weekly outbound crawler cron. - P2 uninstall incomplete: also delete timu_elc_domain_rules, timu_elc_broken_link_results, timu_elc_broken_link_ignored, and clear the timu_elc_broken_link_check cron event. - P1/P2 promise under-disclosure: readme Description now discloses the weekly outbound link-crawler and the /timu-elc/v1/inventory REST endpoint. - P2 perf: collect_external_urls() now queries fields=ids with no_found_rows=true (and skips meta/term cache priming), fetching content per-ID instead of pulling full post objects while paging. - P3: changelog entry 1.6150; Version header left unchanged per contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- .distignore | 3 +- includes/class-elc-link-checker.php | 191 +++++++++++++++++++++++--- readme.txt | 31 +++++ thisismyurl-external-link-control.php | 49 ++++--- uninstall.php | 23 ++-- updater.php | 89 ------------ 6 files changed, 251 insertions(+), 135 deletions(-) delete mode 100644 updater.php diff --git a/.distignore b/.distignore index 1508b86..0d33f92 100644 --- a/.distignore +++ b/.distignore @@ -17,9 +17,8 @@ CONTRIBUTING.md SECURITY.md PULL_REQUEST_TEMPLATE.md -# GitHub-only updaters — never ship to .org. +# GitHub-only updater — never ship to .org. github-updater.php -updater.php # Tooling artefacts that occasionally land in the working tree. node_modules diff --git a/includes/class-elc-link-checker.php b/includes/class-elc-link-checker.php index 90ff137..48c2616 100644 --- a/includes/class-elc-link-checker.php +++ b/includes/class-elc-link-checker.php @@ -307,27 +307,33 @@ private function collect_external_urls() { $done = false; while ( ! $done && count( $all_urls ) < self::MAX_URLS_PER_RUN ) { - $query = new WP_Query( + $per_page = 100; + $query = new WP_Query( array( - 'post_type' => 'any', - 'post_status' => 'publish', - 'posts_per_page' => 100, - 'paged' => $paged, - 'no_found_rows' => false, - 'fields' => 'all', + 'post_type' => 'any', + 'post_status' => 'publish', + 'posts_per_page' => $per_page, + 'paged' => $paged, + 'no_found_rows' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'fields' => 'ids', ) ); - if ( ! $query->have_posts() ) { + $post_ids = $query->posts; + if ( empty( $post_ids ) ) { break; } - foreach ( $query->posts as $post ) { - if ( ! $post instanceof WP_Post ) { + foreach ( $post_ids as $post_id ) { + $post_id = (int) $post_id; + $post_content = (string) get_post_field( 'post_content', $post_id ); + if ( '' === $post_content ) { continue; } - preg_match_all( '/href=["\'](?Phttps?:\/\/[^"\'>\s]+)["\']/', $post->post_content, $matches ); + preg_match_all( '/href=["\'](?Phttps?:\/\/[^"\'>\s]+)["\']/', $post_content, $matches ); foreach ( $matches['url'] as $raw_url ) { $parsed_host = wp_parse_url( $raw_url, PHP_URL_HOST ); @@ -350,8 +356,8 @@ private function collect_external_urls() { $all_urls[ $normalised ] = array(); } - if ( ! in_array( (int) $post->ID, $all_urls[ $normalised ], true ) ) { - $all_urls[ $normalised ][] = (int) $post->ID; + if ( ! in_array( $post_id, $all_urls[ $normalised ], true ) ) { + $all_urls[ $normalised ][] = $post_id; } if ( count( $all_urls ) >= self::MAX_URLS_PER_RUN ) { @@ -361,7 +367,9 @@ private function collect_external_urls() { } } - if ( $paged >= $query->max_num_pages ) { + // With no_found_rows there is no max_num_pages; a short page is the + // last page. + if ( count( $post_ids ) < $per_page ) { $done = true; } @@ -828,24 +836,159 @@ private function verify_request() { } /** - * Read and normalise the `url` parameter from the request. + * Read, sanitise, and normalise the `url` parameter from the request. * * @return string Normalised URL/host, or '' if absent. */ private function request_url() { - $raw = isset( $_POST['url'] ) ? wp_unslash( $_POST['url'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitized - $raw = is_string( $raw ) ? trim( $raw ) : ''; + $raw = isset( $_POST['url'] ) ? sanitize_text_field( wp_unslash( $_POST['url'] ) ) : ''; + $raw = trim( $raw ); if ( '' === $raw ) { return ''; } // URLs are stored lowercased + query-stripped; mirror that here. $lower = strtolower( $raw ); if ( 0 === strpos( $lower, 'http://' ) || 0 === strpos( $lower, 'https://' ) ) { - return (string) preg_replace( '/[?#].*$/', '', $lower ); + $clean = esc_url_raw( $lower, array( 'http', 'https' ) ); + return (string) preg_replace( '/[?#].*$/', '', $clean ); } return rtrim( $lower, '/' ); } + /** + * Is the normalised URL a member of the current stored result set? + * + * The recheck action only fetches URLs the scan already surfaced as broken + * or unverified — never an arbitrary attacker-supplied URL. This is the + * primary SSRF guard: the request can only re-probe a link the site itself + * already chose to track. + * + * @param string $url Normalised URL. + * @return bool + */ + private function url_in_results( $url ) { + if ( '' === $url ) { + return false; + } + + $results = get_option( self::RESULTS_OPTION ); + if ( ! is_array( $results ) ) { + return false; + } + + foreach ( array( 'broken', 'unverified' ) as $bucket ) { + if ( isset( $results[ $bucket ][ $url ] ) ) { + return true; + } + } + + return false; + } + + /** + * Is the URL a public http(s) URL safe to fetch server-side? + * + * Rejects anything whose host resolves to a loopback, link-local, + * cloud-metadata, or RFC-1918 private address — the address ranges an + * SSRF probe would target to reach internal services. A host that does + * not resolve is also rejected (nothing to fetch). This is the second SSRF + * guard, defending against a stored result whose host later re-points at + * an internal address, and against IP-literal hosts. + * + * @param string $url Normalised URL. + * @return bool + */ + private function is_public_http_url( $url ) { + $parts = wp_parse_url( $url ); + if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { + return false; + } + + if ( ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) { + return false; + } + + $host = strtolower( $parts['host'] ); + + // Bracketed IPv6 literal: strip the brackets before inspection. + if ( '[' === substr( $host, 0, 1 ) && ']' === substr( $host, -1 ) ) { + $host = substr( $host, 1, -1 ); + } + + // Resolve hostnames to the addresses we would actually connect to; + // IP literals are checked directly. + $addresses = filter_var( $host, FILTER_VALIDATE_IP ) + ? array( $host ) + : $this->resolve_host( $host ); + + if ( empty( $addresses ) ) { + return false; + } + + foreach ( $addresses as $address ) { + if ( ! $this->is_public_ip( $address ) ) { + return false; + } + } + + return true; + } + + /** + * Resolve a hostname to the set of IP addresses it points at (v4 + v6). + * + * @param string $host Hostname (no brackets, no scheme). + * @return string[] IP addresses, or empty array when resolution fails. + */ + private function resolve_host( $host ) { + $addresses = array(); + + $records = function_exists( 'dns_get_record' ) + ? @dns_get_record( $host, DNS_A | DNS_AAAA ) // phpcs:ignore WordPress.PHP.NoSilencedErrors + : false; + + if ( is_array( $records ) ) { + foreach ( $records as $record ) { + if ( ! empty( $record['ip'] ) ) { + $addresses[] = $record['ip']; + } elseif ( ! empty( $record['ipv6'] ) ) { + $addresses[] = $record['ipv6']; + } + } + } + + // Fall back to gethostbyname for IPv4-only resolvers; a host that + // resolves to nothing usable returns the input unchanged, which + // FILTER_VALIDATE_IP then rejects. + if ( empty( $addresses ) && function_exists( 'gethostbyname' ) ) { + $v4 = gethostbyname( $host ); + if ( $v4 !== $host && filter_var( $v4, FILTER_VALIDATE_IP ) ) { + $addresses[] = $v4; + } + } + + return $addresses; + } + + /** + * Is the address a public, routable IP — not loopback, link-local, + * cloud-metadata, or RFC-1918 / unique-local private space? + * + * @param string $address IPv4 or IPv6 address literal. + * @return bool + */ + private function is_public_ip( $address ) { + // FILTER_FLAG_NO_PRIV_RANGE covers 10/8, 172.16/12, 192.168/16, fc00::/7. + // FILTER_FLAG_NO_RES_RANGE covers loopback (127/8, ::1), link-local + // (169.254/16 incl. the 169.254.169.254 cloud-metadata address, fe80::/10), + // and other reserved ranges. + return false !== filter_var( + $address, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ); + } + /** * AJAX: dismiss the notice by fingerprinting the current broken set. */ @@ -943,6 +1086,18 @@ public function ajax_recheck_link() { wp_send_json_error( array( 'message' => __( 'No URL supplied.', 'thisismyurl-external-link-control' ) ) ); } + // SSRF guard 1: only re-probe a URL the last scan already surfaced. + // An arbitrary attacker-supplied URL is never fetched. + if ( ! $this->url_in_results( $url ) ) { + wp_send_json_error( array( 'message' => __( 'That URL is not in the current scan results.', 'thisismyurl-external-link-control' ) ), 400 ); + } + + // SSRF guard 2: refuse hosts that resolve to non-public addresses + // (loopback, link-local / cloud-metadata, RFC-1918 private space). + if ( ! $this->is_public_http_url( $url ) ) { + wp_send_json_error( array( 'message' => __( 'That URL points at a non-public address and was not checked.', 'thisismyurl-external-link-control' ) ), 400 ); + } + $result = $this->check_url( $url ); // Move the URL into its correct bucket in the stored results. diff --git a/readme.txt b/readme.txt index 859c778..749f2f7 100644 --- a/readme.txt +++ b/readme.txt @@ -22,6 +22,29 @@ The plugin currently supports: * Adding `rel="nofollow noopener noreferrer"` to external links * Leaving database content untouched by modifying links only during output +**On activation, link filtering is OFF.** The master switch ships disabled so +activating the plugin does not silently rewrite every external link on your +site. Turn it on from Tools > Link Control when you are ready. + += Outbound network activity (please read) = + +This plugin makes outbound network requests on your site's behalf. Two +features reach the public internet, and you should know about them before you +activate: + +* **Weekly broken-link crawler.** On activation the plugin schedules a weekly + WP-Cron job (`timu_elc_broken_link_check`) that scans your published post + content for external links and issues HTTP HEAD/GET requests to each one + (up to 200 per run) to find broken links. Results appear in a dashboard + widget. You can run it on demand with "Scan now", mute links with the ignore + list, and it never modifies your content. Deactivating the plugin unschedules + the job. +* **REST endpoint `GET /wp-json/timu-elc/v1/inventory`.** A capability-gated + (`manage_options`) read-only endpoint that reports every external domain + found in your content with link counts. It runs no outbound requests of its + own — it reads your stored post content only — but it is part of the plugin's + surface and is documented here for transparency. + == How It Works == The plugin filters post content with `the_content` and updates matching external links during rendering. @@ -88,6 +111,14 @@ I review PRs thoughtfully and appreciate well-tested contributions. Contributing == Changelog == += 1.6150 = +* Security: hardened the dashboard widget's "Recheck" action against server-side request forgery (SSRF). A recheck now only re-probes a URL the most recent scan already surfaced as broken or unverified, and refuses any host that resolves to a non-public address (loopback, link-local / cloud-metadata `169.254.169.254`, or RFC-1918 private space). The existing capability + nonce gate is unchanged. +* Security: swapped the GitHub update checker to the hardened release updater — the API request now carries a User-Agent and a timeout, checks for an HTTP 200 before trusting the response, caches results for six hours, and scopes its post-install folder move to this plugin only. +* Change: link filtering now ships OFF on activation. Activating the plugin no longer silently rewrites every external link sitewide; turn the master switch on from Tools > Link Control when you are ready. The new-tab / nofollow / UGC defaults are pre-set so the behaviour is in place the moment you enable filtering. +* Fix: uninstall now removes the per-domain rules, broken-link scan results, and ignore-list options, and clears the weekly broken-link cron event, instead of leaving them behind. +* Docs: the readme now discloses the weekly outbound broken-link crawler and the `/timu-elc/v1/inventory` REST endpoint so site owners know the plugin makes scheduled outbound network requests. +* Performance: the broken-link scan now queries only post IDs (deferring content fetch and skipping meta/term cache priming and found-rows counting) instead of pulling full post objects while paging. + = 1.6149 = * New: WordPress 7.0 Abilities API support. The plugin registers a read-only ability, `thisismyurl-external-link-control/scan-external-links`, that returns the most recent broken-link scan — every broken or unverifiable link, its HTTP status, and the posts it appears on — for AI agents and REST clients. It reads stored results only and never starts a new scan, so it is instant and makes no outbound requests. Filter by post ID or status. Requires the `manage_options` capability. diff --git a/thisismyurl-external-link-control.php b/thisismyurl-external-link-control.php index 9b06dc5..380ebbe 100644 --- a/thisismyurl-external-link-control.php +++ b/thisismyurl-external-link-control.php @@ -88,13 +88,23 @@ public function __construct() { } /** - * Activate Plugin Defaults: - * Sets Master Switch, New Tab, and Nofollow to '1' by default. + * Activate Plugin Defaults. + * + * Least-surprise on activation: the master switch ships OFF, so activating + * the plugin does NOT silently rewrite every external link sitewide. The + * owner opts in from Tools > Link Control. The new-tab / nofollow / UGC + * toggles default ON only so that the moment the owner flips the master + * switch the sensible behaviour is already in place — none of them do + * anything while `enabled` is 0. + * + * The broken-link checker still schedules its weekly outbound crawler on + * activation (see ELC_Link_Checker::schedule_if_needed); that behaviour is + * disclosed in the readme Description. */ public function activate_plugin_defaults() { if ( false === get_option( 'timu_elc_options' ) ) { add_option( 'timu_elc_options', array( - 'enabled' => 1, + 'enabled' => 0, 'new_tab' => 1, 'nofollow' => 1, 'comment_ugc' => 1, @@ -466,20 +476,23 @@ public function render_plugin_admin_ui() { TIMU_ELC::instance(); /** - * GitHub Updater Integration. + * GitHub release-updater integration. + * + * Wires the hardened TIMU_GitHub_Release_Updater (guarded after_install, + * timeout + User-Agent on the API request, HTTP 200 check, and a 6-hour + * transient cache). The legacy FWO_GitHub_Updater has been removed. */ -add_action( 'plugins_loaded', function() { - $updater_path = plugin_dir_path( __FILE__ ) . 'updater.php'; - if ( file_exists( $updater_path ) ) { - require_once $updater_path; - if ( class_exists( 'FWO_GitHub_Updater' ) ) { - new FWO_GitHub_Updater( array( - 'slug' => 'thisismyurl-external-link-control', - 'proper_folder_name' => 'thisismyurl-external-link-control', - 'api_url' => 'https://api.github.com/repos/thisismyurl/thisismyurl-external-link-control/releases/latest', - 'github_url' => 'https://github.com/thisismyurl/thisismyurl-external-link-control', - 'plugin_file' => __FILE__, - ) ); - } +add_action( 'plugins_loaded', function () { + $updater_path = plugin_dir_path( __FILE__ ) . 'github-updater.php'; + if ( ! file_exists( $updater_path ) ) { + return; + } + require_once $updater_path; + if ( function_exists( 'timu_boot_github_release_updater' ) ) { + timu_boot_github_release_updater( array( + 'slug' => 'thisismyurl-external-link-control', + 'repo' => 'thisismyurl/thisismyurl-external-link-control', + 'plugin_file' => __FILE__, + ) ); } -}); \ No newline at end of file +} ); \ No newline at end of file diff --git a/uninstall.php b/uninstall.php index fdfe95c..05522e5 100644 --- a/uninstall.php +++ b/uninstall.php @@ -1,10 +1,11 @@ config = $config; - - add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'check_update' ) ); - add_filter( 'plugins_api', array( $this, 'plugin_popup' ), 10, 3 ); - add_filter( 'upgrader_post_install', array( $this, 'after_install' ), 10, 3 ); - } - - private function get_github_data() { - if ( ! empty( $this->github_data ) ) { - return $this->github_data; - } - - $response = wp_remote_get( $this->config['api_url'] ); - - if ( is_wp_error( $response ) ) { - return false; - } - - $this->github_data = json_decode( wp_remote_retrieve_body( $response ) ); - return $this->github_data; - } - - public function check_update( $transient ) { - if ( empty( $transient->checked ) ) { - return $transient; - } - - $github_data = $this->get_github_data(); - $plugin_slug = plugin_basename( $this->config['plugin_file'] ); - - if ( $github_data && version_compare( $github_data->tag_name, $transient->checked[ $plugin_slug ], '>' ) ) { - $obj = new stdClass(); - $obj->slug = $this->config['slug']; - $obj->new_version = $github_data->tag_name; - $obj->url = $this->config['github_url']; - $obj->package = $github_data->zipball_url; - - $transient->response[ $plugin_slug ] = $obj; - } - - return $transient; - } - - public function plugin_popup( $result, $action, $args ) { - if ( $action !== 'plugin_information' || $args->slug !== $this->config['slug'] ) { - return $result; - } - - $github_data = $this->get_github_data(); - - $result = new stdClass(); - $result->name = $this->config['proper_folder_name']; - $result->slug = $this->config['slug']; - $result->version = $github_data->tag_name; - $result->author = 'thisismyurl'; - $result->homepage = $this->config['github_url']; - $result->last_updated = $github_data->published_at; - $result->sections = array( 'description' => $github_data->body ); - $result->download_link = $github_data->zipball_url; - - return $result; - } - - public function after_install( $response, $hook_extra, $result ) { - global $wp_filesystem; - $install_directory = plugin_dir_path( $this->config['plugin_file'] ); - $wp_filesystem->move( $result['destination'], $install_directory ); - $result['destination'] = $install_directory; - return $result; - } - } -} \ No newline at end of file From 9d043c58252ccdaa78f9a58c5bfa52aabaaa4137 Mon Sep 17 00:00:00 2001 From: thisismyurl <122108986+thisismyurl@users.noreply.github.com> Date: Thu, 28 May 2026 21:08:55 -0400 Subject: [PATCH 3/5] Apply gate-editor readme fixes Co-Authored-By: Claude Opus 4.8 (1M context) --- readme.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.txt b/readme.txt index 749f2f7..635c643 100644 --- a/readme.txt +++ b/readme.txt @@ -84,7 +84,7 @@ If they're helpful, here are genuine ways to support the work: * **Sponsor this project:** Visit https://github.com/sponsors/thisismyurl if sponsorship fits your budget. Sponsorship helps, but it's always optional. * **Contribute code or ideas:** Opening a pull request, reporting an issue, or testing edge cases is just as valuable as sponsorship. Helping me improve these plugins is a great way to contribute. -* **Share your experience:** A review on my [Google My Business profile](https://business.google.com/refer) or a follow on [WordPress.org](https://profiles.wordpress.org/thisismyurl/), [GitHub](https://github.com/thisismyurl), or [LinkedIn](https://linkedin.com/in/thisismyurl) helps others find this work. +* **Share your experience:** A follow on [WordPress.org](https://profiles.wordpress.org/thisismyurl/), [GitHub](https://github.com/thisismyurl), or [LinkedIn](https://linkedin.com/in/thisismyurl) helps others find this work. = I found a bug or have a feature idea = From c6e4fe4ba988de834940b517d33348cba57b0413 Mon Sep 17 00:00:00 2001 From: thisismyurl <122108986+thisismyurl@users.noreply.github.com> Date: Thu, 28 May 2026 21:14:29 -0400 Subject: [PATCH 4/5] Stamp release version 1.6148.2110 --- readme.txt | 2 +- thisismyurl-external-link-control.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.txt b/readme.txt index 635c643..230e48b 100644 --- a/readme.txt +++ b/readme.txt @@ -5,7 +5,7 @@ Tags: external links, nofollow, target blank, seo, link management Requires at least: 6.2 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 1.6148 +Stable tag: 1.6148.2110 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html diff --git a/thisismyurl-external-link-control.php b/thisismyurl-external-link-control.php index 380ebbe..3d30fbd 100644 --- a/thisismyurl-external-link-control.php +++ b/thisismyurl-external-link-control.php @@ -3,7 +3,7 @@ * Plugin Name: This Is My URL - External Link Control * Plugin URI: https://thisismyurl.com/external-link-control * Description: Globally manage external link behavior, including nofollow and target attributes. - * Version: 1.6148 + * Version: 1.6148.2110 * Requires at least: 6.2 * Requires PHP: 7.4 * Author: Christopher Ross From 81f6ca92e52ef3251f9d74895771f037e4eac3aa Mon Sep 17 00:00:00 2001 From: thisismyurl <122108986+thisismyurl@users.noreply.github.com> Date: Thu, 28 May 2026 21:20:47 -0400 Subject: [PATCH 5/5] Standardize support block / Coxon attribution --- readme.txt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/readme.txt b/readme.txt index 230e48b..cb7141a 100644 --- a/readme.txt +++ b/readme.txt @@ -78,13 +78,15 @@ Yes. Disable the master switch or deactivate the plugin and the rendered output = I want to support you = -I'm building these tools because WordPress developers and site owners deserve straightforward, practical solutions. There's no tracking, no ads, and you don't need to pay to use these plugins. +I build these tools because WordPress sites in the wild keep hitting the same problems, and a small, focused plugin is usually the right fix. They're free to use, with no tracking and no ads. -If they're helpful, here are genuine ways to support the work: +If one of them saves you time, here are the genuine ways to help: -* **Sponsor this project:** Visit https://github.com/sponsors/thisismyurl if sponsorship fits your budget. Sponsorship helps, but it's always optional. -* **Contribute code or ideas:** Opening a pull request, reporting an issue, or testing edge cases is just as valuable as sponsorship. Helping me improve these plugins is a great way to contribute. -* **Share your experience:** A follow on [WordPress.org](https://profiles.wordpress.org/thisismyurl/), [GitHub](https://github.com/thisismyurl), or [LinkedIn](https://linkedin.com/in/thisismyurl) helps others find this work. +* **Sponsor the work:** [GitHub Sponsors](https://github.com/sponsors/thisismyurl) is the simplest way. Any amount helps, and none of it is expected. +* **Contribute code or ideas:** A pull request, a bug report, or a tested edge case is worth as much as a donation. Helping me improve these plugins is a great way to contribute. +* **Share it:** A note on [WordPress.org](https://profiles.wordpress.org/thisismyurl/), [GitHub](https://github.com/thisismyurl), or [LinkedIn](https://linkedin.com/in/thisismyurl) helps other people find work that might save them the same afternoon. + +This plugin is built and maintained by [This Is My URL](https://thisismyurl.com/), the WordPress development and technical SEO practice of Christopher Ross. I help teams build WordPress sites that stay secure, fast, and maintainable, and I write small, focused plugins like this one for the problems those sites keep running into. = I found a bug or have a feature idea =