| 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 攻击得逞的原因一直是由于输入数据未经验证或验证不当造成的,这些数据往往是通过查询字符串获得的。
msdn.microsoft.com/msdnmag/issues/06/11/SecureHabits 上获得)中所述,您必须要承担责任。 如果页面使用查询字符串参数,则需要确保它们在使用前经过适当的验证。 怎样做到这一点呢?
Microsoft Anti-Cross Site Scripting Library 1.5 版)
问题
如果任由页面接受来自查询字符串的输入,开发人员无法承担这种情况所带来的后果。 必须对值进行验证,并且需要仔细检查查询字符串的格式。 像这样的验证过程包含两个明确的步骤: 静态验证(用于检查必需参数的类型及其是否存在)和动态验证(用于确认指定的值是否与代码其他部分的预期一致)。 动态验证对于每个页面是特定的,不能将其委托给与页面无关的外部组件。 相反,静态验证依靠一系列通用检查(必需的参数、类型和长度),这些检查无需对页面进行实例化即可执行。
对于典型的 ASP,必须在每个安全的页面中包含身份验证泛型代码,与此相同,在 ASP.NET 中必须在每个页面中包含查询字符串验证代码。 ASP.NET 将身份验证标准代码移动到少量系统提供的 HTTP 模块,但不处理查询字符串。 另一方面,XSS 和 SQL Injection 攻击的发展近来给交叉检查任何可能的输入源带来了问题。 使用一个外部组件链接到对查询字符串参数进行严格的静态验证的应用程序,可以大大改善这种情况,因为它会自动确保当查询字符串不符合声明的架构时不执行任何 ASP.NET 页面请求。
更重要的是,利用外部组件,不需要对页面的源代码进行任何更改。 您要做的就是通过配置文件将组件与应用程序一起注册,并添加一个 XML 文件,用于描述每个感兴趣的页面的查询字符串语法。 接下来我们将更详细深入地介绍该策略。
定义策略
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 异常。
msdn.microsoft.com/msdnmag/issues/06/06/CuttingEdge)。
声明性查询字符串
图 1 中显示的文件被命名为 web.querystring。当然名称和架构都是任意的。
(从安全角度来看,主要问题并不是页面通过查询字符串接收值,而是页面可能使用这些值。 如果页面中的某些代码要处理通过查询字符串发送的输入,那么作为开发人员,必须确保该输入是安全可信的。 因此,您可能希望向 XML 文件中添加一个 <page> 节点,并且只为应用程序中那些实际使用通过查询字符串传递的数据的页面添加。)
在架构示例中,<page> 元素有两个属性: url 和 abortOnError。 前者指示页面的相对 URL,后者是可选的 Boolean 属性,用于指示在输入错误时是否应中止页面请求。 如果选择中止页面,则根据在查询字符串中发现不可接受的数据后决定采取的措施,用户会收到 HTTP 错误或 ASP.NET 异常。 无论以何种方式显示结果,都不必编辑所涉及的 ASP.NET 页面的代码。 在 HTTP 模块中可能会出现请求终止,这发生在对页面类进行识别和实例化之前。
有一种备用方法。 在该方法中,HTTP 模块会让请求顺利通过,但会向 HTTP 上下文添加详细信息,从而将所检测到的内容通知页面类。 然后页面会负责采取适当的对策,如显示专门的错误页面。 在这种情况下,该页面的作者必须将所有查询字符串异常集成到应用程序的错误处理策略集的上下文中。 此方法的缺点在于需要对涉及查询字符串的每个页面的代码进行更改。 (我将在稍后讨论这一点。)
图 2 中的属性定义参数。
ASP.NET 会将查询字符串上传递的所有值作为字符串接收。 因此,HttpRequest 对象上定义的 QueryString 属性是键和值都是字符串的 NameValueCollection 对象。 但是字符串格式是纯粹的序列化格式。 当然,每个查询字符串参数不仅可以表示字符串,也可以表示 Boolean 值或数值,以及特殊的字符串子类型,如 URL、GUID 和文件名。 因此,在 web.querystring 文件中,您可以使用自定义的枚举类型 QueryStringParamTypes 的值指定期望的参数类型:
Friend Enum QueryStringParamTypes As Integer Text = 1 Int = 2 Bool = 3 End Enum
支持的类型的列表可以扩展,例如增加各种数值类型。 Text 类型的参数还可以通过 Length 属性指定最大长度。 假设一个页面可以接受来自查询字符串的 5 字符的客户 ID,当然有必要限制该参数的长度。 此外,web.querystring 可用于启用对参数名称区分大小写的检查,并可将某个参数指定为可选。 web.querystring 文件的内容由查询字符串 HTTP 模块对其进行解析,并转换成内存中的对象。
对 QueryString HTTP 模块进行编码
图 3 中的代码,利用一个文件观察程序对象来检测对 web.querystring 文件的任何更改,并及时对其进行重新加载。
图 4 中所示。 描述符包含页面的 URL、一个用于指示在验证失败时要采取的措施的标志以及支持的查询字符串参数的列表。 通过 QueryStringParamInfo 类的一个实例来描述每个参数。 QueryStringParamCollection 是相关的集合类。 它是典型的泛型集合类,其中包含一对 Find 方法: 一个用于确认在集合中是否有给定名称的参数,一个用于返回参数描述符实例。
查询字符串描述符会对给定页面的查询字符串的相关信息进行缓存。 但 web.querystring 文件可以引用多个页面。 因此,使用页面的 URL 作为键,在一个哈希表中对 web.querystring 引用的所有页面的所有描述符进行分组。 以下代码段显示了 HTTP 模块的 BeginRequest 处理程序如何检索当前请求页面的描述符:
Dim currentPage As String currentPage = HttpContext.Current.Request.Path.ToLower() Dim qsDesc As QueryStringDescriptor = _ _queryStringData.Item(currentPage)
查询字符串描述符是页面查询字符串的正确语法的内存中表示形式。 下一步是对照此架构验证发布的查询字符串。
查询字符串验证
验证过程分为三个步骤。 第一,模块对发布的查询字符串中的参数进行计数。 如果发布的查询字符串比预期的参数数量多,则验证失败。 下一步,模块循环访问发布的查询字符串参数,并确保每个参数与声明的架构中的某项匹配。 如果发现额外的未知参数,则验证失败。 最后,模块循环访问架构中定义的所有参数,并确认指定了所有必需的参数,并且每个指定的参数具有某个正确类型的值。
数据验证步骤试图将给定参数的值解析为其声明的类型。 以下是验证数值所使用的代码段:
If paramType = QueryStringParamTypes.Int Then Dim result As Integer Dim success As Boolean = Int32.TryParse(paramValue, result) If Not success Then Return False End If
按照设计,只从“true”和“false”这样的字符串解析 Boolean 值。 Querystring HTTP 模块的验证子系统也接受像“yes”和“no”这样的字符串。
最后,作为请求管道中的第一步,解析查询字符串的内容并对其类型进行验证。 如果一切顺利完成,则处理请求。 否则,请求会立即终止,并显示适当的 HTTP 状态代码。 例如:
HttpContext.Current.Response.StatusCode = 500 HttpContext.Current.Response.[End]()
为用户提供了如图 5 所示的页面。 您可能会抱怨其中没有指示 IIS 错误的实际原因,但 HTTP 状态代码和泛型描述明确指示了错误的来源是在处理请求过程中产生的内部的服务器端错误。 正如前面提到的,Michael Howard 的文章中解释说,在错误页面中应该始终泄漏最少的信息,以避免不经意间将详细信息散布给可能的黑客从而带来风险。 在这一点上,HTTP 500 错误在指示实际发生的错误方面足够通用。 总之,正如上述代码段所示,HTTP 状态代码可随意设置。
注意事项和备用方案
如果遇到格式错误的数据,是否应该中止请求?或者,将验证结果缓存在某个位置并让页面代码做出对用户的最后决定是否更好? 此外,是否应该在请求生命周期这么早的阶段捕获和处理查询字符串? 我们首先来解决后一个问题。
图 6 列出了指示请求处理特征的应用程序级事件。 如果不在请求开始时检查,那么应在何时检查查询字符串? 一个很好的检查点就是在授权之后立即检查。 如果请求处理已经过了授权阶段,那么可以比较确信将调用该页面的 HTTP 处理程序。
但是能否在此之后进行检查呢? 一般来说,PostAcquireRequestState 及其之前的任何事件处理程序都可以。 用户代码(代码页面作者以代码隐藏的方式编写或内嵌在 ASPX 文件中)只在 PostAcquireRequestState 事件之后执行。 随后,只有在全局 PostAcquireRequestState 事件触发后,页面才能处理完查询字符串。 但您不应等待这么长时间。在授权后并在页面执行之前检查查询字符串可以减少大量额外的操作,即检索会话状态和检查输出缓存。 如果由于错误的查询字符串,要终止该页面Error! Hyperlink reference not valid.,不必首先加载会话状态,尤其当它来自于像 SQL Server™ 这样的进程外来源时。
图 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 QueryStringErrorCodes NoError = 0 TooManyParameters = 1 InvalidQueryParameter = 2 MissingRequiredParameter = 4 InvalidContent = 8 End 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 Boolean Get Dim result As QueryStringErrorCodes = DirectCast( _ Context.Items(QueryStringHelper.QueryStringValidationStatus), _ QueryStringErrorCodes) Return (result = QueryStringErrorCodes.NoError) End Get End Property
更好的方法是,可以在基类上定义这样一个属性,并从此类派生出所有启用了查询字符串的页面。
总结
并非所有 ASP.NET 页面都使用查询字符串。 但查询字符串可用作 Web 页面的输入。 因此,这也是存在安全漏洞的页面上的一个可能的攻击点。 如果页面需要查询字符串屏障,那就准备在要使用查询字符串的所有页面中重复编写相同的代码。
本专栏中介绍的 QueryString 模块不需要在源页面中编码,它会对照保存在单独的 XML 文件中的给定架构,自动检查发布的查询字符串。 这意味着对现有代码没有任何影响,同时额外提供了一道抵御攻击者的内置屏障。 但请记住,这并非银弹。