WordPress去除JS/CSS版本号的4种安全方法

WordPress 静态资源 URL 末尾的 ver 参数会泄露 WP 版本号给扫描器。本文给出内容哈希、直接删除、伪造版本、null 注册四种方案的代码与坑,并附 CDN 命中率从 78% 提到 94% 的实测对比。

更新 24 分钟阅读 3,142 阅读

保哥这篇想聊的是一个看上去很小、但其实牵扯到安全、缓存、CDN 三个层面的问题:WordPress 默认在加载所有 JS 和 CSS 链接的尾巴上挂一个 ?ver=x.x.x 参数。要不要去掉它?怎么去掉才不会把缓存搞坏?我把自己这几年在十几个站点上反复折腾出来的经验、加上这次重写时新补的方案四与对比表,整理成下面这篇。读完应该可以一次到位地把 WordPress 静态资源 URL 版本号问题解决干净。

那个 ?ver= 是从哪儿来的,又泄露了什么

打开任意一个 WordPress 页面查看源码,你会看到类似这样的链接:

<link rel='stylesheet' href='https://example.com/wp-content/themes/twentytwentyfive/style.css?ver=1.2.3' />
<script src='https://example.com/wp-includes/js/wp-emoji-release.min.js?ver=6.5.2'></script>

那个 ver=6.5.2 不是装饰品,它是 WordPress 给浏览器和 CDN 看的"缓存破坏"参数。当 WordPress 升级到新版本,所有内置脚本的 ver 也跟着升,浏览器看到 URL 变了就会重新拉取,避免命中老缓存。

但问题来了:这个版本号同时也在告诉全世界你站点的 WordPress 版本是多少。攻击者只要扫一眼前端源码,就能知道你跑的是 6.5.2 还是 5.9.3,然后立刻去查这个版本对应的已知漏洞。如果你恰好没及时升级,等于公开挂了一块"欢迎扫我"的牌子。

类似的版本泄露还包括:

  1. <meta name="generator" content="WordPress 6.5.2"> —— 头部直接写明版本。
  2. /wp-login.php 页面源码里同样有 ver= 参数。
  3. /readme.html 里直接告诉你版本号。
  4. 主题和插件各自的 ver 参数泄露插件版本,进而暴露插件漏洞。
  5. RSS feed 和 atom 里 <generator> 节点泄露版本。
  6. Emoji 加载脚本 wp-emoji-release.min.js 路径里嵌了 WP 主版本号。

所以"去版本号"不是单点优化,而是 WordPress 加固清单里的一个常规项。WPScan 这类专门扫 WP 的工具,第一步就是抓 ver 参数和 generator meta。

最常见的"一段代码搞定"做法以及它的隐患

网上流传最广的代码是这种:

<?php
function remove_cssjs_ver( $src ) {
    if ( strpos( $src, 'ver=' ) ) {
        $src = remove_query_arg( 'ver', $src );
    }
    return $src;
}
add_filter( 'style_loader_src', 'remove_cssjs_ver', 999 );
add_filter( 'script_loader_src', 'remove_cssjs_ver', 999 );

它能用,我自己最早几年也是这么写的。但用了一段时间后我发现它有两个不那么明显的问题。

第一个问题:它把缓存破坏机制一起干掉了。当我在主题或插件里改了 CSS、JS 文件,正常情况下应该让浏览器拉新版本,但因为 URL 永远没有 ver,所有访客的浏览器都会继续吃旧的缓存文件。我曾经因为这个 debug 了半天,最后发现是自己当年图省事埋的坑,访客在前端看到的是三天前的样式,我一直以为是浏览器抽风。

第二个问题:strpos 的判断在某些边缘情况下会被绕过。比如有些插件加载的 URL 是 ?cool=ver=1.2strpos 会误判,但其实并不需要处理。这种情况虽然少,但出现一次排查就费劲。

所以我现在的做法是:去掉版本号,但保留缓存破坏能力。下面把四种方案逐一展开,最后给一个对比表方便选型。

方案一:把 ver 替换成内容哈希

这是我目前最推荐的做法。思路是:不删除 ver 参数,而是把它的值从"WordPress 版本号"替换成"文件的内容哈希"。这样既不暴露 WP 版本,又保留了缓存破坏能力。

<?php
/**
 * 把 ver 参数从 WP 版本号替换成文件的 mtime 哈希
 * 既隐藏版本,又保留缓存破坏能力
 */
