本文约 5,634 字,预计阅读 15 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:同样一段数据,两个地方要处理两次
「为什么要转义两次?」这是很多人第一次看到「入库前 sanitize_text_field(),出库时还要 esc_html()」时的疑问。
答案是这两个动作防的是不同的事,而且都不能省。
入库前净化(sanitize) 解决的是「数据该以什么形态被保存」。用户提交了一个标题,你不知道里面有没有多余的空白、有没有非法字符、有没有伪造的换行。净化是把数据规整成你期望的样子,让数据库里的内容是可预期的。
出库时转义(esc) 解决的是「这段内容在 HTML 里该怎么显示」。即使入库时净化过,输出时仍然必须转义——因为数据库中可能还有历史数据、其他插件写入的数据,而且 esc_html() 防的是浏览器把它当标签解析。
漏掉前者,数据库里会有脏数据;漏掉后者,就是存储型 XSS。安全扫描报告里最常见的插件漏洞类型,就是输出时漏转义。
本文把这三类函数整理成对照表,并给出可直接套用的写法。
原理分析:三类函数的分工
| 类别 | 代表函数 | 使用时机 | 防的是什么 |
|---|---|---|---|
| 输入净化 | sanitize_* |
数据写入数据库之前 | 脏数据、非法格式 |
| 输出转义 | esc_* |
数据输出到 HTML / 属性 / URL 之前 | XSS、HTML 结构破坏 |
| 富文本过滤 | wp_kses* |
允许保留部分 HTML 标签时 | 恶意标签与属性 |
| 类型转换 | absint / intval / floatval |
数字类输入 | 类型混淆 |
| 反转义处理 | wp_unslash |
读取 $_POST / $_GET 之后 |
WordPress 自动添加的斜杠 |
关于 wp_unslash():WordPress 为了兼容老代码会对 $_POST、$_GET、$_COOKIE 做 addslashes() 处理(即 wp_magic_quotes())。所以从超全局变量读数据时的标准顺序是先 wp_unslash() 再 sanitize_*,顺序反了会留下多余的斜杠。用核心提供的 wp_unslash() 而不是 stripslashes(),因为它能处理数组。
常见函数对照表:
| 场景 | 推荐函数 |
|---|---|
| 单行文本输入 | sanitize_text_field() |
| 多行文本(不允许 HTML) | sanitize_textarea_field() |
| 邮箱 | sanitize_email() |
| URL(写入数据库) | esc_url_raw() |
| URL(输出到页面) | esc_url() |
| 别名 / slug | sanitize_title() 或 sanitize_title_with_dashes() |
| 数组键名、选项名 | sanitize_key() |
| 文件名 | sanitize_file_name() |
| HTML 类名、HTML 属性值 | sanitize_html_class() |
| 整数(非负) | absint() |
| 保留部分 HTML 的正文 | wp_kses_post() |
| 完全去除 HTML | wp_strip_all_tags() |
| 输出到 HTML 文本节点 | esc_html() |
| 输出到 HTML 属性 | esc_attr() |
| 输出到 JS 字符串 | esc_js() |
| 输出到 textarea 内容 | esc_textarea() |
| 输出到内联 style | (尽量避免,必要时用白名单) |
| SQL 语句 | $wpdb->prepare() |
注意 esc_url() 与 esc_url_raw() 的区别:esc_url() 会把 & 转成 & 以适配 HTML 输出,所以存数据库必须用 esc_url_raw(),用错会导致存进去的 URL 带 HTML 实体,取出来再判断时出错。
保姆级实操
第一步:处理表单提交的完整链路
// 标准顺序:isset 判断 → wp_unslash → sanitize
$name = isset( $_POST['mmf_name'] ) ? sanitize_text_field( wp_unslash( $_POST['mmf_name'] ) ) : '';
$email = isset( $_POST['mmf_email'] ) ? sanitize_email( wp_unslash( $_POST['mmf_email'] ) ) : '';
$url = isset( $_POST['mmf_url'] ) ? esc_url_raw( wp_unslash( $_POST['mmf_url'] ) ) : '';
$count = isset( $_POST['mmf_count'] ) ? absint( $_POST['mmf_count'] ) : 0;
$note = isset( $_POST['mmf_note'] ) ? sanitize_textarea_field( wp_unslash( $_POST['mmf_note'] ) ) : '';
注意 absint() 不需要 wp_unslash(),因为它内部会转成整数。
第二步:输出的三种位置,三种函数
<?php
$title = '用户输入的 <b>标题</b> & "引号"';
?>
<!-- 1) 文本节点:用 esc_html -->
<h2><?php echo esc_html( $title ); ?></h2>
<!-- 2) 属性:用 esc_attr -->
<a title="<?php echo esc_attr( $title ); ?>" href="<?php echo esc_url( $url ); ?>">链接</a>
<!-- 3) textarea 内容:用 esc_textarea -->
<textarea><?php echo esc_textarea( $title ); ?></textarea>
<!-- 4) 内联 JS:用 esc_js -->
<script>
var title = '<?php echo esc_js( $title ); ?>';
</script>
<!-- 5) 需要国际化的场景:用带 _e / __ 的转义版本 -->
<p><?php esc_html_e( '保存成功', 'mmf' ); ?></p>
<p><?php echo esc_html__( '保存成功', 'mmf' ); ?></p>
esc_html_e() 是「转义并输出」,esc_html__() 是「转义并返回」,后者用于需要拼接的场景。
第三步:富文本用 wp_kses
当内容必须保留部分 HTML(比如文章正文、评论)时,不能用 esc_html()(会把标签全显示成文本),也不能不处理(等于放行 XSS)。正确做法:
// 保留文章级别允许的标签(等价于文章正文的规则)
echo wp_kses_post( $content );
// 完全去除所有标签,只留文字
echo wp_strip_all_tags( $content );
// 自定义允许的标签与属性
$allowed = array(
'a' => array(
'href' => array(),
'title' => array(),
'rel' => array(),
),
'strong' => array(),
'em' => array(),
'br' => array(),
);
echo wp_kses( $content, $allowed );
也可以基于核心默认规则做增删:
add_filter( 'wp_kses_allowed_html', function ( $tags, $context ) {
if ( 'post' === $context ) {
$tags['iframe']['src'] = true;
$tags['iframe']['loading'] = true;
}
return $tags;
}, 10, 2 );
第四步:数据库查询必须参数化
global $wpdb;
// 正确:用 $wpdb->prepare,占位符 %s / %d / %f
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s AND post_status = %s",
'%' . $wpdb->esc_like( $keyword ) . '%',
'publish'
)
);
// LIKE 里的用户输入必须再用 esc_like 处理,否则 % 和 _ 会被当成通配符
第五步:给数组批量处理
// 递归反转义
$data = wp_unslash( $_POST['mmf_settings'] );
// 批量净化数组里的每个值
$data = array_map( 'sanitize_text_field', (array) $data );
更推荐的做法是显式处理每个键,而不是无脑 array_map,因为不同字段需要不同的净化函数。
第六步:验证结果
# 用安全扫描工具检查插件
wpscan --url https://example.com --enumerate vp --api-token YOUR_TOKEN
# 静态检查:搜索未转义的 echo
grep -rn --include=*.php -E "echo \$|echo \\\$_" wp-content/plugins/your-plugin/
更严格的做法是引入 WordPress 编码规范的 phpcs 规则集(WordPress-Core / WordPress-Extra),它会对未转义的输出报警。
常见踩坑
-
入库净化了就以为输出安全:两者防的不是同一件事,必须都做。历史数据、其他插件写入的数据不会被你的入库逻辑净化。
-
存 URL 用了
esc_url():会把&变成&存进数据库,取出来再判断会出错。存用esc_url_raw(),显示用esc_url()。 -
wp_kses_post()用在属性里:它保留的标签会破坏属性结构。属性位置一律用esc_attr()。 -
忘记
wp_unslash():存进去的数据带多余反斜杠,表现为文本里出现\'。 -
esc_html()用在已包含 HTML 的内容上:用户看到一堆<p>。富文本场景要用wp_kses_post()。 -
案例: 某插件在后台保存了一条公告文本,入库用了
sanitize_text_field(),前台输出时直接echo get_option('mmf_notice')。攻击者利用另一个接口的越权漏洞(参见 nonce 与 capability 安全)往这个选项里写入了<script>标签,前台所有页面加载即执行,导致管理员会话被劫持。修复方式是输出改为echo esc_html( get_option('mmf_notice') )。教训是:安全是分层的,某一层的漏洞要靠另一层的防护兜底,输出转义是最后一道防线,绝不能省。 -
用
stripslashes()代替wp_unslash():前者不处理数组,且没有考虑 WordPress 的魔术引号上下文。
总结
记住这条口诀:输入先 wp_unslash() 再 sanitize_*;输出按位置选 esc_*;富文本用 wp_kses*;SQL 一律 $wpdb->prepare()。
四个高频判断:存 URL 用 esc_url_raw,显示 URL 用 esc_url;数字用 absint;文本节点用 esc_html,属性用 esc_attr;需要保留标签就用 wp_kses_post,不要手动拼 HTML。
把这张对照表固化成团队规范,比事后靠扫描器发现问题有效得多。设置页的完整防护写法见 创建自定义设置页面,而把所有这些函数调用串起来的最小示例,在 从零写一个 WordPress 插件 里。
