安全地开发插件和主题


到目前为止,你已经熟悉了开发 WordPress 插件和主题的基础知识。现在也是考虑编写代码所涉及的安全问题的好时机。

在本课程中,你将学习如何以安全为先来进行开发。

你将了解确保代码安全的好处、应采取哪些步骤来保护代码,以及在哪里可以找到更多关于以安全为先进行开发的信息。

免责声明

本课程旨在介绍在开发插件和主题时具备安全意识。本课程中使用的代码经过了大幅简化。请勿在自己的插件或主题中使用本教程中的任何代码。

请务必阅读 WordPress 开发者手册中关于安全性的完整文档:https://developer.wordpress.org/apis/security/,以确保你遵循正确的方法和流程。

什么是以安全为先进行开发?

以安全为先进行开发,是确保代码不仅能够正常运行,而且不会引入任何安全漏洞的过程。

如果你的插件或主题代码存在安全漏洞,任何安装了该产品的 WordPress 网站都可能遭受潜在攻击,并可能导致该网站被入侵。

编写代码时,养成安全思维非常重要,要考虑你的代码可能会被如何恶意使用。

  • 不要信任任何数据,无论是用户输入、第三方 API 数据,还是数据库中的数据。始终进行检查,确保数据有效且可以安全使用。
  • WordPress 提供了许多 API,可以帮助你完成常见任务,例如清理用户输入、验证数据和转义输出。应依赖这些 API 来验证和清理数据,而不是编写自己的函数。
  • 及时了解常见漏洞,并保持代码更新,以防止这些漏洞。

安全性在开发流程中的哪个环节?

安全性应该贯穿开发流程的每一个阶段。

通常,发现的安全漏洞中数量最多的发生在 PHP 层,因为 PHP 在服务器上执行。

因此,本课程将重点介绍 PHP 代码中最常见的预防措施。

清理输入

开发时首先要采取的步骤之一,是确保所有用户输入都经过清理。这意味着,对从用户传递来的任何数据(例如表单提交或 URL 参数)进行检查,以确保其可以安全使用。

在下面的示例代码中,name 和 email 字段通过插件生成的表单提交,然后保存到名为 form_submissions 的自定义数据库表中:

$name = $_POST['name'];
$email = $_POST['email'];
global $wpdb;
$table_name = $wpdb->prefix . 'form_submissions';

$sql = "INSERT INTO $table_name (name, email) VALUES ('$name', '$email')";
$result = $wpdb->query($sql);

如你所见,数据未经任何清理就直接保存到了数据库中。

这意味着,如果用户提交的姓名为 John'; DROP TABLE form_submissions;--,SQL INSERT 查询会被执行,随后执行 DROP 查询,数据库中的表就会被删除!

WordPress 提供了可用于清理传入数据的清理 API。你可以使用 sanitize_text_fieldsanitize_email 函数,在查询中使用 name 和 email 字段之前对其进行清理。

$name = sanitize_text_field( $_POST['name'] );
$email = sanitize_email( $_POST['email'] );

global $wpdb;
$table_name = $wpdb->prefix . 'form_submissions';

$sql = "INSERT INTO $table_name (name, email) VALUES ('$name', '$email')";
$result = $wpdb->query($sql);

请注意,这段代码遵循了数据清理的一项关键原则:尽可能早地进行清理。

如需详细了解 WordPress 开发者可用的各种数据清理函数,请参阅 WordPress 开发者文档中的 清理数据页面。

验证数据 (4:06)

验证数据,是根据预定义的模式(或多个模式)对数据进行测试,并得出明确结果(有效或无效)的过程。

不可信数据可能来自许多来源:用户、第三方 API 数据,甚至数据库中的数据也可能被视为不可信,尤其是在数据被其他内容修改过的情况下。即使网站管理员也可能犯错,输入错误或不安全的数据,因此始终检查数据非常重要。

在此示例中,删除函数要求将数字 ID 提交到 admin-ajax 回调:

