final_url ? Url::host( $r->final_url ) : $host; $protected = Url::host_matches( $host, $allowlist ) || Url::host_matches( $final, $allowlist ); switch ( $r->error_type ) { case '': break; case 'dns': return self::result( self::BROKEN, __( 'DNS lookup failed (domain does not resolve)', 'unbreak' ) ); case 'ssl': return self::result( self::SSL_ERROR, __( 'SSL/TLS error:', 'unbreak' ) . ' ' . $r->error_message ); case 'timeout': case 'connection': if ( $protected ) { return self::result( self::UNVERIFIABLE, __( 'Site blocks automated checks (connection refused or timed out)', 'unbreak' ) ); } return self::result( self::TIMEOUT, 'timeout' === $r->error_type ? __( 'Request timed out', 'unbreak' ) : __( 'Connection error:', 'unbreak' ) . ' ' . $r->error_message ); case 'too_many_redirects': return self::result( self::UNVERIFIABLE, __( 'Redirect loop or too many redirects (often caused by cookie or bot checks)', 'unbreak' ) ); case 'unsafe': return self::result( self::UNVERIFIABLE, __( 'Not checked: the address is on a private or reserved network', 'unbreak' ) ); case 'blocked': return self::result( self::UNVERIFIABLE, __( 'Outgoing requests are blocked on this server (WP_HTTP_BLOCK_EXTERNAL)', 'unbreak' ) ); case 'invalid_url': default: return self::result( self::UNVERIFIABLE, __( 'The URL could not be requested:', 'unbreak' ) . ' ' . $r->error_message ); } $code = (int) $r->code; if ( $code <= 0 ) { return self::result( $protected ? self::UNVERIFIABLE : self::TIMEOUT, __( 'No HTTP status received', 'unbreak' ) ); } if ( self::is_challenge( $r ) ) { return self::result( self::UNVERIFIABLE, __( 'Bot protection challenge (for example Cloudflare)', 'unbreak' ) ); } if ( in_array( $code, self::BLOCKING_CODES, true ) ) { return self::result( self::UNVERIFIABLE, /* translators: %d: HTTP status code. */ sprintf( __( 'HTTP %d: site blocks automated checks', 'unbreak' ), $code ) ); } if ( 404 === $code || 410 === $code ) { return self::result( self::BROKEN, sprintf( 'HTTP %d', $code ) ); } if ( $protected && $code >= 400 ) { return self::result( self::UNVERIFIABLE, /* translators: %d: HTTP status code. */ sprintf( __( 'HTTP %d from a site that blocks automated checks', 'unbreak' ), $code ) ); } if ( $code >= 200 && $code < 300 ) { if ( $r->redirect_count > 0 ) { if ( self::is_parked( $url, $r ) ) { return self::result( self::SUSPICIOUS, __( 'Redirects to a parked or for-sale domain', 'unbreak' ) ); } return self::result( self::REDIRECT, /* translators: 1: number of redirect hops, 2: final URL. */ sprintf( _n( 'Redirects (%1$d hop) to %2$s', 'Redirects (%1$d hops) to %2$s', $r->redirect_count, 'unbreak' ), $r->redirect_count, $r->final_url ) ); } return self::result( self::OK, '' ); } if ( $code >= 500 ) { return self::result( self::SERVER_ERROR, sprintf( 'HTTP %d', $code ) ); } // 3xx without a usable Location, or other 4xx (400, 405, 406, 451...): not provably broken. return self::result( self::UNVERIFIABLE, /* translators: %d: HTTP status code. */ sprintf( __( 'Unexpected HTTP %d', 'unbreak' ), $code ) ); } /** * Built-in allow-list plus the user's extra hosts. * * @return string[] */ public static function allowlist(): array { $extra = Settings::get( 'extra_allowlist' ); $list = array_merge( self::BUILTIN_ALLOWLIST, is_array( $extra ) ? $extra : array() ); /** * Filters the hosts whose 403/429/999 answers are never treated as broken. * * @param string[] $list Host patterns ("linkedin.com", "amazon.*", "*.instagram.com"). */ return array_values( (array) apply_filters( 'unbreak_allowlist', $list ) ); } /** * Whether the final host differs from the original (ignoring "www."). * * @param string $from Original URL. * @param string $to Final URL. */ public static function host_changed( string $from, string $to ): bool { $a = preg_replace( '/^www\./', '', Url::host( $from ) ); $b = preg_replace( '/^www\./', '', Url::host( $to ) ); return '' !== $b && $a !== $b; } /** * Parked / for-sale heuristic: final host differs and the page contains well-known markers * (or the final host is a parking service). * * @param string $url Original URL. * @param Response $r Result. */ private static function is_parked( string $url, Response $r ): bool { if ( ! self::host_changed( $url, $r->final_url ) ) { return false; } $final_host = Url::host( $r->final_url ); if ( Url::host_matches( $final_host, self::PARKING_HOSTS ) ) { return true; } if ( Url::host_matches( $final_host, array( 'godaddy.com' ) ) && false !== stripos( $r->final_url, 'forsale' ) ) { return true; } $body = strtolower( $r->body ); if ( '' === $body ) { return false; } foreach ( self::PARKING_MARKERS as $marker ) { if ( false !== strpos( $body, $marker ) ) { return true; } } return false; } /** * Cloudflare-style challenge detection. * * @param Response $r Result. */ private static function is_challenge( Response $r ): bool { $code = (int) $r->code; if ( 'challenge' === strtolower( $r->header( 'cf-mitigated' ) ) ) { return true; } if ( ! in_array( $code, array( 403, 429, 503 ), true ) ) { return false; } $server = strtolower( $r->header( 'server' ) ); if ( false !== strpos( $server, 'cloudflare' ) && 503 !== $code ) { return true; } $body = strtolower( $r->body ); foreach ( self::CHALLENGE_MARKERS as $marker ) { if ( false !== strpos( $body, $marker ) ) { return true; } } return false; } /** * Result tuple. * * @param string $status Status. * @param string $reason Reason. * @return array{status:string,reason:string} */ private static function result( string $status, string $reason ): array { return array( 'status' => $status, 'reason' => $reason, ); } }