跳至內容
伺服器與 .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 這類主動式混合內容會被瀏覽器直接封鎖,可能在沒有任何可見說明的情況下弄壞版面或功能。圖片、影片這類被動式混合內容通常還是會載入,但鎖頭圖示會被降級,訪客可能看到不安全的提示。兩種都值得修正。