WordPress REST API 安全強化與存取限制實作

把網址結尾加上 /wp-json/wp/v2/users,按下 Enter,畫面跳出一串 JSON——裡面是你站台所有作者的帳號名稱與使用者代號。這不是漏洞,是 WordPress 預設就開著的行為。對攻擊者來說,這一步等於免費拿到了暴力破解的「使用者名單」,接下來只要逐一試密碼。

WordPress REST API 安全的難處在於:你不能直接把整個 API 關掉。Gutenberg 區塊編輯器、媒體上傳、後台的不少操作,全都靠它運作;一刀切的結果通常是編輯器整個壞掉。真正該做的,是搞清楚哪些端點會曝險、用對應的手段收斂存取權,同時不動到網站需要的功能。

這篇會帶你看清楚 REST API 預設曝露了什麼、使用者列舉(user enumeration)有哪幾條路、以及針對部落格、WooCommerce、headless 三種站型分別該怎麼限制存取。每段都附可直接貼進 functions.php 的程式碼,並教你用 curl 自己驗證有沒有生效。

WordPress REST API 預設會曝露哪些資料?

預設情況下,未登入的訪客就能讀到使用者清單、文章、分類、媒體與站台基本設定。REST API 從 WordPress 4.7.0 起內建啟用,/wp-json/ 這個根路徑會列出站台所有已註冊的命名空間(namespace)與可用路由,等於對外公開了一份「這個站有哪些功能可以打」的地圖。

對安全來說,風險最高的是這幾個無需驗證、用瀏覽器直接打開就能看到的 GET 端點:

  • /wp-json/wp/v2/users:回傳所有「曾發表過內容」的使用者,包含顯示名稱、slug(通常等同登入帳號)與使用者代號。這是列舉攻擊的頭號目標。
  • /wp-json/wp/v2/posts:文章內容與中介資料,草稿與私密文章預設不會外流,但已發布內容會全部攤開。
  • /wp-json/wp/v2/media:媒體庫清單,可能洩漏未公開連結的檔案路徑。
  • /wp-json/wp/v2/categories/tags/types/taxonomies:站台結構資訊,本身敏感度低,但會餵給自動化掃描工具做指紋辨識。

自動化掃描器的典型流程是這樣:先打 /wp-json/ 拿到路由地圖,再從 users 端點蒐集帳號、從外掛相關端點推測你裝了哪些外掛與版本,最後拿這份清單去比對已知的 CVE 漏洞清單,挑出可打的目標發動針對性攻擊。換句話說,曝露的端點越多,攻擊者的偵察(reconnaissance)成本越低。

要先確認自己站台目前曝露了什麼,登出後用 curl 打一次就知道:

curl -s https://你的網域/wp-json/wp/v2/users | head

如果回傳的是一串包含 slug 欄位的 JSON 陣列,代表使用者列舉目前是開著的。

為什麼不能直接關掉整個 REST API?

因為 WordPress 核心與多數現代外掛都把 REST API 當成內部通訊管道,整個關掉會連自己的後台一起打壞。最直接的災情是 Gutenberg 區塊編輯器:它載入區塊資料、儲存內容、管理媒體全都透過 REST API 呼叫,API 一斷,編輯器就載不進來,文章與頁面都無法編輯。

除了編輯器,還有一連串功能依賴它運作:

  • 媒體庫的上傳與預覽
  • 不少表單外掛(如 Contact Form 7)的前台送出
  • 站台健康檢查、應用程式密碼(Application Passwords)等核心模組
  • 任何透過 AJAX 與前台互動的外掛功能

所以「關掉 REST API」這個念頭要換成「限制誰、能打哪些端點」。網路上常見的 .htaccess 直接擋掉 /wp-json/ 整段路徑,看似乾淨,實際上有兩個問題:一是會波及上述核心功能,二是擋不乾淨——WordPress 還有另一條 ?rest_route=/wp/v2/users 的查詢字串路徑,就算你在伺服器層擋掉了 /wp-json/,這條路依然打得進去。真正可靠的收斂要做在 PHP 層的過濾器(filter)上,而不是 URL 字串比對。

怎麼擋掉 REST API 的使用者列舉?

最精準的做法是用 rest_endpoints 過濾器,把 users 端點從路由表裡移除,其餘 API 維持正常。這樣 Gutenberg 與依賴 API 的外掛都不受影響,因為它們不需要「未驗證讀取使用者清單」這個功能。把以下程式碼放進佈景主題的 functions.php 或自訂外掛:

add_filter( 'rest_endpoints', function( $endpoints ) {
    if ( isset( $endpoints['/wp/v2/users'] ) ) {
        unset( $endpoints['/wp/v2/users'] );
    }
    if ( isset( $endpoints['/wp/v2/users/(?P<id>[d]+)'] ) ) {
        unset( $endpoints['/wp/v2/users/(?P<id>[d]+)'] );
    }
    return $endpoints;
} );

