在 ASP.NET Core 中防止跨站脚本 (XSS)

作者:Rick Anderson

跨站脚本 (XSS) 是一种安全漏洞,攻击者可利用此漏洞将客户端脚本(通常是 JavaScript)置于网页中。 当其他用户加载受影响的页面时,攻击者的脚本便会运行,从而使攻击者可盗取 cookie 和会话令牌、通过 DOM 操作更改网页的内容或是将浏览器重定向到其他页面。 当应用程序接收用户输入并将它输出到页面而不进行验证、编码或转义时,通常会出现 XSS 漏洞。

本文主要适用于 ASP.NET Core MVC,其中包含视图、Razor页面和其他返回可能易受 XSS 攻击的 HTML 的应用。 如果以 HTML、XML 或 JSON 形式返回数据的 Web API 未正确清理用户输入,则可能会在其客户端应用中触发 XSS 攻击,具体取决于客户端应用对 API 的信任程度。 例如,如果 API 接受用户生成的内容并在 HTML 响应中返回该内容,则攻击者可能会将恶意脚本注入到用户浏览器中呈现响应时执行的内容中。

为了防止 XSS 攻击,Web API 应实现输入验证和输出编码。 输入验证可确保用户输入符合预期条件,并且不包含恶意代码。 输出编码可确保正确清理 API 返回的任何数据,以便用户浏览器无法将其作为代码执行。 有关详细信息,请参阅此 GitHub 问题

针对 XSS 保护应用程序

在基本级别上,XSS 的工作原理是诱使应用程序将 <script> 标记插入到呈现的页面中,或是将 On* 事件插入到元素中。 开发人员应使用以下预防步骤来避免向其应用程序中引入 XSS。

  1. 切勿将不受信任的数据置于 HTML 输入中,除非按照以下其余步骤进行操作。 不受信任的数据是指可能由攻击者控制的任何数据,例如 HTML 窗体输入、查询字符串、HTTP 标头,甚至是源自数据库的数据,因为即使攻击者无法破坏你的应用程序,也可能能够破坏你的数据库。

  2. 将不受信任的数据置于 HTML 元素中之前,请确保其经过 HTML 编码。 HTML 编码接受诸如 < 等字符,并会将它们更改为安全形式,如 <

  3. 将不受信任的数据置于 HTML 属性中之前,请确保其经过 HTML 编码。 HTML 属性编码是 HTML 编码的超集,会对其他字符(如“and”)进行编码。

  4. 将不受信任的数据置于 JavaScript 中之前,请将数据置于你会在运行时检索其内容的 HTML 元素中。 如果无法做到这一点,请确保数据经过 JavaScript 编码。 JavaScript 编码会接收针对 JavaScript 的危险字符,然后将它们替换为其十六进制,例如 < 会编码为 \u003C

  5. 将不受信任的数据置于 URL 查询字符串中之前,请确保其经过 URL 编码。

使用 Razor 的 HTML 编码

MVC 中使用的 Razor 引擎会自动对源自变量的所有输出进行编码,除非你确实难以阻止它这样做。 每当你使用 @ 指令时,它便会使用 HTML 属性编码规则。 HTML 属性编码是 HTML 编码的超集,这意味着你不必担心自己是应该使用 HTML 编码还是 HTML 属性编码。 必须确保仅在 HTML 上下文中使用 @,而不是在尝试将不受信任的输入直接插入 JavaScript 中时使用。 标记帮助程序也会对在标记参数中使用的输入进行编码。

使用以下 Razor 视图:

@{
    var untrustedInput = "<\"123\">";
}

@untrustedInput

此视图输出 untrustedInput 变量的内容。 此变量包含在 XSS 攻击中使用的一些字符,即 <、"和 >。 检查源会显示编码为以下内容的呈现输出:

&lt;&quot;123&quot;&gt;

警告

ASP.NET Core MVC 提供一个 HtmlString 类,它在输出时不会自动编码。 切勿将此类与不受信任的输入结合使用,因为这会公开 XSS 漏洞。

使用 Razor 的 JavaScript 编码

有时可能要将值插入 JavaScript 中,以在视图中进行处理。 可通过两种方式来执行此操作。 插入值的最安全方法是将值置于标记的数据属性中,并在 JavaScript 中检索它。 例如:

