Get the sample code for this article.
| NEW: Explore the sample code online! - or - 代碼下載位置: CuttingEdge2007_03.exe (168KB) |
目錄問題 定義策略 聲明性查詢字串 對 QueryString HTTP 模組進行編碼 查詢字串驗證 注意事項和備用方案 總結 |
多年來,典型的 ASP 開發人員一直通過在每個頁面的頂部插入某些泛型代碼來實現頁面身分識別驗證,這些代碼將提取使用者憑證、附加 cookie 並進行重新導向。 ASP.NET HTTP 模組在進行身分識別驗證時消除了所有這些重複性代碼。 因此,ASP.NET 應用程式所連結的每個頁面不必得到所選擇的身分識別驗證模組的安全保護。 通過 web.config 檔案和大量外部資源(如登入頁面和成員資料庫)可以以聲明的方式完成所有工作。
ASP.NET 還引入了其他系統模組和編程技術,從而最大程度減少重複性代碼並使 Web 應用程式常見功能的實現合理化。 例如,網站地圖、匿名使用者和設定檔現在都是內建功能,您不必再為其重複編寫或複製代碼。
對安全性的側重使得 ASP.NET 運行庫中加入了大量本機屏障,這消除了為使交叉核對輸入資料能夠抵禦一些可能形式的攻擊而給開發人員帶來的沉重負擔。 當然,這並不意味著 ASP.NET 應用程式在設計上就是安全的,但確實意味著安全性水平比過去有了提高。 但要進一步提高安全水平仍舊取決於開發人員。
ASP.NET 頁面旨在將資料發布到這些頁面上,並在 HTTP POST 資料包的主體中對輸入參數進行分組。 大多數 ASP.NET 應用程式使用查詢字串來傳遞輸入資料並不像典型的 ASP 應用程式那樣頻繁。 然而,查詢字串仍然是將外部資料匯入 ASP.NET 頁面的一種合理的方法。 但是誰來驗證這些資料呢?
最近的統計顯示,跨網站指令碼 (XSS) 攻擊日益猖獗,它們在已發現的攻擊中所佔比例最高。 XSS 攻擊得逞的原因一直是由於輸入資料未經驗證或驗證不當造成的,這些資料往往是通過查詢字串獲得的。
首先用 ASP.NET 1.1 版對所有發布的資料進行預先處理(表單和查詢字串),尋找可能被 XSS 攻擊者利用的可疑的字元組合。 但這道屏障並非銀彈,正如 Michael Howard 在 2006 年 11 月《MSDN 雜誌》上的文章“安全習慣: 8 個開發更安全的程式碼的簡單規則”(可從 msdn.microsoft.com/msdnmag/issues/06/11/SecureHabits 上獲得)中所述,您必須要承擔責任。 如果頁面使用查詢字串參數,則需要確保它們在使用前經過適當的驗證。 怎樣做到這一點呢?
在本專欄中,我將構建一個 HTTP 模組,用於讀取 XML 檔案,其中已經對查詢字串的預期結構進行了寫入程式碼。 該模組會對照給定的架構對任何請求的頁面的查詢字串進行驗證。 您不需要接觸任何頁面的代碼。 (有關抵禦 XSS 攻擊的更多資訊,請參閱 Microsoft Anti-Cross Site Scripting Library 1.5 版)
問題
如果任由頁面接受來自查詢字串的輸入,開發人員無法承擔這種情況所帶來的後果。 必須對值進行驗證,並且需要仔細檢查查詢字串的格式。 像這樣的驗證過程包含兩個明確的步驟: 靜態驗證(用於檢查必需參數的類型及其是否存在)和動態驗證(用於確認指定的值是否與代碼其他部分的預期一致)。 動態驗證對於每個頁面是特定的,不能將其委託給與頁面無關的外部組件。 相反,靜態驗證依靠一系列通用檢查(必要參數、類型和長度),這些檢查無需對頁面進行執行個體化即可執行。
對於典型的 ASP,必須在每個安全的頁面中包含身分識別驗證泛型代碼,與此相同,在 ASP.NET 中必須在每個頁面中包含查詢字串驗證代碼。 ASP.NET 將身分識別驗證標準代碼移動到少量系統提供的 HTTP 模組,但不處理查詢字串。 另一方面,XSS 和 SQL Injection 攻擊的發展近來給交叉檢查任何可能的輸入源帶來了問題。 使用一個外部組件連結到對查詢字串參數進行嚴格的靜態驗證的應用程式,可以大大改善這種情況,因為它會自動確保當查詢字串不符合聲明的架構時不執行任何 ASP.NET 頁面請求。
更重要的是,利用外部組件,不需要對頁面的原始碼進行任何更改。 您要做的就是通過設定檔將組件與應用程式一起註冊,並添加一個 XML 檔案,用於描述每個感興趣的頁面的查詢字串文法。 接下來我們將更詳細深入地介紹該策略。
定義策略
ASP.NET 提供 HTTP 模組作為一種工具,用於在對請求的頁面類進行執行個體化和處理之前將您的代碼注入運行庫管道。 從文法的角度來看,HTTP 模組只是一個用於實現給定介面的類。 從更廣泛的體繫結構角度來看,HTTP 模組是一種與應用程式具有相同生存期的觀察器。 該模組可觀察請求處理活動並進行註冊,以偵聽一些特定事件,如 BeginRequest、EndRequest 或 PostMapRequestHandler。 ASP.NET 請求的應用程式事件的完整列表可在 System.Web.HttpApplication 類的文檔中找到 (msdn2.microsoft.com/0dbhtdck.aspx)。
安裝完成後,當每次 ASP.NET 運行庫處理的請求達到觸發被觀察的事件的階段時,HTTP 模組就會發揮作用。 注意,ASP.NET 運行庫不一定會處理 ASP.NET 應用程式託管的所有資源的請求。 預設情況下,Web 服務器會直接處理靜態資源,如階層式樣式表 (CSS) 和 JPG 檔案,而不會讓 ASP.NET 應用程式來處理,除非 IIS 被配置為允許 ASP.NET 處理這些資源。
我的查詢字串 HTTP 模組將偵聽 begin-request 事件並對照以前載入的結構描述驗證查詢字串的內容。 如果參數的數量匹配並且提供的值與預期的類型相容,則模組會讓該請求進入下一階段。 否則,請求將被終止,並引發相應的 HTTP 狀態碼或 ASP.NET 異常。
前面我曾提到一個用於存放查詢字串文法的 XML 檔案。 實際上不一定非要是 XML 檔案。 (如果是 XML 檔案,架構則完全取決於您。) 您只需一個資料來源,以聲明的方式儲存關於頁面查詢字串的預期結構的資訊。 它可以是簡單的 XML 檔案,也可以是複雜的基於供應商的服務。 我在 2006 年 6 月的專欄中提供了一個專門使用提供者的自訂應用程式服務的樣本 (msdn.microsoft.com/msdnmag/issues/06/06/CuttingEdge)。
聲明性查詢字串
圖 1 顯示了一個 XML 檔案樣本和查詢字串 HTTP 模型要識別的架構。 在根節點 <querystring> 下,有與應用程式的頁面相同數量的 <page> 節點,它們可以處理來自查詢字串的值。 在本專欄附帶的代碼中,圖 1 中顯示的檔案被命名為 web.querystring。當然名稱和架構都是任意的。
(從安全形度來看,主要問題並不是頁面通過查詢字串接收值,而是頁面可能使用這些值。 如果頁面中的某些代碼要處理通過查詢字串發送的輸入,那麼作為開發人員,必須確保該輸入是安全可信的。 因此,您可能希望向 XML 檔案中添加一個 <page> 節點,並且只為應用程式中那些實際使用通過查詢字串傳遞的資料的頁面添加。)
在架構樣本中,<page> 元素有兩個屬性: url 和 abortOnError。 前者指示頁面的相對 URL,後者是可選的 Boolean 屬性,用於指示在輸入錯誤時是否應中止頁面請求。 如果選擇中止頁面,則根據在查詢字串中發現不可接受的資料後決定採取的措施,使用者會收到 HTTP 錯誤或 ASP.NET 異常。 無論以何種方式顯示結果,都不必編輯所涉及的 ASP.NET 頁面的代碼。 在 HTTP 模組中可能會出現請求終止,這發生在對頁面類進行識別和執行個體化之前。
有一種備用方法。 在該方法中,HTTP 模組會讓請求順利通過,但會向 HTTP 上下文添加詳細資料,從而將所檢測到的內容通知頁面類。 然後頁面會負責採取適當的對策,如顯示專門的錯誤頁面。 在這種情況下,該頁面的作者必須將所有查詢字串異常整合到應用程式的錯誤處理策略集的上下文中。 此方法的缺點在於需要對涉及查詢字串的每個頁面的代碼變更。 (我將在稍後討論這一點。)
預設情況下 abortOnError 屬性被設定為“true”,意味著查詢字串中的任何異常都將中止頁面請求。 在每個 <page> 節點下,都有一列 <param> 節點,每個支援的查詢字串參數有一個節點。 在程式碼範例中,可以使用圖 2 中的屬性定義參數。
ASP.NET 會將查詢字串上傳遞的所有值作為字串接收。 因此,HttpRequest 對象上定義的 QueryString 屬性是鍵和值都是字串的 NameValueCollection 對象。 但是字串格式是純粹的序列化格式。 當然,每個查詢字串參數不僅可以表示字串,也可以表示 Boolean 值或數值,以及特殊的字串子類型,如 URL、GUID 和檔案名稱。 因此,在 web.querystring 檔案中,您可以使用自訂的枚舉類型 QueryStringParamTypes 的值指定期望的參數類型:
Friend Enum QueryStringParamTypes As IntegerText = 1Int = 2Bool = 3End Enum
支援的類型的列表可以擴充,例如增加各種數實值型別。 Text 類型的參數還可以通過 Length 屬性指定最大長度。 假設一個頁面可以接受來自查詢字串的 5 字元的客戶 ID,當然有必要限制該參數的長度。 此外,web.querystring 可用於啟用對參數名稱區分大小寫檢查,並可將某個參數指定為可選。 web.querystring 檔案的內容由查詢字串 HTTP 模組對其進行解析,並轉換成記憶體中的對象。
對 QueryString HTTP 模組進行編碼
圖 3 中顯示了 QueryString HTTP 模組的原始碼。 正如上面提到的,HTTP 模組類可實現由 Init 和 Dispose 方法構成的 IHttpModule。 當在應用程式上下文中載入和卸載模組時,會調用這些方法。 在 Init 方法中,HTTP 模組通常會為它希望觀察的應用程式事件註冊一個偵聽程式。 在此樣本中,它為 BeginRequest 事件註冊了一個處理常式。 此外,該模組還會處理 web.querystring 檔案並建立其內容在記憶體中的表示形式。 每個應用程式只調用一次 Init 方法,一次性讀取設定檔的內容並對其進行緩衝,在 Web 應用程式重新啟動時才會檢測到對 web.querystring 檔案的更改。 這未必會導致出現問題,因為在生產中如果不停止和重新啟動應用程式,幾乎不需要對 web.querystring 檔案變更。 但是,您也可以擴充圖 3 中的代碼,利用一個檔案觀察程式對象來檢測對 web.querystring 檔案的任何更改,並及時對其進行重新載入。
web.querystring 檔案的內容被映射到一個 QueryStringDescriptor 類型的對象, 4 中所示。 描述符包含頁面的 URL、一個用於指示在驗證失敗時要採取的措施的標誌以及支援的查詢字串參數的列表。 通過 QueryStringParamInfo 類的一個執行個體來描述每個參數。 QueryStringParamCollection 是相關的集合類。 它是典型的泛型集合類,其中包含一對 Find 方法: 一個用於確認在集合中是否有給定名稱的參數,一個用於返回參數描述符執行個體。
查詢字串描述符會對給定頁面的查詢字串的相關資訊進行緩衝。 但 web.querystring 檔案可以引用多個頁面。 因此,使用頁面的 URL 作為鍵,在一個雜湊表中對 web.querystring 引用的所有頁面的所有描述符進行分組。 以下程式碼片段顯示了 HTTP 模組的 BeginRequest 處理常式如何檢索當前請求頁面的描述符:
Dim currentPage As StringcurrentPage = HttpContext.Current.Request.Path.ToLower()Dim qsDesc As QueryStringDescriptor = __queryStringData.Item(currentPage)
查詢字串描述符是頁面查詢字串的正確文法的記憶體中表示形式。 下一步是對照此結構描述驗證發布的查詢字串。
查詢字串驗證
驗證過程分為三個步驟。 第一,模組對發布的查詢字串中的參數進行計數。 如果發布的查詢字串比預期的參數數量多,則驗證失敗。 下一步,模組逐一查看發布的查詢字串參數,並確保每個參數與聲明的架構中的某項匹配。 如果發現額外的未知參數,則驗證失敗。 最後,模組逐一查看架構中定義的所有參數,並確認指定了所有必要參數,並且每個指定的參數具有某個正確類型的值。
資料驗證步驟試圖將給定參數的值解析為其聲明的類型。 以下是驗證數值所使用的程式碼片段:
If paramType = QueryStringParamTypes.Int ThenDim result As IntegerDim success As Boolean = Int32.TryParse(paramValue, result)If Not success Then Return FalseEnd If
按照設計,只從“true”和“false”這樣的字串解析 Boolean 值。 Querystring HTTP 模組的驗證子系統也接受像“yes”和“no”這樣的字串。
最後,作為請求管道中的第一步,解析查詢字串的內容並對其類型進行驗證。 如果一切順利完成,則處理請求。 否則,請求會立即終止,並顯示適當的 HTTP 狀態碼。 例如:
HttpContext.Current.Response.StatusCode = 500HttpContext.Current.Response.[End]()
為使用者提供了 5 所示的頁面。 您可能會抱怨其中沒有指示 IIS 錯誤的實際原因,但 HTTP 狀態碼和泛型描述明確指示了錯誤的來源是在處理請求過程中產生的內部的伺服器端錯誤。 正如前面提到的,Michael Howard 的文章中解釋說,在錯誤頁面中應該始終泄漏最少的資訊,以避免不經意間將詳細資料散布給可能的駭客從而帶來風險。 在這一點上,HTTP 500 錯誤在指示實際發生的錯誤方面足夠通用。 總之,正如上述程式碼片段所示,HTTP 狀態碼可隨意設定。
圖 5 錯誤查詢字串的結果 (單擊該映像獲得較小視圖)
圖 5 錯誤查詢字串的結果 (單擊該映像獲得較大視圖)
注意事項和備用方案
如果遇到格式錯誤的資料,是否應該中止請求?或者,將驗證結果緩衝在某個位置並讓頁面代碼做出對使用者的最後決定是否更好? 此外,是否應該在請求生命週期這麼早的階段捕獲和處理查詢字串? 我們首先來解決後一個問題。
圖 6 列出了指示請求處理特徵的應用程式級事件。 如果不在請求開始時檢查,那麼應在何時檢查查詢字串? 一個很好的檢查點就是在授權之後立即檢查。 如果請求處理已經過了授權階段,那麼可以比較確信將調用該頁面的 HTTP 處理常式。
但是能否在此之後進行檢查呢? 一般來說,PostAcquireRequestState 及其之前的任何事件處理常式都可以。 使用者代碼(字碼頁面作者以程式碼後置的方式編寫或內嵌在 ASPX 檔案中)只在 PostAcquireRequestState 事件之後執行。 隨後,只有在全域 PostAcquireRequestState 事件觸發後,頁面才能處理完查詢字串。 但您不應等待這麼長時間。在授權後並在頁面執行之前檢查查詢字串可以減少大量額外的操作,即檢索工作階段狀態和檢查輸出緩衝。 如果由於錯誤的查詢字串,要終止該頁面Error! Hyperlink reference not valid.,不必首先載入工作階段狀態,尤其當它來自於像 SQL Server 這樣的進程外來源時。
最後,查詢字串檢查應只放置在兩個應用程式事件的位置: BeginRequest 或 PostAuthorizeRequest。 如果處理查詢字串需要使用者資訊則應選擇後者,例如,如果允許某些使用者根據自己的角色指定某些參數。 在這種情況下,您還可以向圖 1 的架構添加 roles 屬性。 在其他任何情形下,通過在 BeginRequest 中設定攔截,可以在管道非常早期的階段終止該頁面,以防止進行進一步處理。
如果您仍希望頁面代碼處理錯誤的查詢字串並嘗試正常降級或恢複,情況就不一樣了。 對於這一點,我認為在頁面執行之前的任何事件都將正常運行。 我會選擇 PostAcquireRequestState,它是在頁面代碼執行之前管道中可以檢查查詢字串的最後一個點。 在這一點上也有可用的工作階段狀態。 我還沒有說到這一點,但上下文已經很清楚地說明了這一點: 從 Request 內部對象的 QueryString 集合的一開始就提供了查詢字串資訊。
因此假定您希望 HTTP 模組檢查查詢字串並將其結果沿著管道傳遞下去,直到頁面代碼。 您可以採取幾種可能的方法。 在討論這些方法之前,首先應該指出,這些方法都會對代碼有影響,需要對每個帶查詢字串的頁面的原始碼變更。
HTTP 模組與負責給定請求的處理常式進行交流的最簡單的方法就是將資料填入 HttpContext 對象的 Items 集合。 Items 屬性是針對要寫入和讀取資訊的 HTTP 模組和處理常式的雜湊表。 儲存在 Items 表中的任何資料都具有與請求相同的壽命。
HTTP 模組使用 HttpContext 類上的靜態 Current 屬性,獲得對當前請求的內容物件的存取權限,如下所示:
HttpContext.Current.Items("QueryStringStatus") = errorCode
Items 是 System.Collections.Hashtable,鍵和值都可以是任何 .NET 類型。 查詢字串模組使用公用枚舉類型來列出所有可能的錯誤碼:
<Flags()> _Public Enum QueryStringErrorCodesNoError = 0TooManyParameters = 1InvalidQueryParameter = 2MissingRequiredParameter = 4InvalidContent = 8End Enum
這些代碼的組合可以更好地描述查詢字串存在的錯誤,該組合將填入雜湊表中一個符合命名規範的位置中。 HTTP 模組和頁面必須就命名規範達成一致,以便頁面可以檢索和使用該資訊。 HTTP 模組定義了一個公用常量,用來表示該位置的名稱:
Public Const QueryStringValidationStatus As String = _"QueryStringValidationStatus"
頁面可以使用以下代碼檢索來自 HTTP 模組的訊息,並決定如何處理該資訊:
Dim result As QueryStringErrorCodes = _DirectCast(Context.Items( _QueryStringHelper.QueryStringValidationStatus), _QueryStringErrorCodes)
假設您還希望該模組為頁面提供從有效查詢字串獲得的類型值。 考慮以下 URL,假定查詢字串是正確的:
http://www.yourserver.com/page.aspx?detailed=true
頁面應結合代碼,解析查詢字串值並將其轉換成 Boolean 值。 在驗證過程中,HTTP 模組中已經完成了這一轉換。 最簡單的方法是通過將類型值的雜湊表放入另一個 Items 位置中,將這些類型值與目標頁面共用(有關詳細資料請參見原始碼)。
更簡潔的方法是為每個帶查詢字串的頁面添加新的唯讀屬性。 假定將其稱為 IsValidQueryString,則該方法如下所示:
Public Property IsValidQueryString As BooleanGetDim result As QueryStringErrorCodes = DirectCast( _Context.Items(QueryStringHelper.QueryStringValidationStatus), _QueryStringErrorCodes)Return (result = QueryStringErrorCodes.NoError)End GetEnd Property
更好的方法是,可以在基類上定義這樣一個屬性,並從此類派生出所有啟用了查詢字串的頁面。
總結
並非所有 ASP.NET 頁面都使用查詢字串。 但查詢字串可用作 Web 頁面的輸入。 因此,這也是存在安全性漏洞的頁面上的一個可能的攻擊點。 如果頁面需要查詢字串屏障,那就準備在要使用查詢字串的所有頁面中重複編寫相同的代碼。
本專欄中介紹的 QueryString 模組不需要在源頁面中編碼,它會對照儲存在單獨的 XML 檔案中的給定架構,自動檢查發布的查詢字串。 這意味著對現有代碼沒有任何影響,同時額外提供了一道抵禦攻擊者的內建屏障。 但請記住,這並非銀彈。