跳到正文
服务器与 .htaccess

迁到 https 之后的 WordPress 混合内容问题

清掉迁移 https 后残留的 WordPress 混合内容警告:找出数据库里剩下的 http:// 网址,安全替换,并在 .htaccess 里强制 https。

发布于

证书装好了,站点也能通过 https 打开,可小锁标志就是不肯出现。这就是混合内容:页面本身是通过 https 来的,但页面里的某个东西 —— 一张图片、一个样式表、一段脚本 —— 仍然在用明文 http:// 请求。本文会带你找出这些网址,在不破坏序列化数据的前提下替换掉,修正 siteurlhome,并在服务器层强制 https,让问题不会卷土重来。

混合内容到底是什么

装证书改变的是连接的加密方式,它并不改变你的页面去请求什么。迁移之前被写进文章、小工具或主题设置里的每一个 http://yoursite.com/wp-content/uploads/logo.png,现在依然躺在数据库里,浏览器则老老实实地用不安全的连接去请求它。

浏览器对两类资源的处理不同,在判断这件事有多紧急时,这个区分很关键:

  • 主动混合内容 —— 脚本、样式表、iframe、XHR。直接拦截。这就是为什么一个站点迁到 https 之后看起来坏掉了,却哪里都找不到错误提示:某个样式表被悄悄拒绝了。
  • 被动混合内容 —— 图片、音频、视频。通常仍会加载,但小锁标志会被降级,有些浏览器会显示「不安全」提示。

两类都值得修,只是会真正弄坏东西的只有第一类。

第一步:查清还有什么是不安全的

从浏览器入手。打开页面,打开开发者工具,读控制台。每一个被拦截或被降级的请求都会连同完整网址列在那里。这告诉你什么不安全,但没告诉你它存在哪里。

要弄清这一点,就得直接看数据库。导出它,然后搜索导出文件:

wp db export dump.sql
grep -o "http://example\.com[^\"']*" dump.sql | sort -u | head -50

如果用不了 WP-CLI,mysqldump 也能生成同样的文件:

mysqldump -u USER -p DBNAME > dump.sql

读一读返回的那些去重后的网址。上传目录的路径指向文章内容和元数据。主题资源的路径通常指向选项表。凡是你不认识的域名,都是外部嵌入内容,需要另一种处理方式(下文会讲)。

第二步:先修 siteurl 和 home

siteurlhome 是 WordPress 用来拼装它生成的几乎所有内部网址的两个选项。只要其中之一还写着 http://,无论数据库其余部分清理得多干净,WordPress 都会继续输出不安全的网址。

检查一下:

wp option get siteurl
wp option get home

用 sql 的话:

SELECT option_name, option_value
FROM wp_options
WHERE option_name IN ('siteurl', 'home');

这两个是纯字符串,不是序列化数组,所以在这里直接用 sql 更新确实是安全的:

UPDATE wp_options
SET option_value = REPLACE(option_value, 'http://example.com', 'https://example.com')
WHERE option_name IN ('siteurl', 'home');

动手之前有一个坑。如果 wp-config.php 里定义了 WP_HOMEWP_SITEURL,这两个常量会完全覆盖数据库,你的更新看上去就像什么都没发生:

define( 'WP_HOME', 'https://example.com' );
define( 'WP_SITEURL', 'https://example.com' );

要么在那里改,要么把它们删掉,让数据库说了算。先查这一项 —— 很多「我改了但什么都没变」的情况都是它。

第三步:替换必须能处理序列化数据

数据库里的其余部分才是真正的风险所在,而且这个风险和大多数人预想的不一样。危险不在于替换漏掉了网址,而在于它在文本层面替换成功了,却毁掉了周围的数据。

WordPress 把数组和对象存成 PHP 序列化字符串,而这种格式会记录它包含的每个字符串的字节长度:

a:1:{s:4:"logo";s:38:"http://example.com/img/logo.png";}

用原生 sql 的 REPLACEhttp:// 换成 https://,文本长了一个字节,声明的长度却还停在旧值。PHP 读取这个长度,往前走这么多字节,在它预期的位置找不到结束符,于是拒绝反序列化整个数组。接着 WordPress 交给主题或插件的,就是一个表现得像从未保存过这项设置的值。

症状不是报错,而是小工具不见了、自定义器设置被重置、页面构建器的排版渲染成一片空白 —— 而后台没有任何地方提示出了问题。没有备份就想从这种状态里恢复,是真的痛苦,所以先做一份:

wp db export backup-before-https-replace.sql

然后使用一个会先反序列化、在解码后的结构内部替换、再用修正后的长度重新序列化的工具。先空跑一次:

wp search-replace 'http://example.com' 'https://example.com' --dry-run --all-tables --skip-columns=guid

读一读报告。如果某张你不认识的表出现了成千上万条命中,先停下来看看再提交。确认没问题后再真正执行:

wp search-replace 'http://example.com' 'https://example.com' --all-tables --skip-columns=guid --report-changed-only

--skip-columns=guid 很重要:guid 是给订阅阅读器用的永久标识符,不是一个活的网址,重写它可能让订阅者把你整个归档都当成新文章。如果你想在动手之前先看清哪些列可以用普通 sql 处理、哪些列必须用能识别序列化的方式处理,可以用 WordPress 搜索替换 sql 工具来生成语句。

如果你用不了命令行,那就选一个明确声明能处理序列化数据的迁移插件,它会在后台界面里做同样的解码工作。没有明确这么写的工具,就当它不支持。

第四步:在服务器层强制 https