add_action( 'wp_ajax_delete_form_submission', 'wp_learn_delete_form_submission' );
function wp_learn_delete_form_submission() {
    if ( ! isset( $_POST['id'] ) ) {
        wp_send_json_error( 'Invalid ID' );
    }
    $id = $_POST['id'];

    global $wpdb;
    $table_name = $wpdb->prefix . 'form_submissions';

    $sql    = "DELETE FROM $table_name WHERE id = $id";
    $result = $wpdb->get_results( $sql );

    return wp_send_json( array( 'result' => $result ) );
}

这里的 ID 未经验证就直接用于 SQL 查询。同样,这意味着如果用户提交的 ID 为 1; DROP TABLE form_submissions;,相同的 SQL DROP 查询就会在删除操作之后执行,表也会被删除。

为防止这种情况,你可以使用 PHP 的类型转换功能,确保 $id 的值始终为整数。具体做法是在变量名之前添加 (int),如下所示:

$id = (int) $_POST['id'];

请注意,只有当通过 $_POST 数组传递的字符串中的第一个字符可以转换为整数时,这种方式才会正常工作,否则 $id 的值将为 0。在这种情况下,最好更新代码以处理该情况。

这段代码也遵循了数据验证的一项关键原则:尽可能早地进行验证。

if ($id === 0){
    // return early with an error
    return wp_send_json( array( 'result' => 'Invalid ID passed' ) );

}

如需详细了解验证数据的各种方法,请参阅 WordPress 开发者文档中关于验证数据的章节。

转义输出

安全性的另一个方面,是确保向浏览器输出的任何信息都是安全的,包括文本、HTML 或 JavaScript 代码,以及来自数据库的数据。

即使你的代码不负责提供所显示数据的来源,也有责任以安全的方式显示这些数据。

在此示例中,代码从数据库获取表单提交内容,然后遍历这些提交内容,并在 WordPress 仪表盘的管理页面中输出提交数据:

    $submissions = wp_learn_get_form_submissions();
    ?>
    <div class="wrap" id="wp_learn_admin">
        <h1>Admin</h1>
        <table>
            <thead>
                <tr>
                    <th>Name</th>
                    <th>Email</th>
                </tr>
            </thead>
            <?php foreach ($submissions as $submission){ ?>
                <tr>
                    <td><?php echo $submission->name; ?></td>
                    <td><?php echo $submission->email; ?></td>
                    <td><a class="delete-submission" data-id="<?php echo $submission->id?>" style="cursor:pointer;">Delete</a></td>
                </tr>
            <?php } ?>
        </table>
    </div>
    <?php

这里有三处数据需要转义:$submission->name$submission->email 字段,以及 $submission->id

<td><a class="delete-submission" data-id="<?php echo (int) $submission->id?>" style="cursor:pointer;">Delete</a></td>

对于 name 和 email 字段,我们可以使用 WordPress 内置的转义函数 esc_html()。ID 则可以像进行数据验证时一样,将其转换为整数来完成转义。

  <td><?php echo esc_html( $submission->name ); ?></td>
  <td><?php echo esc_html( $submission->email ); ?></td>
  <td><a class="delete-submission" data-id="<?php echo (int) $submission->id?>" style="cursor:pointer;">Delete</a></td>

请注意,这段代码遵循了数据转义的一项关键原则:尽可能晚地进行转义。

如需详细了解转义输出的各种方法,请参阅 WordPress 开发者文档中关于转义数据的章节。

防止无效请求

每当发出请求时,都必须检查该请求是否有效。这意味着要检查请求是否来自可信来源。

例如,你可能有一个用于呈现表单的短代码,用户可以通过该表单提交自己的信息。

用于呈现表单的函数可能如下所示。

add_shortcode( 'wp_learn_form_shortcode', 'wp_learn_form_shortcode' );
function wp_learn_form_shortcode() {
    ob_start();
    ?>
    <form method="post">
        <input type="hidden" name="wp_learn_form" value="submit">
        <div>
            <label for="email">Name</label>
            <input type="text" id="name" name="name" placeholder="Name">
        </div>
        <div>
            <label for="email">Email address</label>
            <input type="text" id="email" name="email" placeholder="Email address">
        </div>
        <div>
            <input type="submit" id="submit" name="submit" value="Submit">
        </div>
    </form>
    <?php
    $form = ob_get_clean();
    return $form;
}
?>