function baoge_replace_ver_with_hash( $src ) {
    if ( strpos( $src, 'ver=' ) === false ) {
        return $src;
    }

    // 解析出本地文件路径
    $parsed = wp_parse_url( $src );
    if ( empty( $parsed['path'] ) ) {
        return $src;
    }

    $local_path = ABSPATH . ltrim( str_replace( wp_parse_url( site_url(), PHP_URL_PATH ), '', $parsed['path'] ), '/' );

    if ( ! is_readable( $local_path ) ) {
        // 远程文件不处理
        return remove_query_arg( 'ver', $src );
    }

    $hash = substr( md5_file( $local_path ), 0, 8 );
    $src  = remove_query_arg( 'ver', $src );
    $src  = add_query_arg( 'v', $hash, $src );

    return $src;
}
add_filter( 'style_loader_src', 'baoge_replace_ver_with_hash', 999 );
add_filter( 'script_loader_src', 'baoge_replace_ver_with_hash', 999 );

这段代码做了几件事:

  1. 检查 URL 上是否真的有 ver=,没有就直接返回。
  2. 把 URL 解析成本地路径,对本地能读到的文件计算 md5 前 8 位。
  3. 用一个新的参数名 v 替换原来的 ver,避免和某些插件冲突。
  4. 远程 URL(比如 Google Fonts、第三方 CDN)只删除 ver,不计算哈希。

这种做法的好处是:当文件没改动,哈希不变,浏览器和 CDN 全都吃缓存;一旦你改了哪怕一个字符,哈希立刻变,缓存自动失效。在我自己的 zhangwenbao.com 上跑了一年多,CDN 命中率维持在 95% 以上。

方案二:直接删除 ver 同时处理 generator 标签

如果你不想引入哈希计算(比如担心服务器 I/O 压力,或者用了对象缓存层),也可以直接删除版本号,但请同时把暴露版本的 generator meta 一起去掉,否则等于只关了一扇门:

<?php
// 1. 删除前端 ver 参数
function baoge_remove_ver( $src ) {
    if ( strpos( $src, 'ver=' ) !== false ) {
        $src = remove_query_arg( 'ver', $src );
    }
    return $src;
}
add_filter( 'style_loader_src', 'baoge_remove_ver', 999 );
add_filter( 'script_loader_src', 'baoge_remove_ver', 999 );

// 2. 删除 generator meta 标签
remove_action( 'wp_head', 'wp_generator' );

// 3. 删除 RSS 中的 generator 信息
add_filter( 'the_generator', '__return_empty_string' );

// 4. 删除 emoji 脚本(顺手做的指纹清理)
remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
remove_action( 'wp_print_styles', 'print_emoji_styles' );

这个方案的代价就是上面提到的:以后改了主题 CSS,要么手动让访客 Ctrl+F5,要么在 CDN 上手动刷新。如果你站点更新频率低(比如一个月才换一次主题),倒是可以接受。

方案三:什么都不删,反过来"伪造" ver

这是一个比较取巧的思路:保留 ver 参数的格式,但把它的值统一替换成一个不真实但合理的字符串,让扫描脚本拿到的不是真实版本号。

<?php
function baoge_fake_ver( $src ) {
    if ( strpos( $src, 'ver=' ) === false ) {
        return $src;
    }
    $src = remove_query_arg( 'ver', $src );
    $src = add_query_arg( 'ver', '1.0.0', $src );
    return $src;
}
add_filter( 'style_loader_src', 'baoge_fake_ver', 999 );
add_filter( 'script_loader_src', 'baoge_fake_ver', 999 );

这个方案的问题在于:所有文件版本永远是 1.0.0,等于完全失去缓存破坏能力。我个人不推荐用,但把它列出来是因为有人会问"能不能这样"。极少数场景下它有用:比如你的站点上线后基本不再改静态资源,但又害怕 ver=0 这种字符串被防火墙规则误拦时,固定一个像样的版本字符串能让请求看起来更正常。

方案四:在注册阶段就不传版本号

这是我最近半年开始用的方案,思路完全不一样:不在过滤器里改,而是在 wp_enqueue_scriptwp_register_style 调用时就把第四个参数 version 传 null。WordPress 的资源系统本身支持这个:

<?php
function baoge_enqueue_assets() {
    wp_enqueue_style(
        'baoge-main',
        get_template_directory_uri() . '/assets/main.css',
        [],
        null  // version 传 null
    );
    wp_enqueue_script(
        'baoge-app',
        get_template_directory_uri() . '/assets/app.js',
        ['jquery'],
        null,
        true
    );
}
add_action( 'wp_enqueue_scripts', 'baoge_enqueue_assets', 20 );

这种写法只对你自己注册的资源生效,对 WordPress 核心的 jquery、wp-emoji 这些没用。所以方案四要和方案二配合:自己写的代码用 null version、过滤器层兜底其他插件和核心。这套组合是目前我觉得最干净的写法,权责分明。

顺带说一下:很多教程让你给所有资源都传 null 来"消除缓存问题",那种写法是错的。null 的语义是"不输出 ver 参数",但缓存破坏能力同时丢失,和方案二一样需要后端缓存策略兜底。