清理数据库只能让你的页面不再请求不安全的资源,它拦不住访客一开始就从 http:// 进来。那属于服务器层的重定向,在 Apache 上归 .htaccess 管:

# BEGIN Force HTTPS
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
</IfModule>
# END Force HTTPS

关于位置有两点。把它放在 # BEGIN WordPress 标记上方 —— 标记里面的东西每次有人保存固定链接都会被丢弃。另外,只有在 mod_rewrite 已启用、且虚拟主机允许覆盖(AllowOverride All,至少要有 FileInfo)时它才生效。在 nginx 上 .htaccess 会被完全忽略,这段配置得写进 server 块里。

RedirectMatch 干不了这活。它只按请求路径匹配,没有任何办法判断当前请求是否已经是安全的,结果就是把 https 请求重定向回它自己。上面那条带条件的 RewriteRule 才是对的工具。

重定向死循环,以及它为什么会发生

如果你一加上那条规则站点就开始无休止地重定向,说明你的 TLS 是在上游某处终止的 —— 负载均衡器、反向代理或 CDN —— 然后它把明文 http 转发给 Apache。Apache 看到一个不安全的请求,重定向到 https,代理又转发了一次 http,如此往复。

改成判断转发请求头:

RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]

在这种架构下 WordPress 自己也有同样的盲区 —— is_ssl() 读的是 $_SERVER['HTTPS'],而代理从来不会设置它,于是后台网址就成了 http://。在 wp-config.php 里、那句「停止编辑」注释的上方,加上这段:

if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO'] ) {
	$_SERVER['HTTPS'] = 'on';
}

风险也要说明白:X-Forwarded-Proto 是客户端可以自行提供的请求头。只有当一个你自己掌控的代理总会覆盖它时,信任它才是正确的。在一台可以从公网直接访问的服务器上,它是可以被伪造的。

数据库替换解决不了的东西

  • 写死在文件里的网址。 凡是写进 functions.php、子主题模板或内联资源路径里的,都不在数据库里。主题目录要单独 grep 一遍。
  • 转义过的 JSON。 有些构建器把网址存成 https:\/\/example.com。按常规写法去搜完全找不到,可能需要针对转义形式再跑一遍。
  • 外部资源。 只提供 http 的第三方脚本或嵌入内容,你这边修不了。要么找到 https 版本,要么弃用它。
  • 各级缓存。 页面缓存、对象缓存和 CDN 上的副本,在你正确替换之后仍会继续输出旧的 HTML。在断定替换失败之前,把这三样都清一遍。

有一件事可以跳过:upgrade-insecure-requests 这个 Content Security Policy 响应头会在浏览器里重写不安全的请求,从而让警告消失。但它治的是症状,错误的网址仍然留在数据库里,下一次导出、迁移或输出订阅源时又会被原样带走。先把数据修对,之后如果你愿意,再把它当成安全网来用。

还是没解决?

每完成一步都重新检查一次 siteurlhome —— 插件和迁移工具有时会在你背后把它们改回去。如果控制台点名的某个不安全网址你在导出文件里怎么都找不到,那就查看页面源代码并在那里搜索:如果它出现在生成的 HTML 里却不在数据库中,说明它是在 PHP 里拼出来的,要修的就是产生它的那个主题或插件。

FAQ

常见问题

我已经装了 SSL 证书,WordPress 站点为什么还在提示混合内容?

证书只改变连接的加密方式,不会改变你的页面去请求什么。旧的 http:// 网址仍然存在数据库里,藏在文章内容、小工具选项和主题设置中。浏览器通过 https 加载页面,却发现某张图片或某个脚本是用明文 http 请求的,于是在一个本来安全的页面上报告混合内容。

怎么找出数据库里还残留哪些 http:// 网址?

先用浏览器打开页面,看开发者控制台,它会把每个不安全的请求连同网址一起列出来。然后导出数据库,在导出文件里搜索带 http 前缀的自有域名。统计每张表命中的条数,就能判断问题是出在文章内容里、wp_options 里,还是某些你没想到的插件表里。

能不能用普通的 sql 搜索替换来修混合内容?

只有 siteurl、home 这类标量值可以。凡是 WordPress 以序列化数组形式存储的内容(大多数选项值和元数据都属于这一类),都会记录其中每个字符串的字节长度。直接替换会改掉文本却留下旧的长度值,随后反序列化失败,该设置就会悄悄退回默认值。

在 WordPress 里强制 https 的 .htaccess 规则是什么?

一条带 https 变量判断条件的 RewriteRule,放在 BEGIN WordPress 标记上方,这样保存固定链接时才不会被清掉。在代理或 CDN 后面,即使是安全请求,https 变量读出来也是 off,所以要改成判断 X-Forwarded-Proto 请求头,否则这条规则会一直重定向下去。

我强制 https 之后站点陷入了重定向死循环,为什么?

几乎可以肯定,你的 TLS 是在某个代理或 CDN 上终止的,它再把明文 http 转发给 Apache。重写规则看到一个不安全的请求,就重定向到 https,代理又转发了一次 http,循环就这样往复。把条件换成判断 X-Forwarded-Proto 请求头,并在 wp-config.php 里设置 https 服务器变量。

混合内容是会让整个页面出问题,还是只影响小锁标志?

取决于是什么资源。脚本、样式表和 iframe 这类主动混合内容会被浏览器直接拦截,可能在没有任何可见提示的情况下弄坏排版或功能。图片、视频这类被动混合内容通常还能加载,但小锁标志会被降级,访客可能看到不安全的提示。两类都值得修。