用户提交表单后,数据会发送到服务器。随后,数据会经过处理和清理,并存储到数据库中。

add_action( 'wp', 'wp_learn_maybe_process_form' );
function wp_learn_maybe_process_form() {
    if (!isset($_POST['wp_learn_form'])){
        return;
    }

    $name = sanitize_text_field( $_POST['name'] );
    $email = sanitize_email( $_POST['email'] );

    global $wpdb;
    $table_name = $wpdb->prefix . 'form_submissions';

    $sql = "INSERT INTO $table_name (name, email) VALUES ('$name', '$email')";
    $result = $wpdb->query($sql);
    if ( 0 < $result ) {
        wp_redirect( WPLEARN_SUCCESS_PAGE_SLUG );
        die();
    }

    wp_redirect( WPLEARN_ERROR_PAGE_SLUG );
    die();
}

由于表单可能出现在使用该短代码的任何页面上,恶意用户可能会尝试向表单发送 POST 请求,或是寻找插件中的漏洞,或是向表单发送大量请求。

为防止这种情况,你可以检查请求是否来自可信来源。为此,可以实现一种称为 nonce 的机制,即一次性使用的数字。

首先,在表单本身中,可以使用 wp_nonce_field 函数添加 nonce 字段,并向该函数传递 nonce 操作和 nonce 名称:

wp_nonce_field( 'wp_learn_form_nonce_action', 'wp_learn_form_nonce_field' );

表单在前端呈现时,会添加一个隐藏字段,其中使用 nonce 名称作为隐藏字段的 id 和 name 属性,并将生成的 nonce 作为字段值。提交表单时,这些数据会一并提交。

然后,在处理表单数据的函数中,可以使用 wp_verify_nonce 函数验证 nonce 是否有效,并将 POST 提交的 nonce 字段和 nonce 操作传递给该函数。如果验证结果为 false,可以提前退出,从而阻止后续代码执行。

    if ( ! wp_verify_nonce( $_POST['wp_learn_form_nonce_field'], 'wp_learn_form_nonce_action' ) {
        wp_redirect( WPLEARN_ERROR_PAGE_SLUG );
        die();
    }

每当代码发出 Web 请求时,无论是通过重定向访问新 URL、向表单 POST 数据,还是发出 AJAX 请求,都应该检查请求是否有效。

如需详细了解如何在插件中使用 nonce,请参阅 WordPress 开发者文档中关于 Nonce 的章节。

防止未经身份验证的用户

根据代码的功能,最好将某些功能限制为具有特定权限级别的用户。例如,你可能有一个从数据库中删除数据的函数。

function wp_learn_delete_form_submission() {
    if ( ! isset( $_POST['id'] ) ) {
        wp_send_json_error( 'Invalid ID' );
    }
    $id = (int) $_POST['id'];

    global $wpdb;
    $table_name = $wpdb->prefix . 'form_submissions';

    $sql    = "DELETE FROM $table_name WHERE id = $id";
    $result = $wpdb->get_results( $sql );

    return wp_send_json( array( 'result' => $result ) );
}

虽然你可能会尽力阻止没有权限的用户运行此函数,但针对这种情况添加检查仍然是一个好主意。

WordPress 提供了强大的用户角色和权限系统,你可以使用默认的用户角色和权限,也可以创建自定义角色和权限。

在本例中,只允许具有管理站点选项权限的用户执行操作即可。该权限是管理员角色包含的标准权限。

function wp_learn_delete_form_submission() {
    if ( ! current_user_can( 'manage_options' ) ) {
        return wp_send_json( array( 'result' => 'Authentication error' ) );
    }
    // rest of function code
}

WordPress 开发者文档有关于用户角色和权限的详细章节,其中列出了默认权限以及创建自定义权限的方法。

延伸阅读

为了培养以安全为先进行开发的思维,请务必阅读 WordPress 开发者文档中的安全性条目,其中包含本课程中的所有示例,以及关于安全最佳实践、常见漏洞和更多示例代码的补充信息。