ASP.NET 查询字串的验证


 本文示例源代码或素材下载

  目录

  问题

  定义策略

  宣告式查询字串

  编写 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 网页的合法方式。但是这些资料将由谁来验证?

  最新统计资料显示,跨站台的指令码处理 (Cross-Site Scripting,XSS) 攻击愈来愈猖獗,它们是最常见的探索式攻击。成功的 XSS 攻击一向肇因於输入资料未验证或未正确验证,通常此资料会透过查询字串入侵。

  从 1.1 版开始,ASP.NET 就会预先处理任何张贴的资料 (表单和查询字串),以寻找可能是 XSS 攻击者所运用的可疑字元组合。但此关卡不是彻底解决问题的方法,就像 Michael Howard 在 2006 年 11 月所写的 MSDN?Magazine 文章<安全习惯:开发更安全程式码的八大要则>(可於 msdn.microsoft.com/msdnmag/issues/06/11/SecureHabits 取得) 中所述,您必须负起完全责任。如果您的网页使用查询字串参数,就必须确定在使用之前已通过适当的验证。这要如何做?

  在本专栏中,我建置了一个 HTTP 模组来读取 XML 档案,您只需要在此档案中以硬式编码的方式加入查询字串的预期结构。然後此模组就会以给定的结构描述来验证所要求网页的查询字串。您完全不需要修改网页上的任何程式码 (如需防止 XSS 攻击的详细资讯,请
参阅 Microsoft Anti-Cross Site Scripting Library v1.5 (英文))。

  问题

  开发人员不能够无条件接受网页上的查询字串输入。所有的值都必须经过验证,且查询字串的格式亦需要仔细检查。如此的验证程序包含两个不同的步骤:静态验证 (会检查必要参数的型别及其是否存在) 和动态验证 (会验证指定的值与其余程式码的预期值是否一致)。动态验证会针对每一个网页进行验证,且无法委派至外部之无从验证的网页元件。相反地,静态验证则仰赖通用检查清单 (必要的参数、型别、长度),不需要执行个体化网页即可执行。

  就像您必须在传统 ASP 中为每一个受保护网页包括验证用的泛型程式码一样,您也要在 ASP.NET 的每一个网页中包括查询字串的验证程式码。ASP.NET 虽已将验证标准码移到系统提供的小型 HTTP 模组群组,但并未处理查询字串。此外,XSS 和 SQL 插入式攻击的增加,也引来需要交叉检查可能输入来源的问题。运用外部元件连结到实作查询字串参数之严格验证的应用程式,可以有很大的帮助,因为这样可以确保当查询字串不符合所宣告的结构描述时,不会执行 ASP.NET 网页要求。

  更重要的是,运用外部元件,网页原始程式码就不需要任何变更。您唯一需要做的,是透过组态档在应用程式中注册该元件,并新增一个 XML 档案来说明每一个网页的查询字串语法。现在让我们深入细述此策略。

  定义策略

  ASP.NET 之所以提供 HTTP 模组这个工具,是要让您在产生及处理所要求的网页类别之前,可以将您自己的程式码插入执行阶段管线中。从语法观点来看,HTTP 模组只是一个实作给定介面的类别。从更广泛的架构面来看,HTTP 模组好比是与应用程式具有相同存留期的一种观察者。此模组会观察处理要求的活动,并注册以接听一些特定事件,例如 BeginRequest、EndRequest 或 PostMapRequestHandler。您可以在 System.Web.HttpApplication 类别的文件 (msdn2.microsoft.com/0dbhtdck.aspx) 中找到 ASP.NET 要求的应用程式事件之完整清单。

  在安装 HTTP 模组之後,每当 ASP.NET 执行阶段所处理的要求到达引发所观察事件的阶段时,该模组就会开始执行。请注意,ASP.NET 执行阶段不一定会处理 ASP.NET 应用程式装载之所有资源的要求。根据预设,除非 IIS 已设定为允许 ASP.NET 处理静态资源 (例如阶层式样式表 (CSS) 和 JPG 档),否则会直接由网页伺服器提供这些资源,而不会涉及 ASP.NET 应用程式。

  我的查询字串 HTTP 模组会接听开始要求事件,并根据先前载入的结构描述来验证查询字串内容。如果参数的数目和提供的值与预期型别相符,该模组就会让要求进入下一个阶段。否则会终止该要求并传回适当的 HTTP 状态码,或者会掷回 ASP.NET 例外状况。

  我提过的 XML 档案是储存查询字串语法的地方。您不一定要使用 XML 档案 (如果它是 XML 档案,其结构描述完全由您决定)。您只需要一个资料来源,以宣告方式来保存网页查询字串之预期结构的相关资讯。它可以是简单的 XML 档案,也可以是复杂的提供者服务。我在 2006 年 6 月的专栏提供了很好的范例,其中说明如何设计会使用提供者的自订应用程式服务 (msdn.microsoft.com/msdnmag/issues/06/06/CuttingEdge)。

  宣告式查询字串

  [图 1] 显示查询字串 HTTP 模组可辨识的范例 XML 档案和结构描述。在根节点 <querystring> 之下有许多 <page> 节点,数量会与应用程式中可能处理查询字串值的页面数量一样多。在本专栏所附的程式码中,[图 1] 显示的档案名称为 web.querystring。此名称可以是任意名称,当然,结构描述也是一样

  Figure 1 范例 web.querystring 组态档