這段同時移除了「使用者清單」與「單一使用者」兩條路由,登出狀態下兩者都會回傳 404

如果你不想整個移除端點,只想把敏感欄位藏起來(例如仍需要前台顯示作者名稱,但不想洩漏等同帳號的 slug),可以改用 rest_prepare_user 過濾器,依權限決定回傳哪些欄位:

add_filter( 'rest_prepare_user', function( $response, $user, $request ) {
    if ( ! current_user_can( 'list_users' ) ) {
        $data = $response->get_data();
        unset( $data['slug'] );  // slug 通常等同登入帳號
        unset( $data['link'] );  // 作者彙整頁網址,同樣會洩漏帳號
        $response->set_data( $data );
    }
    return $response;
}, 10, 3 );

這裡用 current_user_can( 'list_users' ) 檢查「能力」而不是角色名稱,是刻意的:後面會解釋為什麼用能力判斷比用角色名稱可靠。

作者彙整頁與 ?author=N 也要一起堵

擋掉 REST API 的 users 端點只解決了一條路,使用者列舉還有另一條更古老的入口:作者彙整頁。WordPress 給每個作者一個 你的網域/author/帳號 的網址,而 ?author=1?author=2 這種帶數字的網址會自動 301 轉址到對應作者頁,網址列直接顯示出帳號。攻擊者不需要猜帳號,只要把數字從 1 開始往上跑就能收集一份完整名單。

如果你的站台沒有用到作者彙整頁(單一作者的部落格、或根本沒有部落格的商業站都屬於這類),可以直接把作者頁轉回首頁:

add_action( 'template_redirect', function() {
    if ( is_author() && ! is_user_logged_in() ) {
        wp_redirect( home_url( '/' ), 301 );
        exit;
    }
} );

加上 ! is_user_logged_in() 的條件,是讓登入的編輯者仍能在後台預覽作者頁,只把未登入訪客擋在外面。多作者、且作者頁是內容一部分的站台就不適合整段轉址,這種情況請依賴前面 rest_prepare_user 隱藏 slug 的做法,並搭配後面講的登入頁強化。

不同站型該選哪種存取限制策略?

沒有一體適用的設定,關鍵看你的前台有沒有「公開呼叫 API」的需求。以下用三種常見站型對照,先看一張快速對照,再逐一說明:

站型 公開 API 需求 建議策略
單純部落格 / 形象站 幾乎沒有 移除 users 端點 + 擋作者頁;可進一步要求全 API 登入
WooCommerce 商店 前台結帳需要 Store API 只移除 users,保留 Store API 命名空間,勿全站要求登入
Headless / 前後端分離 前端靠 API 取資料 保留讀取端點,用應用程式密碼管寫入,逐端點收斂

部落格與形象站可以收得最緊

這類站台前台幾乎不靠公開 API 運作,可以採最嚴格的策略:除了前面移除 users 端點、擋作者頁,還能進一步要求「所有 REST API 請求都必須登入」。用 rest_authentication_errors 過濾器處理:

add_filter( 'rest_authentication_errors', function( $result ) {
    // 已被其他驗證流程處理過就不覆蓋
    if ( true === $result || is_wp_error( $result ) ) {
        return $result;
    }
    if ( ! is_user_logged_in() ) {
        return new WP_Error(
            'rest_not_logged_in',
            '需登入後才能存取 REST API。',
            array( 'status' => 401 )
        );
    }
    return $result;
} );

開頭那段 true === $result || is_wp_error( $result ) 不能省:它確保前面若已有其他驗證器(例如應用程式密碼)放行或拒絕了請求,這支過濾器就不去蓋掉結果。少了這個判斷,會把合法的已驗證請求也一起擋掉。要注意這種「全 API 要求登入」會同時擋掉前台所有匿名 AJAX 呼叫,套用前先確認佈景主題與外掛沒有依賴公開端點。

WooCommerce 商店不能把 API 鎖死

WooCommerce 的前台結帳、購物車與商品互動,新版會走 Store API(命名空間 wc/store),這段必須對未登入的買家開放,否則結帳流程會壞掉。所以商店站不能套用上面「全 API 要求登入」的程式碼。正確做法是只精準移除 users 端點,並在限制存取時把需要的命名空間列為例外。

如果你用安全外掛(例如 WP Cerber)的「封鎖 REST API」功能,記得把會用到 API 的外掛命名空間加進白名單,常見的有結帳相關的 wc/store、表單外掛的 contact-form-7yoast 等。漏掉哪個,對應功能就會回 403。涉及金流的部分這裡只點到為止——商店站在收斂 API 前,務必先在測試環境跑一次完整下單流程,確認沒有把結帳需要的端點一起擋掉。

