本篇文章更新時間:2023/03/25
如有資訊過時或語誤之處,歡迎使用 Contact 功能通知。
一介資男的 LINE 社群開站囉!歡迎入群聊聊~
如果本站內容對你有幫助,歡迎使用 BFX Pay 加密貨幣 或 新台幣 贊助支持。
看過前兩篇 [WordPress] 外掛開發入門指南 與 [WordPress] 外掛基礎知識篇 – 外掛開發者都需要來讀一次的文件 後
恭喜你,你的程式碼可運作!但是它是否安全呢?
WordPress 開發團隊嚴謹看待安全性。在網站設計中,安全性有極大的重要性,因此安全性也是必須要關注的核心。儘管核心開發人員已經有一個專門團隊專注保護平台安全,但是像你這樣的主題或外掛開發人員也很清楚,核心外部可能存在許多潛在的漏洞。由於 WordPress 提供了很多功能和靈活性,外掛和主題成了最容易被攻擊的點。
當程式碼在數百,甚至數千個網站上執行時,你應該特別小心處理輸入 WordPress 的資料以及它如何呈現給最終使用者。這個安全性問題通常在你建立的主題設定頁面、建立使用短碼或儲存和顯示與文章相關的額外資料時出現。
內容目錄
- 1 發展安全意識
- 2 引導原則
- 3 檢查使用者權限(Capabilities)
- 4 資料驗證
- 5 清理 (Sanitizing) 資料
- 6 逸出轉譯資料
- 6.1 逸出轉譯的函式
- 6.2 客製化逸出轉譯的範例
- 6.3 總是最後才執行轉譯
- 6.4 除非... 你無法這麼做
- 6.5 帶有本地化函式的轉譯
- 6.6 範例
- 6.6.1 任何數值變數在任何地方使用都需要進行轉譯處理
- 6.6.2 將 HTML 屬性內的任意變數進行轉譯
- 6.6.3 在 HTML 屬性中跳脫任意 URL,在其他情境下也同樣適用
- 6.6.4 透過 wp_localize_script() 方法傳遞任意 JavaScript 變數
- 6.6.5 轉譯 JavaScript 程式碼區塊中的任意變數
- 6.6.6 在內嵌的 JavaScript 程式碼中轉譯任意變數
- 6.6.7 在 HTML 屬性中轉譯任意變數以供 JavaScript 使用
- 6.6.8 轉譯 HTML textarea 多行輸入元素中的任意字串
- 6.6.9 轉譯帶有任意標籤的 HTML 字串
- 6.6.10 在 XML 或 XSL 格式中的上下文中轉譯任意字串
- 7 Nonces
- 8 常見的漏洞
- 9 完整的安全性範例
發展安全意識
開發時,考量新功能的安全性是非常重要的。開發過程中請使用以下原則:
- 不要相信任何資料。 不要相信用戶輸入、第三方 API 或資料庫中的資料而沒有驗證。保護你的 WordPress 主題,確保進入和離開你的主題的資料是正確的。在使用者輸入資料之前,必須進行
驗證
和清理
(sanitize),輸出時進行編碼逸出轉譯
(escape)。 - 使用 WordPress API。 許多 WordPress 核心功能提供了內建驗證和清理資料的功能。當可能發生安全性問題時,應使用 WordPress 提供的功能。
- 保持更新。 隨著技術的進步,外掛或主題中新的安全漏洞也可能隨之而來。保持警惕,透過維護你的程式碼並在必要時更新來避免安全風險。
引導原則
- 永遠不要相信使用者輸入的資料。
- 盡可能地進行逸出轉譯(Escape)。
- 逸出轉譯(Escape) 所有來自不可信的來源(例如資料庫和使用者)、第三方(例如 Twitter)等。
- 絕不要假設任何事情,驗證它。
- 進行清理(Sanitation)輸入資料,但是驗證或拒絕是更好的做法。
檢查使用者權限(Capabilities)
如果你的外掛允許使用者提交資料,無論是在後台管理還是前端操作,都應該檢查用戶權限。
使用者角色與權限
架構高效安全層級最重要的一步是設置用戶權限系統。WordPress 透過『使用者角色與權限』(User Roles and Capabilities)功能實現了這一點。
每個登入到 WordPress 的使用者根據其使用者角色會自動被分配指定的使用者權限。
「使用者角色(User roles)」只是一種描述使用者所屬群組的方式。每個群組都有一組預先定義的權限。
例如,你網站的主要使用者將具有「管理員(Administrator)」的使用者角色,而其他使用者可能會擁有像「編輯(Editor)」或「作者(Author)」的角色。你可以指派多個使用者到同一個角色中,例如網站可能會有兩個管理員。
「用戶權限」是指你專為每個用戶或用戶角色分配的具體權限。
例如,管理員具有「manage_options」權限,這使他們可以查看、編輯和保存網站的設定。相反的,編輯人員無此權限,可以防止他們越權操作。
這些權限會在管理後台中的各個操作點進行檢查。依據分配給角色的權限,選單、功能與其他期望的體驗也許是新增或是刪除。
建立一個外掛時,請務必確保當前的使用者只擁有必要的權限,才可以執行你的程式。
權限階層
使用者角色層級越高,使用者就有越多的功能。每個使用者角色都繼承了層次結構中之前的角色。
例如,對於單站點的安裝,最高的使用者角色為「管理員」,該角色會繼承以下角色及其權限:「訂閱者」、「投稿者」、「作者」和「編輯」。
範例
不限制版本
下面的範例會在前端建立一個連結,允許將文章放入垃圾桶。因為這段程式碼沒有檢查使用者的權限,所以它允許任何訪問該網站的訪客將文章放入垃圾桶!
/**
* Generate a Delete link based on the homepage url.
*
* @param string $content Existing content.
*
* @return string|null
*/
function wporg_generate_delete_link( $content ) {
// Run only for single post page.
if ( is_single() && in_the_loop() && is_main_query() ) {
// Add query arguments: action, post.
$url = add_query_arg(
[
'action' => 'wporg_frontend_delete',
'post' => get_the_ID(),
], home_url()
);
return $content . ' ' . esc_html__( 'Delete Post', 'wporg' ) . '';
}
return null;
}
/**
* Request handler
*/
function wporg_delete_post() {
if ( isset( $_GET['action'] ) && 'wporg_frontend_delete' === $_GET['action'] ) {
// Verify we have a post id.
$post_id = ( isset( $_GET['post'] ) ) ? ( $_GET['post'] ) : ( null );
// Verify there is a post with such a number.
$post = get_post( (int) $post_id );
if ( empty( $post ) ) {
return;
}
// Delete the post.
wp_trash_post( $post_id );
// Redirect to admin page.
$redirect = admin_url( 'edit.php' );
wp_safe_redirect( $redirect );
// We are done.
die;
}
}
/**
* Add the delete link to the end of the post content.
*/
add_filter( 'the_content', 'wporg_generate_delete_link' );
/**
* Register our request handler with the init hook.
*/
add_action( 'init', 'wporg_delete_post' );
限制特殊權限的版本
上述的範例允許任何訪客點擊「刪除」連結並將文章刪除。然而,我們只希望「編輯」權限及以上的使用者能夠點擊「刪除」連結。
為了實現這個目標,我們將檢查當前使用者是否具備 edit_others_posts
權限,而這個權限只有「編輯」權限及以上的使用者才具備。
/**
* Generate a Delete link based on the homepage url.
*
* @param string $content Existing content.
*
* @return string|null
*/
function wporg_generate_delete_link( $content ) {
// Run only for single post page.
if ( is_single() && in_the_loop() && is_main_query() ) {
// Add query arguments: action, post.
$url = add_query_arg(
[
'action' => 'wporg_frontend_delete',
'post' => get_the_ID(),
], home_url()
);
return $content . ' ' . esc_html__( 'Delete Post', 'wporg' ) . '';
}
return null;
}
/**
* Request handler
*/
function wporg_delete_post() {
if ( isset( $_GET['action'] ) && 'wporg_frontend_delete' === $_GET['action'] ) {
// Verify we have a post id.
$post_id = ( isset( $_GET['post'] ) ) ? ( $_GET['post'] ) : ( null );
// Verify there is a post with such a number.
$post = get_post( (int) $post_id );
if ( empty( $post ) ) {
return;
}
// Delete the post.
wp_trash_post( $post_id );
// Redirect to admin page.
$redirect = admin_url( 'edit.php' );
wp_safe_redirect( $redirect );
// We are done.
die;
}
}
/**
* Add delete post ability
*/
add_action('plugins_loaded', 'wporg_add_delete_post_ability');
function wporg_add_delete_post_ability() {
if ( current_user_can( 'edit_others_posts' ) ) {
/**
* Add the delete link to the end of the post content.
*/
add_filter( 'the_content', 'wporg_generate_delete_link' );
/**
* Register our request handler with the init hook.
*/
add_action( 'init', 'wporg_delete_post' );
}
}
資料驗證
不可信的資料來自多個來源(使用者、第三方網站,甚至你自己的資料庫!)所有資料在使用前都需要檢查。
請記住:即使是管理員也是用戶,用戶會輸入錯誤的資料,無論是故意還是偶然。你的工作是保護使用者不受自己(或別人)的傷害。
驗證
輸入是將資料根據預定格式(或模式)進行測試並得出明確結果(有效或無效)的過程。相對於逸出轉譯化,驗證是一種更具體的方法,但兩者都有其作用。
簡單的驗證範例:
- 確認必填欄位不留空
- 檢查輸入的電話號碼是否只包含數字和連結符號
- 確認輸入的字串是否為有效的選項之一
- 確認數量欄位是否大於 0
資料驗證應盡可能在最早期進行。 這意味著在執行任何操作之前對資料進行驗證。
驗證的哲學
關於驗證,有幾種不同的哲學觀點在聊「如何驗證」,每種觀點都適用於不同的情境。
安全名單、白名單
只接受資料從已知且可信的列表輸入。
當不信任的資料與安全列表進行比較時,重要的是確保使用嚴格的類型檢查。否則,攻擊者可能會以某種方式包裝輸入,以使其通過安全列表,但仍具有惡意效果。
比較運算元
$untrusted_input = '1 malicious string'; // will evaluate to integer 1 during loose comparisons
if ( 1 === $untrusted_input ) { // 使用「==」 比對的話會回傳 true, 但 「===」會回傳 false
echo 'Valid data';
} else {
wp_die( 'Invalid data' );
}
in_array()
$untrusted_input = '1 malicious string'; // will evaluate to integer 1 during loose comparisons
$safe_values = array( 1, 5, 7 );
if ( in_array( $untrusted_input, $safe_values, true ) ) { // 第三個參數 `true` 會打開強型別檢查
echo 'Valid data';
} else {
wp_die( 'Invalid data' );
}
switch()
$untrusted_input = '1 malicious string'; // will evaluate to integer 1 during loose comparisons
switch ( true ) {
case 1 === $untrusted_input: // 使用自己的強型別檢查比使用 switch 的方法好
echo 'Valid data';
break;
default:
wp_die( 'Invalid data' );
}
黑名單
拒絕來自已知不受信任的資料列表。通常不是一個好主意。
格式檢查
測試資料是否符合正確的格式,只有當符合格式時才予以接受。
if ( ! ctype_alnum( $data ) ) {
wp_die( "Invalid format" );
}
if ( preg_match( "/[^0-9.-]/", $data ) ) {
wp_die( "Invalid format" );
}
格式校正
接受大部分資料,但移除或是逸出轉譯可能的危險部分。
$trusted_integer = (int) $untrusted_integer;
$trusted_alpha = preg_replace( '/[^a-z]/i', "", $untrusted_alpha );
$trusted_slug = sanitize_title( $untrusted_slug );
範例一
讓我們來設計一個輸入欄位,用來輸入 US 美國的郵遞區號:
在這裡,我們告訴瀏覽器只允許輸入最多十個字符,但是在輸入哪些字符上沒有限制。他們可以輸入 11221
或 eval()
。
這就是驗證的作用。在處理表單時,我們寫程式來檢查每個字串的正確資料類型,如果不正確,就將其丟棄。
例如:為了檢查my-zipcode
字段,我們可以進行以下操作:
/**
* Validate a US zip code.
*
* @param string $zip_code RAW zip code to check.
*
* @return bool true if valid, false otherwise.
*/
function wporg_is_valid_us_zip_code( string $zip_code ):bool {
// Scenario 1: empty.
if ( empty( $zip_code ) ) {
return false;
}
// Scenario 2: more than 10 characters.
// The `maxlength` attribute is only enforced by
// the browser, so we still need to validate the
// length of the input on the server to protect
// against a manual submission.
if ( 10 < strlen( trim( $zip_code ) ) ) {
return false;
}
// Scenario 3: incorrect format.
if ( ! preg_match( '/^d{5}(-?d{4})?$/', $zip_code ) ) {
return false;
}
// Passed successfully.
return true;
}
然後,在處理表單時,你的程式碼應該檢查 wporg_zip_code
欄位,並根據結果執行相應的動作:
if ( isset( $_POST['wporg_zip_code'] ) && wporg_is_valid_us_zip_code( $_POST['wporg_zip_code'] ) ) {
// $_POST['wporg_zip_code'] is valid; carry on
}
注意,這個具體的例子是在檢查提供的資料是否為正確的格式;它並不是在檢查資料是否為有效的郵遞區號。為了達到這個目的,你需要再寫一個函式來將其與一個有效郵遞區號列表進行比較。
範例二
假設你的程式碼會查詢資料庫中的文章,而你希望允許使用者對查詢結果進行排序。
$allowed_keys = array( 'author', 'post_author', 'date', 'post_date' );
$orderby = sanitize_key( $_POST['orderby'] );
if ( in_array( $orderby, $allowed_keys, true ) ) {
// $orderby is valid; carry on
}
此範例程式碼會透過比對鍵值排序的陣列,確認傳入鍵值排序(紀錄在 orderby
輸入參數中)的有效性,以防止使用者傳入任意且可能有害的資料。
在將傳入的鍵值排序與陣列比對前,會先使用 WordPress 內建的 sanitize_key()
函式處理鍵值。此函式確保(除其他處理之外)鍵值全為小寫,因為 in_array()
進行區分大小寫的搜尋。
將 true
傳入 in_array()
第三個參數即啟用嚴格型別檢查,讓該函式不僅比較值,也比較值的型別。透過此方式,程式碼可以確信傳入的鍵值排序是字串類型,而非其他類型的資料。
驗證資料會使用的函式
大部分的驗證都是在自定義的程式碼中進行,但也有一些輔助功能。這些輔助功能是此清理
(Sanitation)頁面上列出的功能之外的。
balanceTags( $html )
或force_balance_tags( $html )
– 用於嘗試確保 HTML 標籤平衡,有開頭有結尾,用以產生有效的 XML 輸出。count()
用於檢查陣列中有多少項目in_array()
用於檢查某個項目是否存在於陣列中is_email()
用於驗證電子郵件地址是否有效。in_array()
用於檢查某個項目是否存在於陣列中mb_strlen()
或strlen()
用於檢查字串的預期字元數preg_match()
、strpos()
用於在其他字串中檢查某些字串是否存在sanitize_html_class( $class, $fallback )
– 對 HTML 中的 Class 屬性值進行逸出轉譯,確保只包含有效字元。將字串逸出轉譯成 A-Z、a-z、0-9、'-' 等字元,如此逸出轉譯產生空字串,則會返回提供的替代值。tag_escape( $html_tag_name )
– 對 HTML 標籤名稱進行逸出轉譯。term_exists()
用於檢查標籤、類別或其他分類法詞彙是否存在。username_exists()
用於檢查使用者名稱是否存在。validate_file()
用於驗證輸入的檔案路徑是否為真實路徑(但並不驗證檔案是否存在)。
請查看 WordPress 開發參考文件 以了解更多像這樣的功能。搜尋名稱類似 *_exists()
、*_validate()
和 is_*()
的函式。並非所有這些都是驗證函式,但其中許多都很有用。
清理 (Sanitizing) 資料
「Sanitizing input」指的是保護、清理、過濾輸入資料的過程。「Validation 驗證」比「Sanitization 清理」更具體,因此通常優先考慮使用 Validation 驗證。但是,當「更具體細節」的驗證方式不可行時,「Sanitization 清理」便是次佳的選擇。
範例
假設我們有一個輸入的欄位名稱是 title
:
我們在這裡不能使用驗證(Validation),因為文字欄位太一般了:它可以是任何內容。因此,我們使用 sanitize_text_field()
函式對輸入資料進行過濾。
$title = sanitize_text_field( $_POST['title'] );
update_post_meta( $post->ID, 'title', $title );
前述提到的 sanitize_text_field()
做以下幾件事情:
- 檢查無效的 UTF-8
- 將單一的 less-than 字元(<) 轉成實體格式(HTML entity)
- 去除所有的 HTML 標籤
- 移除換行、tab 和多餘的空白
- 去除八進制字元。
Sanitization 清理可以使用的函式
這裡列出一些方法可以用來幫你「清理」資料:
sanitize_email()
sanitize_file_name()
sanitize_hex_color()
sanitize_hex_color_no_hash()
sanitize_html_class()
sanitize_key()
sanitize_meta()
sanitize_mime_type()
sanitize_option()
sanitize_sql_orderby()
sanitize_term()
sanitize_term_field()
sanitize_text_field()
sanitize_textarea_field()
sanitize_title()
sanitize_title_for_query()
sanitize_title_with_dashes()
sanitize_user()
sanitize_url()
wp_kses()
wp_kses_post()
逸出轉譯資料
「逸出轉譯」是一種保護輸出資料的過程,通過排除不需要的資料,例如格式不正確的 HTML 標籤,從而保護你的資料,在呈現給終端用戶之前確保其安全性。
大部分 WordPress 的函式都會妥善處理好輸出的資料,不需要額外的逸出轉譯處理。
逸出轉譯的函式
WordPress 有許多輔助功能可用於大多數常見的情境。
仔細注意每個功能的行為,因為有些會刪除 HTML 標籤,有些則允許 HTML 標籤存在。你必須根據輸出的內容與上下文,使用最適合的方法,且總是輸出時才進行轉譯,而不是之前。
-
esc_html() – 將HTML元素排除於內容中,可在任何時候使用。這方法會去除 HTML。
-
esc_js() – 使用在內嵌形式呈現的 JavaScript
-
esc_url() – 使用在任何形式下的 URL 連結資源,包含
src
或href
屬性 -
esc_url_raw() – 使用在 URL 儲存資料庫或其他需要非編碼 URL 的情況下。
-
esc_xml() – 使用在 XML 格式區塊中。
-
esc_attr() – 將其用於所有印出 HTML 元素屬性中的內容。
-
esc_textarea() – 用於 Textarea 多行輸入的元素中,轉譯其文字內容。
-
wp_kses() – 使用此方法來安全地轉譯所有不信任的 HTML(如文章、留言等),並且保留 HTML 的格式。
-
wp_kses_post() – 另一個版本的 wp_kses(),可允許所有在文章內容中允許的 HTML 元素。
-
wp_kses_data() – 另一個版本的 wp_kses(),可允許所有在留言內容中允許的 HTML 元素。
客製化逸出轉譯的範例
如果你需要以特定的方式逸出轉譯,那麼函式 wp_kses()(發音為“kisses”)就很有用。
該函式確保你的輸出中只包含指定的 HTML 元素、屬性和屬性值,並規範化 HTML 實體。
array(
'href' => array(),
'title' => array(),
),
'br' => array(),
'em' => array(),
'strong' => array(),
)
); ?>
在這個例子中,除了 、
、 和
以外的所有標籤都會被去除。此外,如果傳遞了一個
標籤,則轉譯會確保只返回
href
和 title
的屬性。
總是最後才執行轉譯
最好在輸出資料時盡可能晚地進行輸出轉譯。
遲點轉譯有幾點好處:
- 可以更快速地進行程式碼審查和部署,因為可以一眼判斷輸出是否安全,而不是在許多行程式碼中找尋以查看是否已轉譯。
- 在變數首次轉換和輸出之間,某些問題可能會不小心更改變數,引入潛在漏洞。
- 晚點轉譯可以更輕鬆地進行自動程式碼掃描,節省時間,並減少審查與部署時間。
- 在任何可能的情況下,晚點轉譯使程式碼更加彈性與未來的相容性。
- 在輸出時轉換/轉譯可以消除任何歧義並增加了清晰度(對於開發維護人員)。
// 可以,但不是很好。
$url = esc_url( $url );
$text = esc_html( $text );
echo '' . $text . '';
// 好多了!
echo '' . esc_html( $text ) . '';
除非... 你無法這麼做
有時候逃脫字元的運用並不實用。在少數特殊情況下,無法將輸出傳遞給 wp_kses()
,因為就其定義而言,它會剔除正在運作的程式碼,導致錯誤。
在這種情況下,將在建立字串時進行轉譯,並將值儲存在帶有後綴 _escaped
、_safe
或 _clean
的變數中(例如, $variable
變成 $variable_escaped
或 $variable_safe
)。
如果一個函式不能在內部最後才輸出與轉譯,那麼它要確保總是能返回「安全」的 HTML。這允許 echo my_custom_script_code();
這樣的客製化操作,而不使用 wp_kses()
,並使用這些標籤。
帶有本地化函式的轉譯
相較於使用 echo
輸出資料,通常會使用 WordPress 的本地化函式,例如 _e()
或 __()
。
這些函式僅僅是將一個本地化函式包在一個轉譯函式中:
esc_html_e( 'Hello World', 'text_domain' );
// 相同於下面
echo esc_html( __( 'Hello World', 'text_domain' ) );
下面是一些有用的本地化與轉譯方法:
範例
任何數值變數在任何地方使用都需要進行轉譯處理
echo $int;
根據是整數還是浮點數,(int)
,absint()
,(float)
都是正確且可接受的方法。不過有時,number_format()
或 number_format_i18n()
可能更適合使用。
intval()
,floatval()
是可接受,但過時的(PHP4)函式。
將 HTML 屬性內的任意變數進行轉譯
echo '';
可以呼叫 esc_attr()
方法進行轉譯。
當一個變數被用作屬性或 URL 的一部分時,最好將整個字串進行轉譯,這樣,如果變數前面有一個潛在的轉譯字元,它也會被正確地轉譯。
正確:
echo '';
不正確:
echo '';
注意:如果在 HTML 屬性中使用 wp_create_nonce()
建立的 nonce,也應像這樣進行轉譯處理。
在 HTML 屬性中跳脫任意 URL,在其他情境下也同樣適用
echo '';
這應該使用 esc_url()
方法進行轉譯。
正確:
echo '';
不正確:
echo '';
echo '';
透過 wp_localize_script() 方法傳遞任意 JavaScript 變數
wp_localize_script( 'handle', 'name',
array(
'prefix_nonce' => wp_create_nonce( 'plugin-name' ),
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'errorMsg' => __( 'An error occurred', 'plugin-name' ),
)
);
不需要特別轉譯,WordPress 方法會替你處理。
轉譯 JavaScript 程式碼區塊中的任意變數
$my_var
變數應使用 esc_js()
方法轉譯。
正確:
在內嵌的 JavaScript 程式碼中轉譯任意變數
$var
變數應使用 esc_js()
方法轉譯。
正確:
在 HTML 屬性中轉譯任意變數以供 JavaScript 使用
$var
變數應使用 esc_js()
、json_encode()
或 wp_json_encode()
方法轉譯。
正確:
轉譯 HTML textarea 多行輸入元素中的任意字串
echo '';
$data
變數應使用 esc_textarea()
方法轉譯。
正確:
echo '';
轉譯帶有任意標籤的 HTML 字串
echo '', $phrase, '';
這取決於 $phrase
變數預期是否包含 HTML 標籤。
- 如果不包含,則使用
esc_html()
或其變體方法。
- 如果預期有 HTML,請使用
wp_kses_post()
、wp_kses_allowed_html()
或 wp_kses()
,其中包含你希望保留的 HTML 標籤。
在 XML 或 XSL 格式中的上下文中轉譯任意字串
echo '', $var, ' ';
使用 esc_xml()
或 ent2ncr()
方法轉譯。
正確:
echo '', ent2ncr( $var ), ' ';
Nonces
「Nonce」是指「一次性使用的數字」,用來保護URL和表單免受某些類型的濫用、惡意或其他影響。
在技術上,WordPress 的 nonce 並不都絕對是數字;它們是由數字和字母組成的雜湊。它們也不僅僅使用一次:它們有一個有限的「壽命(週期)」,在這個期限過後它們就會過期。在這段時間內,在特定的用戶與使用情境中,相同的 nonce 將會產生。對於該操作的 nonce ,該用戶的 nonce 將保持不變,直到該 nonce 的生命週期結束為止。
WordPress 的安全權杖被稱為「Nonces」(儘管與前述真正的 Nonces 定義存在差異),因為它們與 Nonces 具有類似的作用。有助於防範多種類型的攻擊,包括 CSRF,但不會防止重放攻擊 (Replay attack),因為不會檢查 Nonces 是否為一次性使用。永遠不應該在身份驗證、授權或控制訪問時僅仰賴 Nonces 的功能,請使用 current_user_can()
來保護你的功能,並始終假設非安全權杖可能會被破解。
為何要使用 nonce?
以一個使用 nonce 的例子作為說明,考慮管理員後台可能產生的 URL 範例如下,會將文章編號為 123 的文章刪除。
http://example.com/wp-admin/post.php?post=123&action=trash
當你前往該 URL 時,WordPress 將驗證你的授權 cookie 資訊,如果你有權刪除該文章,WordPress 將繼續進行刪除作業。攻擊者可以做的是讓你的瀏覽器在不知情的情況下轉到該網址。例如,攻擊者可以在第三方網頁上製作一個偽裝的連結,如下所示:

這會觸發你的瀏覽器向 WordPress 發出請求,並且瀏覽器會自動附加你的驗證 cookie,而 WordPress 將認為這是一個有效的請求。
加上 nonce 可以防止這種情況發生。例如,當使用 nonce 時,WordPress 為使用者產生的 URL 如下所示:
http://example.com/wp-admin/post.php?post=123&action=trash&_wpnonce=b192fc4204
如果有人試圖在沒有由 WordPress 生成並提供給用戶的正確 nonce 的情況下刪除編號為 123 的文章,WordPress 將向瀏覽器發送 “403 Forbidden” 回應。
建立一個 nonce
你可以建立一個 Nonce 並將其新增到 URL 的查詢字串中,也可以將其新增到表單中的隱藏欄位中,或者以其他方式使用它。
對於要在 AJAX 請求中使用的 Nonces,通常會將 Nonce 新增到隱藏的欄位中,JavaScript 程式碼可以從中提取它。
請注意,Nonces 對當前使用者的操作都是唯一的,因此,如果使用者在非同步的登入或登出操作下,頁面上的 Nonces 將不再有效。
在 URL 中加入 nonce
要在 URL 中加入一個 nonce,請使用 wp_nonce_url()
方法,代入指定 URL 和查詢操作的字串。例如:
$complete_url = wp_nonce_url( $bare_url, 'trash-post_'.$post->ID );
為了最大限度地保護安全,請確保查詢動作的字串越具體越好。
預設情況下,wp_nonce_url()
會加入一個名為 _wpnonce
的欄位。你可以在函式呼叫中並指定不同的名稱。例如:
$complete_url = wp_nonce_url( $bare_url, 'trash-post_'.$post->ID, 'my_nonce' );
在表單中加入 nonce
若要在表單中加入 nonce,可以呼叫 wp_nonce_field()
方法,並指定代表該項動作的字串。預設情況下,wp_nonce_field()
會產生兩個隱藏欄位,一個其值為 nonce,另一個其值為當前網址(來源),並將結果輸出。例如,這個呼叫:
wp_nonce_field( 'delete-comment_'.$comment_id );
可能會輸出類似以下的結果:
為達到最大的保護效果,請確認代表該項動作的字串越具體越好。
你可以指定 nonce 欄位的不同名稱,也可以指定不想要引薦來源欄位,還可以指定將結果回傳而非直接輸出。有關語法的詳細說明,請參考: wp_nonce_field()
方法文件說明。
在其他地方使用 nonce
如果要建立一個 nonce 以供其他用途使用,請呼叫 wp_create_nonce()
方法,並指定一個表示動作的字串。例如:
$nonce = wp_create_nonce( 'my-action_'.$post->ID );
這僅僅返回 nonce 本身,例如:295a686963
為了獲得最大限度的保護,表示動作的字串應盡可能的具體描述。
驗證 nonce
你可以驗證傳遞在 URL、後台管理畫面的表單、AJAX 要求,或其他一些情境中的 nonce。
要驗證傳遞在 URL 或管理員畫面的表單中的 nonce,請呼叫 check_admin_referer()
,並指定表示動作的字串。
例如:
check_admin_referer( 'delete-comment_'.$comment_id );
此呼叫會檢查 nonce 和 referrer,如果驗證失敗,則採取正常操作(以 “403 Forbidden” 回應錯誤訊息終止此動作執行)。
如果你在建立 nonce 時未使用預設的欄位名稱(_wpnonce
),請指定欄位名稱。
例如:
check_admin_referer( 'delete-comment_'.$comment_id, 'my_nonce' );
驗證透過 AJAX 請求的 nonce
若要驗證傳遞在 AJAX 請求中的 nonce,請呼叫 check_ajax_referer() 方法,並指定代表該動作的字串。例如:
check_ajax_referer( 'process-comment' );
這個呼叫會檢查 nonce(但不會檢查 referrer),如果檢查失敗,則預設會終止程式執行。
如果你在建立 nonce 時沒有使用預設的欄位名稱(_wpnonce
或 _ajax_nonce
),或者想採取其他行動而不是終止程式執行,可以指定其他參數。詳細內容請參閱:check_ajax_referer()
。
驗證在其他情況下傳遞的 nonce
為了驗證在其他情況下傳遞的 nonce,使用 wp_verify_nonce()
,並指定 nonce 和表示動作的字串。
例如:
wp_verify_nonce( $_REQUEST['my_nonce'], 'process-comment'.$comment_id );
如果方法驗證結果是 false,則不繼續處理請求。取而代之,採取一些適當的動作。通常是呼叫 wp_nonce_ays()
,該函式會向瀏覽器發送 "403 Forbidden" 的回應。
修改 nonce 的系統
你可以透過勾點來修改 nonce 系統。
修改 nonce 的生命週期(過期時間)
預設情況下,nonce 會在一天後失效,即使其仍符合相符的動作字串。若要更改生命週期,可以新增一個 nonce_life
事件過濾器,指定生命週期的秒數。
例如,將生命週期更改為四小時:
add_filter( 'nonce_life', function () { return 4 * HOUR_IN_SECONDS; } );
執行額外驗證
當 check_admin_referrer()
發現 nonce 和 referrer 都有效時,可以加入 check_admin_referer
動作以進行額外的驗證。
例如:
function wporg_additional_check ( $action, $result ) {
...
}
add_action( 'check_admin_referer', 'wporg_additional_check', 10, 2 );
修改發生錯誤時的訊息
你可以透過本地化的翻譯機制更改當一個 nonce 無效時顯示的錯誤訊息。例如:
function my_nonce_message ($translation) {
if ($translation === 'Are you sure you want to do this?') {
return 'No! No! No!';
}
return $translation;
}
add_filter('gettext', 'my_nonce_message');
額外的資訊
本章節包含有關 WordPress 中 nonce 系統的其他資訊,可能在某些情況下很有用。
Nonce 的生命週期
注意,正如 WordPress nonce 不是「一次性使用的數字」一樣,nonce 的生命週期也不是真正的生命週期。WordPress 使用一個具有兩個標記的系統(一半的壽命),並驗證當前標記和上一個標記的 nonces。在預設設置下(24 小時的壽命),這意味著 nonce 中的時間資訊與自 Unix 時間以來過去了多少個 12 小時有關。這意思在中午至午夜之間產生的 nonce 將具有「壽命」直到隔天中午。實際「壽命」是在 12 至 24 小時之間變化。
當 nonce 有效時,驗證的函式會返回當前的標記號碼,即 1 或 2。例如,你可以使用此資訊來刷新處於第二個標記的 nonce,這樣它們就不會過期。
Nonce 的安全性
如果你已正確安裝 WordPress, Nonces 將使用獨特於你網站的「金鑰」和「鹽」產生。NONCE_KEY
和 NONCE_SALT
定義在你的 wp-config.php
文件中,該文件包含提供更多資訊的註解。
你不應僅依賴 Nonces 進行身份驗證或授權或訪問控制。使用 current_user_can()
來保護你的函式,並始終假定 Nonces 可能會被破解。
取代 nonce 系統
nonce 系統的某些功能是可替換的,因此你可以透過提供自己的函式來替換它們。
如果要更改管理員請求或 AJAX 請求驗證的方式,可以替換 check_admin_referrer()
或 check_ajax_referrer()
,或者兩者兼具。
如果要使用其他 nonce 系統替換 nonce 系統,可以替換 wp_create_nonce()
、wp_verify_nonce()
和 wp_nonce_tick()
等方法。
相關資訊
Nonce 相關函式: wp_nonce_ays()
, wp_nonce_field()
, wp_nonce_url()
, wp_verify_nonce()
, wp_create_nonce()
, check_admin_referer()
, check_ajax_referer()
, wp_referer_field()
Nonce 相關勾點: nonce_life
, nonce_user_logged_out
, explain_nonce_(verb)-(noun)
, check_admin_referer
常見的漏洞
安全性是一個不斷變化的領域,漏洞也會隨時間而演變出現。以下討論了一些常見的漏洞,以及保護網站不受利用的技巧。
漏洞的類型
資料庫 SQL 注入攻擊(SQL Injection)
當輸入的數值未經適當處理而可能執行任何 SQL 命令時,就會發生 SQL 注入。為了防止這種情況發生,WordPress API 提供了廣泛的功能,例如 add_post_meta();
,而不需要你透過 SQL 指令手動新增文章中繼資料 (INSERT INTO wp_postmeta…
)。