@{
    var untrustedInput = "<script>alert(1)</script>";
}

<div id="injectedData"
     data-untrustedinput="@untrustedInput" />

<div id="scriptedWrite" />
<div id="scriptedWrite-html5" />

<script>
    var injectedData = document.getElementById("injectedData");

    // All clients
    var clientSideUntrustedInputOldStyle =
        injectedData.getAttribute("data-untrustedinput");

    // HTML 5 clients only
    var clientSideUntrustedInputHtml5 =
        injectedData.dataset.untrustedinput;

    // Put the injected, untrusted data into the scriptedWrite div tag.
    // Do NOT use document.write() on dynamically generated data as it
    // can lead to XSS.

    document.getElementById("scriptedWrite").innerText += clientSideUntrustedInputOldStyle;

    // Or you can use createElement() to dynamically create document elements
    // This time we're using textContent to ensure the data is properly encoded.
    var x = document.createElement("div");
    x.textContent = clientSideUntrustedInputHtml5;
    document.body.appendChild(x);

    // You can also use createTextNode on an element to ensure data is properly encoded.
    var y = document.createElement("div");
    y.appendChild(document.createTextNode(clientSideUntrustedInputHtml5));
    document.body.appendChild(y);

</script>

上面的 标记生成以下 HTML:

<div id="injectedData"
     data-untrustedinput="&lt;script&gt;alert(1)&lt;/script&gt;" />

<div id="scriptedWrite" />
<div id="scriptedWrite-html5" />

<script>
    var injectedData = document.getElementById("injectedData");

    // All clients
    var clientSideUntrustedInputOldStyle =
        injectedData.getAttribute("data-untrustedinput");

    // HTML 5 clients only
    var clientSideUntrustedInputHtml5 =
        injectedData.dataset.untrustedinput;

    // Put the injected, untrusted data into the scriptedWrite div tag.
    // Do NOT use document.write() on dynamically generated data as it can
    // lead to XSS.

    document.getElementById("scriptedWrite").innerText += clientSideUntrustedInputOldStyle;

    // Or you can use createElement() to dynamically create document elements
    // This time we're using textContent to ensure the data is properly encoded.
    var x = document.createElement("div");
    x.textContent = clientSideUntrustedInputHtml5;
    document.body.appendChild(x);

    // You can also use createTextNode on an element to ensure data is properly encoded.
    var y = document.createElement("div");
    y.appendChild(document.createTextNode(clientSideUntrustedInputHtml5));
    document.body.appendChild(y);

</script>

上面的代码生成以下输出:

<script>alert(1)</script>
<script>alert(1)</script>
<script>alert(1)</script>

警告

请勿在 JavaScript 中连接不受信任的输入以创建 DOM 元素或是对动态生成的内容使用 document.write()

使用以下方法之一可防止将代码公开给基于 DOM 的 XSS:

  • createElement()通过适当的方法或属性(如 node.textContent=node.InnerText=)分配属性值。
  • document.CreateTextNode()将其追加到适当的 DOM 位置。
  • element.SetAttribute()
  • element[attribute]=

在代码中访问编码器

HTML、JavaScript 和 URL 编码器可通过两种方式供代码使用:

  • 通过 依赖项注入注入它们。
  • 使用 System.Text.Encodings.Web 命名空间中包含的默认编码器。

使用默认编码器时,应用于字符范围的任何自定义都将被视为安全不会生效。 默认编码器会尽可能使用最安全的编码规则。

若要通过 DI 使用可配置编码器,构造函数应根据需要采用 HtmlEncoder、JavaScriptEncoder 和 UrlEncoder 参数。 例如;

public class HomeController : Controller
{
    HtmlEncoder _htmlEncoder;
    JavaScriptEncoder _javaScriptEncoder;
    UrlEncoder _urlEncoder;

    public HomeController(HtmlEncoder htmlEncoder,
                          JavaScriptEncoder javascriptEncoder,
                          UrlEncoder urlEncoder)
    {
        _htmlEncoder = htmlEncoder;
        _javaScriptEncoder = javascriptEncoder;
        _urlEncoder = urlEncoder;
    }
}

对 URL 参数进行编码

如果要在将不受信任的输入作为值的情况下生成 URL 查询字符串,请使用 UrlEncoder 对值进行编码。 例如,