四种方案对比表

把上面四种方案放在一张表里方便选型:

方案隐藏版本缓存破坏性能开销适用场景
一·内容哈希md5_file 一次/请求,可被 OPcache 缓存大多数生产站
二·直接删 + 清 generator近 0更新频率极低的站
三·伪造 ver近 0害怕静默 URL 的特殊场景
四·null version仅自家资源近 0配合方案二使用

我自己的选择是方案一为主,对核心和插件的 ver 用方案一处理;自己写的主题和插件用方案四在注册时就不传版本号;同时把方案二的 generator/emoji 清理一起放进 functions.php。三招合一,整个 WordPress 前端源码里基本看不到任何版本指纹。

几个容易踩的坑

第一,优先级一定要够高add_filter 第三个参数我用 999,是为了确保在所有插件之后执行。如果你写成默认的 10,部分插件会在你之后再追加 ver,导致前端依然能看到版本。某些 SEO 插件会在 99 时再次注入版本号,所以稳妥起见用 999 或 PHP_INT_MAX。

第二,不要在 wp_enqueue_script 调用时传入 null 来去掉版本,期望它对核心生效。方案四只对你自己注册的资源生效,对 WordPress 核心和其他插件注册的资源完全无效。从过滤器层面统一处理才是正确做法,这点很多教程会写错。

第三,改了 functions.php 之后请清缓存。如果你用了 WP Super Cache、W3 Total Cache、LiteSpeed Cache、Cloudflare、Autoptimize 等任何一层缓存,第一次部署后必须手动清一次缓存,否则你看到的还是老的带版本号的源码。我习惯写完代码后开无痕模式刷三次,确认无 ver 才算结束。

第四,注意管理后台的兼容性。这些钩子默认对前后台都生效。如果某个插件依赖 ver 参数判断升级状态(这种情况不多但存在),可能会出问题。我的做法是在函数最前面加一个 if ( is_admin() ) return $src;,让后台保留原行为:

function baoge_replace_ver_with_hash( $src ) {
    if ( is_admin() ) {
        return $src;
    }
    // ... 后续逻辑
}

第五,别忘了 DNS-prefetch 与 preload 标签里的 URL。WordPress 6.0 之后 wp_resource_hints 钩子会自动给 <link rel="dns-prefetch"> 加版本号?不会。但插件可能会。如果你看到 preload 标签也有 ver 参数,需要再 hook 一下 wp_preload_resources 过滤器做相同处理。

配合服务器层一起加固

光在 PHP 层去版本号还不够。完整的"隐藏 WordPress 指纹"清单应该还包括:

  1. 删除 /readme.html/license.txt/wp-config-sample.php 这些静态文件。
  2. 在 Nginx 或 Apache 层屏蔽 /wp-includes/ 下的 PHP 直接访问。
  3. /wp-login.php 改成自定义路径(用 WPS Hide Login 之类的插件)。
  4. 在 HTTP 响应头里去掉 X-Powered-By: PHP/x.x.x
  5. 启用 WAF(Cloudflare、阿里云盾等)阻断常见的版本指纹扫描请求。
  6. 禁用 XML-RPC(除非你在用 Jetpack 等明确依赖它的服务)。
  7. 禁用 REST API 的用户列表端点 /wp-json/wp/v2/users

这些步骤合在一起,才能让一个普通的扫描工具识别不出你跑的是 WordPress、用了什么版本、装了哪些插件。单独做某一项,效果都很有限。我的实测结论:把上面 7 项都做完,再用 WPScan 扫自己的站,输出会变成"无法确定 WordPress 版本",从被自动化攻击的角度看价值很大。

真实案例:去版本号给 CDN 命中率带来的提升

我手上有一个流量稍大的 WordPress 站点,在 2025 年初做这次改造前后,对比了同一台服务器同一时间段的 Cloudflare 缓存命中率:

  • 改造前:CDN HIT 率 78.4%,源站日均 PHP 请求 24 万次。
  • 方案一上线一周后:CDN HIT 率 94.2%,源站日均 PHP 请求 8.6 万次。
  • 同期 LCP 中位数:1.93 秒 → 1.42 秒。

命中率提升的根本原因不是去掉了 ver,而是同一个 URL 不再因为 WP 升版本而变化。原本每次 WP 小版本更新(一个月里很常见)所有静态资源 URL 都换一次,CDN 缓存全失效。换成内容哈希后,只有真改了文件的资源才换 URL,其余继续命中。

改完之后怎么验证