xkcd Exploits of a Mom
這個四格漫畫在說一個媽媽把小孩的名字命名為「Robert'); DROP TABLE Students;--
」,然後學校的系統因為沒有做到逸出轉譯這小孩的名字,導致資料庫中全校學生的資料表被刪除了的意外。
網站防範 SQL 注入的第一個原則是:當有 WordPress 函式可以使用時,請使用它。
但有時你需要進行複雜的查詢,而這些查詢在 API 中並未設計。如果是這種情況,請務必使用 $wpdb
函式。這些函式是專門為了保護你的資料庫而建立的。
在執行 SQL 查詢之前,所有 SQL 查詢中的資料都必須進行 SQL 轉譯,以防止 SQL 注入攻擊。最好的 SQL 轉譯函式是 $wpdb->prepare()
,它支持類似 sprintf() 和 vsprintf() 這樣的語法。
$wpdb->get_var( $wpdb->prepare(
"SELECT something FROM table WHERE foo = %s and status = %d",
$name, // an unescaped string (function will do the sanitization for you)
$status // an untrusted integer (function will do the sanitization for you)
) );
跨網站指令碼攻擊 Cross Site Scripting (XSS)
跨網站指令碼攻擊(XSS)是指惡意使用者注入 JavaScript 程式碼到網頁中。
透過轉譯輸出、去除非必要的資料,可以避免 XSS 漏洞危害。 作為主題的主要任務是輸出内容,主题應該根據內容類型使用合適的函式轉譯動態内容。
其中一個轉譯函式的例子是從使用者資料中轉譯 URL。
; ?>)
包含 HTML 實體的内容可以透過只允許特定的 HTML 元素方式處理輸出。
$allowed_html = array(
'a' => array(
'href' => array(),
'title' => array()
),
'br' => array(),
'em' => array(),
'strong' => array(),
);
echo wp_kses( $custom_content, $allowed_html );
跨網站偽造要求 Cross-site Request Forgery (CSRF)
跨網站偽造要求(Cross-site request forgery 或縮寫 CSRF,發音為 sea-surf),是指當一個惡意的詐騙使用者,在已經驗證的網站應用程式中執行惡意的請求。例如,一封釣魚郵件可能包含一個連結到一個頁面,該頁面將刪除使用者在 WordPress 管理後台中的帳號。
如果你的主題包含任何 HTML 或基於 HTTP 的表單提交,請使用一個nonce來保證使用者意圖執行某個動作。
持續更新
保持對可能的安全漏洞的最新了解非常重要。以下資源提供一個好的開始:
- WordPress Security Whitepaper
- WordPress Security Release
- Open Web Application Security Project (OWASP) Top 10
完整的安全性範例
完整的安全性範例,包含權限檢查、資料驗證、安全輸入、安全輸出和 nonce:
/**
* Generate a Delete link based on the homepage url.
*
* @param string $content Existing content.
*
* @return string|null
*/
function wporg_generate_delete_link( $content ) {
// Run only for single post page.
if ( is_single() && in_the_loop() && is_main_query() ) {
// Add query arguments: action, post, nonce
$url = add_query_arg(
[
'action' => 'wporg_frontend_delete',
'post' => get_the_ID(),
'nonce' => wp_create_nonce( 'wporg_frontend_delete' ),
], home_url()
);
return $content . ' ' . esc_html__( 'Delete Post', 'wporg' ) . '';
}
return null;
}
/**
* Request handler
*/
function wporg_delete_post() {
if ( isset( $_GET['action'] )
&& isset( $_GET['nonce'] )
&& 'wporg_frontend_delete' === $_GET['action']
&& wp_verify_nonce( $_GET['nonce'], 'wporg_frontend_delete' ) ) {
// Verify we have a post id.
$post_id = ( isset( $_GET['post'] ) ) ? ( $_GET['post'] ) : ( null );
// Verify there is a post with such a number.
$post = get_post( (int) $post_id );
if ( empty( $post ) ) {
return;
}
// Delete the post.
wp_trash_post( $post_id );
// Redirect to admin page.
$redirect = admin_url( 'edit.php' );
wp_safe_redirect( $redirect );
// We are done.
die;
}
}
/**
* Add delete post ability
*/
add_action('plugins_loaded', 'wporg_add_delete_post_ability');
function wporg_add_delete_post_ability() {
if ( current_user_can( 'edit_others_posts' ) ) {
/**
* Add the delete link to the end of the post content.
*/
add_filter( 'the_content', 'wporg_generate_delete_link' );
/**
* Register our request handler with the init hook.
*/
add_action( 'init', 'wporg_delete_post' );
}
}
```