<!--
<page url="..." abortOnError="TRUE|false">
 <param name="..."
     type="Int|Text|Bool"
     optional="FALSE|true"
     length="number (for Text type only)"
     casesensitive="false|true" />
</page>
-->
<querystring>
 <page url="/source/test.aspx" abortOnError="true">
  <param name="id" type="Int" optional="true" casesensitive="true" />
  <param name="code" type="Text" length="5" optional="false" />
  <param name="detailed" type="Bool" optional="false" />
 </page>
 <page url="/source/Test1.aspx" abortOnError="false">
  <param name="guid" type="Int" />
 </page>
</querystring>

  (从安全性的角度而言,主要问题不是网页会透过查询字串接收值,而是网页可能使用那些值。如果网页的些程式码会处理透过查询字串传送的输入,那麽身为开发人员,您必须确保输入安全无虞。因此,XML 档案中的 <page> 节点,应该仅针对在应用程式中会实际透过查询字串取用资料的网页来新增)。

  在范例结构描述中,<page> 元素有两个属性:url 和 abortOnError。前者会指出网页的相对 URL,後者则是选用的 Boolean 属性,以指出万一输入错误时是否要中止网页要求。如果您选择要中止网页,则使用者会收到 HTTP 错误或 ASP.NET 例外状况,视您在查询字串中发现无法接受的资料之後决定采取的动作而定。不论您选择如何处理,都不需要编辑相关 ASP.NET 网页的程式码。在识别及产生网页类别之前,HTTP 模组中就可以适当地使要求终止。

  还有另一替代方法。其中 HTTP 模组会让要求执行,但是会将侦测到的详细资讯新增至 HTTP 内容中,以通知网页类别。然後就由网页负责采取适当的对策,例如显示特定错误网页。在此情况下,网页作者必须在应用程式的错误处理策略内容中,整合处理任何查询字串的异常状况。此方式的缺点是,它必须变更与查询字串相关之每一个网页的程式码 (我稍後会再详述此问题)。

  根据预设,abortOnError 属性会设为 True,表示查询字串中的任何异常将中止网页要求。每一个 <page> 节点底下都会有一份 <param> 节点清单,亦即每一个支援的查询字串参数都有一份。在范例程式码中,可使用 [图 2] 的属性来定义参数。

  Figure 2 <param> 节点支援的属性

属性 描述
Name 指出查询参数的名称。
Type 指出查询字串参数的型别。适用值如下:Text、Int、Bool。
Optional 布林 (Boolean) 属性,这会指出此参数是否为选用项目。根据预设,它是设定为 False。
Length 指出 Text 型别之参数的最大长度。
CaseSensitive 布林 (Boolean) 属性,指出参数名称是否区分大小写。根据预设,它是设定为 False,表示可以在查询字串上指定含有任何大小写字母组合的参数。

  在查询字串中传递的所有值,都会由 ASP.NET 接收为字串。因此,定义在 HttpRequest 物件上的 QueryString 属性,会是一个 NameValueCollection 物件,其中的索引键和值都是字串。不过,字串格式只是纯序列化的格式。每一个查询字串参数当然不只可以代表一个字串,也可以代表布林值或数值,还有像 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] 显示 QueryString HTTP 模组的原始程式码。如前所述,HTTP 模组类别会实作 IhttpModule,其中包含 Init 和 Dispose 方法。在应用程式内容中载入及卸载模组时,会叫用这些方法。在 Init 方法中,HTTP 模组一般会为它想要观察的应用程式事件注册接听项。在此案例中,它会为 BeginRequest 事件注册一个处理常式。此外,该模组也会处理 web.querystring 档案,并在记忆体中建立其内容的表示。每一个应用程式只会叫用 Init 方法一次 -- 这会读取并快取组态档的内容,因此,要等到重新启动 Web 应用程式时才会侦测到 web.querystring 档案的任何变更。因为在实际执行环境中,您不太可能需要输入 web.querystring 档案的变更,而不停止及重新启动应用程式,所以这应该不会是个问题。然而,您也可以延伸 [图 3] 的程式码,以使用档案 watcher 物件来侦测 web.querystring 档案的变更并即时重新载入它。

  Figure 3 QueryStringModule 类别