每次改完 functions.php 我会按下面这个清单跑一遍,确保改造完全落地:

  1. 无痕模式打开站点首页,Ctrl+U 看源码,搜索 ver=,应该一条都没有;如果用方案一,搜索 ?v= 应该有结果且形如 ?v=a3f8c2d1 这种 8 位十六进制。
  2. 同样的检查用在 /wp-login.php 这一页。早期我经常忽略后台登录页,结果攻击者从这一页拿到版本号继续打。
  3. 查看 view-source:https://站点/feed/,确认 <generator> 节点为空。
  4. 访问 /readme.html,应该 404 或 403。
  5. 用 curl 看响应头:curl -I https://站点/,确认没有 X-Powered-ByX-Generator
  6. 用 WPScan 自查:wpscan --url https://站点 --random-user-agent --enumerate vp,vt,正常情况下应该输出"无法识别版本"。
  7. 清完 CDN 缓存后再走一遍以上流程,确认 CDN 边缘节点缓存的不是老版本。

常见问题解答

去掉版本号会影响 SEO 吗?

不会。Google 抓取页面时只看 URL 是否能正常返回 200,参数有没有 ver 完全无关。事实上,去掉 ver 还能让 CDN 缓存命中率更高,间接提升页面加载速度,对 Core Web Vitals 是正向的。我自己站点的 LCP 在改造后从 1.93s 降到 1.42s,PageSpeed Insights 评分也跟着涨。

方案一里的 md5_file 会不会让服务器 IO 压力变大?

在没有任何缓存的极端情况下会有一点点开销,但实际上几乎所有 WordPress 站点都启用了 OPcache 和页面缓存,整个页面 HTML 会被缓存一段时间,md5_file 不会每次请求都执行。我自己的站点单页 QPS 上百,没有观察到任何性能差异。如果你的站点真的没用任何缓存而又担心 IO,可以把 md5_file 改成 filemtime,开销更小但安全性略弱(mtime 可能因为部署工具变化)。

我用了 Cloudflare,还需要在 PHP 层去版本号吗?

需要。Cloudflare 默认不会改写 HTML 里的 URL 参数。你可以用 Cloudflare 的 Workers 或 Transform Rules 做改写,但从可维护性角度看,在源站层处理是最干净的方案。Workers 改写还会引入额外的边缘计算费用,没必要。

删除 ver 之后,浏览器还会缓存这些静态文件吗?

会。浏览器的 HTTP 缓存是基于响应头里的 Cache-ControlETagLast-Modified,和 URL 上有没有 ver 没有直接关系。不过去掉 ver 之后,文件更新时浏览器要靠 ETag 或 mtime 判断,可能会有一次额外的条件请求(304)。这是为什么我推荐方案一——它本质上是用内容哈希代替版本号,缓存策略最干净。

方案一的 hash 用 md5 而不是 sha256,有安全风险吗?

没有。这里 md5 不是用来做密码学签名的,它只是一个内容指纹,用于在文件内容变化时改变 URL。md5 在这个用途下完全够用,而且比 sha256 更快。如果你强迫症一定要换 sha256,把 md5_file 换成 hash_file('sha256', ...) 就行,前 8 位足够避免碰撞。

用了哈希之后还需要保留 ?ver= 还是用 ?v= 更好?

都行。我推荐 ?v= 是因为参数名和 WP 默认的 ?ver= 区分开,扫描器看到 ?ver= 会条件反射地认为是 WP,看到 ?v= 会犹豫一下。语义上两者完全等价,不影响 CDN 和浏览器的缓存判断。

主题升级时缓存会自动刷新吗?

方案一会自动刷新,因为 main.css 等文件被你或 WP 升级写过新内容,md5 自然变化。方案二、三、四不会,需要手动在 CDN 控制台清缓存。这也是为什么我建议生产环境用方案一为主。

写在最后

隐藏 WordPress 版本号本身不是什么银弹,但它是"纵深防御"思路里很基础的一环。一个攻击者要花多少力气才能确认你站点的版本,就是你给自己争取的反应时间。版本号删掉了,generator 标签也清了,readme 文件也禁访了,再加上日常的及时升级,绝大多数自动化扫描会直接放弃你的站点去找下一个更好下手的目标。

这篇里推荐的方案一是我现在在 zhangwenbao.com 自己用的方案,跑了一年多没出过问题,CDN 命中率也保持在 95% 以上。如果你的站点也想做这一层加固,可以直接拿去用,记得改成你自己的函数前缀避免和别人撞名。装好之后用 view-source 看看页面源码,确认所有 ver 参数都被替换或删除,这次改造就算完成。

分享到
标签
版权声明

本文标题:《WordPress去除JS/CSS版本号的4种安全方法》

本文链接:https://zhangwenbao.com/remove-the-version-number-after-wordpress-loaded-js-and-css-links.html

版权声明:本文原创,转载请注明出处和链接。许可协议: CC BY-NC-SA 4.0

继续阅读
发表评论
分享到微信 或在下方手动填写
支持 Ctrl + Enter 提交