Headless 站要靠應用程式密碼管寫入

前後端分離的 headless 架構,前端整個靠 REST API 取資料,所以不能要求全站登入。這類站的收斂重點是分開「讀」與「寫」:公開的讀取端點(文章、頁面)保留,但寫入、刪除與管理類操作必須驗證。

WordPress 5.6 起內建的應用程式密碼(Application Passwords)就是為這個情境設計的。它的特性適合外部程式存取:每組密碼綁定單一使用者帳號、可單獨撤銷、而且不能拿來登入 wp-admin 後台,因此比直接把主帳號密碼交給第三方服務安全。產生方式是到「使用者 → 個人資料」頁面,捲到「應用程式密碼」區塊,給一個好辨識的名稱(例如「部署腳本」「行動 App」)後儲存,產生的密碼只會顯示一次,要當下記下來。外部請求用 HTTP Basic Auth 帶上你的帳號與這組應用程式密碼即可驗證。仍用不到的 users 端點,照樣用 rest_endpoints 移除。

用能力判斷,不要用角色名稱

限制 API 存取時若要分權限,請檢查「能力」(capability)而不是角色名稱(role),這是很多教學會踩到的坑。例如想只放行編輯者與管理員,正確寫法是檢查 edit_posts 這個能力:

add_filter( 'rest_authentication_errors', function( $result ) {
    if ( true === $result || is_wp_error( $result ) ) {
        return $result;
    }
    if ( ! is_user_logged_in() ) {
        return new WP_Error( 'rest_not_logged_in', '需要驗證。', array( 'status' => 401 ) );
    }
    if ( ! current_user_can( 'edit_posts' ) ) {
        return new WP_Error( 'rest_forbidden', '權限不足。', array( 'status' => 403 ) );
    }
    return $result;
} );

訂閱者(subscriber)與投稿者(contributor)沒有 edit_posts 能力,會被擋下。反過來說,current_user_can( 'subscriber' ) 這種寫法是檢查角色名稱、不是能力,在自訂角色或外掛改過權限結構的站台上會失準。用能力判斷的好處是:不管角色叫什麼名字,只要它實際擁有那個能力就放行,邏輯跟著權限走而不是跟著名稱走。

關於使用者端點還有一個容易忽略的細節:即使保留 users 端點,加上 ?context=edit 參數會要求更高權限才回傳完整欄位(含 email、角色)。預設未登入請求只會拿到公開欄位,但這也是為什麼「隱藏 slug」仍有意義——公開欄位裡的 slug 就足以洩漏登入帳號。

改完之後怎麼驗證真的生效了?

每改一段都要登出後實測,不要假設程式碼貼上去就一定有效。用 curl 比用瀏覽器可靠,因為你能直接看到 HTTP 狀態碼,也不會被瀏覽器快取干擾。登出狀態下依序打以下幾個網址:

# 1. 看路由地圖還曝露什麼
curl -s -o /dev/null -w "%{http_code}n" https://你的網域/wp-json/

# 2. 使用者列舉是否已擋下(應回 404 或 401)
curl -s -o /dev/null -w "%{http_code}n" https://你的網域/wp-json/wp/v2/users

# 3. 別忘了測查詢字串那條路
curl -s -o /dev/null -w "%{http_code}n" "https://你的網域/?rest_route=/wp/v2/users"

# 4. 作者列舉是否已轉址(應回 301 轉回首頁)
curl -s -o /dev/null -w "%{redirect_url}n" "https://你的網域/?author=1"

確認被限制的端點回的是 401403,而不是一串使用者 JSON。第三條 ?rest_route= 特別重要,它驗證的是你的防護做在 PHP 過濾器層、而不只是擋 URL 字串——如果這條還能列出使用者,代表你的防護有漏洞。

驗證完限制有效,再登入後台確認反向沒有打壞東西:進 Gutenberg 編輯一篇文章看編輯器能不能正常載入、上傳一張圖到媒體庫、跑一次依賴 API 的外掛功能。兩邊都通過,這次收斂才算完成。

REST API 的安全不是「裝個外掛打勾」就結束的事,而是「曝露面盤點 → 對應收斂 → 實測驗證」的循環。先用 curl 摸清楚自己站台現在攤開了哪些端點,再依站型選對策略:部落格收緊、商店保留結帳命名空間、headless 用應用程式密碼管寫入。改完務必登出實測那三條列舉路徑,確認回的是錯誤碼而非資料。把這個流程跑過一輪,你擋掉的就不只是一個端點,而是攻擊者整條偵察鏈的起點。

相關文章
標籤: functions.php, 網站資安, WordPress REST API, 使用者列舉, 存取限制