var example = "\"Quoted Value with spaces and &\"";
var encodedValue = _urlEncoder.Encode(example);

编码后,encodedValue 变量会包含 %22Quoted%20Value%20with%20spaces%20and%20%26%22。 空格、引号、标点符号和其他不安全字符会以百分号编码为其十六进制值,例如,空格字符会变为 %20。

警告

请勿使用不受信任的输入作为 URL 路径的一部分。 始终将不受信任的输入作为查询字符串值进行传递。

自定义编码器

默认情况下,编码器使用限制为基本拉丁语 Unicode 范围的安全列表,会将该范围之外的所有字符都编码为其字符代码等效项。 此行为也会影响 Razor TagHelper 和 HtmlHelper 呈现,因为它会使用编码器输出字符串。

这背后的原因是为了防范未知或未来的浏览器 bug(以前的浏览器 bug 会阻碍基于非英语字符处理的分析)。 如果你的网站大量使用非拉丁字符(如中文、西里尔文或其他字符),这可能不是你所希望的行为。

Program.cs 中,可以自定义编码器安全列表,以在启动期间添加适用于应用的 Unicode 范围:

例如,使用类似于以下内容的 Razor HtmlHelper 使用默认配置:

<p>This link text is in Chinese: @Html.ActionLink("汉语/漢語", "Index")</p>

上述标记以中文文本编码呈现:

<p>This link text is in Chinese: <a href="/">&#x6C49;&#x8BED;/&#x6F22;&#x8A9E;</a></p>

若要扩大被编码器视为安全的字符范围,可在 Program.cs 中插入以下行:

builder.Services.AddSingleton<HtmlEncoder>(
     HtmlEncoder.Create(allowedRanges: new[] { UnicodeRanges.BasicLatin,
                                               UnicodeRanges.CjkUnifiedIdeographs }));

ConfigureServices() 中,你可以自定义编码器安全列表,以在启动过程中包含适用于你的应用程序的 Unicode 范围。

例如,使用默认配置时,可以使用 Razor HtmlHelper,如下所示;

<p>This link text is in Chinese: @Html.ActionLink("汉语/漢語", "Index")</p>

查看网页的源时,你会看到它按如下进行呈现(中文文本已进行编码);

<p>This link text is in Chinese: <a href="/">&#x6C49;&#x8BED;/&#x6F22;&#x8A9E;</a></p>

若要扩大被编码器视为安全的字符范围,可在 startup.cs 中将以下行插入到 ConfigureServices() 方法中;

services.AddSingleton<HtmlEncoder>(
     HtmlEncoder.Create(allowedRanges: new[] { UnicodeRanges.BasicLatin,
                                               UnicodeRanges.CjkUnifiedIdeographs }));

此示例将安全列表扩大到包含 Unicode 范围 CjkUnifiedIdeographs。 呈现的输出现在会变为

<p>This link text is in Chinese: <a href="/">汉语/漢語</a></p>

安全列表范围指定为 Unicode 代码图表,而不是语言。 Unicode 标准包含可用于查找包含你的字符的图表的代码图表列表。 每个编码器、Html、JavaScript 和 Url 都必须单独配置。

注意

安全列表的自定义仅影响来源为 DI 的编码器。 如果直接通过 System.Text.Encodings.Web.*Encoder.Default 访问编码器,则会使用默认的仅限基本拉丁语安全列表。

编码应在何处进行?

可接受的一般做法是在输出时进行编码,并且编码值绝不应存储在数据库中。 通过在输出时进行编码,可更改数据的使用,例如从 HTML 到查询字符串值。 这还使你可轻松搜索你的数据,而无需在搜索之前对值进行编码,并使你可利用对编码器进行的任何更改或 bug 修复。

将验证作为 XSS 防护方法

验证可能是限制 XSS 攻击的有用工具。 例如,仅包含字符 0-9 的数字字符串不会触发 XSS 攻击。 在用户输入中接受 HTML 时,验证会变得更加复杂。 分析 HTML 输入即使不是不可行,也是困难重重。 Markdown(与去除嵌入式 HTML 的分析器结合使用)是接受丰富输入的更安全选项。 切勿仅依赖于验证。 请在输出之前始终对不受信任的输入进行编码,无论执行了哪种验证或清理。