Imports System
Imports System.Web
Imports System.IO
Public Class QueryStringModule : Implements IHttpModule
  Private _app As HttpApplication
  Private _queryStringData As Hashtable
  Public Sub Init(ByVal context As System.Web.HttpApplication) _
      Implements System.Web.IHttpModule.Init
    _app = context
    AddHandler _app.BeginRequest, AddressOf OnEnter
    ' Load and cache the XML querystring file
    Dim fileName As String = _
      HttpContext.Current.Server.MapPath("web.querystring")
    _queryStringData = QueryStringHelper.LoadFromFile(fileName)
  End Sub
  Public Sub Dispose() Implements System.Web.IHttpModule.Dispose
  End Sub
  Private Sub OnEnter(ByVal source As Object, ByVal e As EventArgs)
    ' Retrieve the query string data structure for the current page
    Dim currentPage As String = _
      HttpContext.Current.Request.Path.ToLower()
    Dim qsDesc As QueryStringDescriptor = _
      _queryStringData.Item(currentPage)
    ' Validate the query string
    Dim isValid As Boolean
    isValid = QueryStringHelper.Validate( _
      HttpContext.Current.Request.QueryString, qsDesc)
    ' Abort the request if validation fails
    If Not isValid Then
      If qsDesc.AbortOnError Then
        HttpContext.Current.Response.StatusCode = 500
        HttpContext.Current.Response.[End]()
      Else
        ' Add information about the error to Context.Items
        HttpContext.Current.Items( _
          QueryStringHelper.QueryStringValidationStatus) = _
            QueryStringHelper.GetErrorCode()
      End If
    Else
      ' Add information for the page to the Context.Items
      HttpContext.Current.Items( _
        QueryStringHelper.QueryStringValidationStatus) = _
          QueryStringHelper.GetErrorCode()
      ' Add typed values 
      HttpContext.Current.Items( _
        QueryStringHelper.QueryStringValues) = _
          QueryStringHelper.GetTypedValues( _
            HttpContext.Current.Request.QueryString, qsDesc)
    End If
  End Sub
End Class

  web.querystring 档案的内容会对应到 QueryStringDescriptor 型别的物件,如 [图 4] 所示。此描述项会包含网页的 URL、指出验证失败时要执行之动作的旗标,以及所支援的查询字串参数清单。每一个参数都会透过 QueryStringParamInfo 类别的执行个体加以描述。QueryStringParamCollection 是相关集合类别。它是含有一对 Find 方法的一般泛型集合类别:一个用来确认集合中是否含有指定名称的参数,一个用来传回参数描述项执行个体。

  Figure 4 QueryString 模组的 Helper 类别

Friend Class QueryStringDescriptor
  Public Url As String
  Public AbortOnError As Boolean
  Public Parameters As QueryStringParamCollection
End Class
Friend Class QueryStringParamInfo
  Public Name As String
  Public [Type] As QueryStringParamTypes
  Public Length As Integer
  Public [Optional] As Boolean
  Public CaseSensitive As Boolean
End Class
Friend Class QueryStringParamCollection : Inherits Collection( _
    Of QueryStringParamInfo)
  Public Overloads Function Contains(ByVal name As String) As Boolean
    For i As Integer = 0 To Count - 1
      Dim comparison As StringComparison = _
        StringComparison.OrdinalIgnoreCase
      Dim currentItem As QueryStringParamInfo = Item(i)
      If currentItem.CaseSensitive Then
        comparison = StringComparison.Ordinal
      End If
      If String.Equals(currentItem.Name, name, comparison) Then
        Return True
      End If
    Next
    Return False
  End Function
  Public Function Find(ByVal name As String) As QueryStringParamInfo
    For i As Integer = 0 To Count - 1
      Dim currentItem As QueryStringParamInfo = Item(i)
      If String.Equals(currentItem.Name, name, _
          StringComparison.OrdinalIgnoreCase) Then
        Return currentItem
      End If
    Next
    Return Nothing
  End Function
End Class
<Flags()> _
Public Enum QueryStringErrorCodes
  NoError = 0
  TooManyParameters = 1
  InvalidQueryParameter = 2
  MissingRequiredParameter = 4
  InvalidContent = 8
End Enum

  查询字串描述项会快取给定网页之查询字串的相关资讯。不过,web.querystring 档案可以参照多重网页。因此,web.querystring 参照之所有网页的所有描述项,在杂凑表中都会使用网页 URL 做为索引键加以分组。下列程式码片段显示 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 之类的字串中剖析布林值。querystring HTTP 模组的验证子系统也接受 yes 和 no 之类的字串。

  最後,会剖析查询字串的内容并验证型别,此为要求管线中的第一个步骤。如果一切都没有问题,就会处理该要求。否则,会立即终止该要求,并传回适当的 HTTP 状态码。请看以下范例:

HttpContext.Current.Response.StatusCode = 500
HttpContext.Current.Response.[End]()

  会提供使用者一个网页,如 [图 5] 所示。您可能会抱怨这样无从了解 IIS 错误的真正原因,但是 HTTP 状态码和一般描述已明白指示错误出处 -- 该错误发生在要求处理期间的内部伺服器端。如前述 Michael Howard 文章中已说明,在错误页面上应尽量不要泄露资讯,以避免传出详细资讯给潜在骇客的风险。因此,HTTP 500 错误对於真正发生的问题含糊带过。反正如先前的程式码片段所示,HTTP 状态码是可以任意设定的。

  图 5 查询字串发生错误的结果

  注意事项和替代方案

  万一资料格式有错误,您就应该终止要求吗?或是应该在某处快取验证结果,然後让网页程式码对使用者做出最後决定?而且,您是否应该在要求存留期的最初就拦截并处理查询字串呢?让我们先探讨後面这一点。

  [图 6] 列出处理要求时的泛应用程式事件。如果不在要求的最开始处查询字串,那要在何处检查呢?紧接在授权之後是一个绝佳位置。如果该要求的进度超出授权阶段,应该就一定会叫用到网页 HTTP 处理常式。

  Figure 6 全域应用程式事件

事件 描述
BeginRequest 指出要求处理程序的开始。
AuthenticateRequest

  PostAuthenticateRequest

包装要求的验证程序。
AuthorizeRequest

  PostAuthorizeRequestPostAuthorizeRequest

包装要求的授权程序。
ResolveRequestCache

  PostResolveRequestCache>PostResolveRequestCache

包装的程序:检查是否能够以先前快取之输出网页为要求提供服务。
PostMapRequestHandler 指出已找到为要求提供服务的 HTTP 处理常式。
AcquireRequestState

  PostAcquireRequestState

包装的程序:撷取要求的工作阶段状态。
PostRequestHandlerExecute 指出已执行为要求提供服务的 HTTP 处理常式。
ReleaseRequestState

  PostReleaseRequestState

包装的程序:释放要求的工作阶段状态。
UpdateRequestCache

  PostUpdateRequestCache

包装的程序:检查是否应快取所要求资源的输出以供未来重复使用。
EndRequest 指出要求处理程序的结束。

  但可以稍後再进行吗?一般而言,包括 PostAcquireRequestState 在内的事件处理常式都可以运作。使用者程式码 -- 即网页作者使用程式码後置撰写的程式码,或是以内嵌在 ASPX 档案内的方式撰写的程式码 -- 只有在 PostAcquireRequestState 事件过去之後才会执行。此外,在引发 global PostAcquireRequestState 事件之前,网页并无法取用查询字串。然而,您不应该等这麽久。在授权之後及网页执行之前检查查询字串,可帮您省略其他一些作业 -- 亦即工作阶段状态的撷取及检查输出快取。如果您会因为查询字串有错误而删去网页,就没有理由先载入工作阶段状态,尤其如果它是来自跨处理序来源的话,如 SQL Server?。

  结论是,只有两个应用程式事件应该执行查询字串的检查:BeginRequest 或 PostAuthorizeRequest。如果需要使用者资讯来处理查询字串的话,就应该选择後者 (例如,如果容许某些使用者依据其角色指定特定参数)。在此案例中,您也可以将 roles 属性新增至 [图 1] 的结构描述中。在其他情况下,若在 BeginRequest 中设置拦截,即可在管线极早阶段删去网页,以防止进一步处理。

  如果您仍然想要由网页程式码去处理错误查询字串,并尝试正常地降级或回复,则情况将大不相同。就此而言,我想在网页执行之前的任何事件中处理都可以。我会选择 PostAcquireRequestState,这是在网页程式码执行之前、但在管线中可检查查询字串的最後位置。在这个位置,您也有可用的工作阶段状态。虽然我尚未提到这一点,但其内容已不言可喻:在要求内建物件的 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

  网页就应该会并入程式码以剖析查询字串值,并将它转换成布林值。在验证步骤期间,已在 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 网页都使用查询字串。不过,查询字串可做为网页的输入来使用。因此,它有可能成为有安全漏洞之网页被攻击的管道。如果网页需要查询字串关卡,请准备在所有您要使用查询字串的网页上重复撰写相同的程式码。

  本专栏所呈现的 QueryString 模组则不需要在来源网页上编写程式码,而是根据另存在个别 XML 档案中的给定结构描述,自动检查所张贴的查询字串。这表示不仅对现有程式码毫无影响,又能对攻击者提供更多内建防护关卡。但要牢记,这并不能完全杜绝可能发生的问题。


« 
» 
快速导航

Copyright © 2016 phpStudy | 豫ICP备2021030365号-3