深入理解同源策略


深入理解同源策略

背景

浏览器作为用户通往网络的主要入口,其设计的核心目标之一便是保护用户数据和系统安全。

同源策略是浏览器最核心、最基本的安全功能 。其核心原则可以概括为:

一个源(Origin)加载的文档或脚本,如何与来自另一个源的资源进行交互,应当受到严格限制 。这项策略旨在隔离潜在的恶意文档,防止恶意网站通过在用户浏览器中执行脚本,读取用户在其他网站(如已登录的邮箱、公司内网)上的敏感数据 。

“源”的定义

“源”的定义是理解同源策略的关键。一个源由 协议(Scheme)、主机(Host)和端口(Port) 三者组成的元组(tuple)唯一确定 。只要这三个部分中任何一个不完全匹配,浏览器就会视其为不同的源。

同源策略的限制与许可

同源策略并非一刀切地禁止所有跨源交互。它的限制主要集中在防止数据被窃取上。通常,跨源交互可以分为三类 :

  • 跨源写入(Cross-origin writes): 通常被允许。例如,链接(<a> 标签)、重定向以及表单提交(<form>)。一个页面的表单可以向另一个域名的服务器提交数据。

  • 跨源嵌入(Cross-origin embedding): 通常被允许。这是现代网页功能的基础。例如,使用 <img> 标签嵌入来自其他域的图片,使用 <script> 标签加载第三方脚本(如 Google Analytics),或使用 <iframe> 嵌入其他网站的内容。

  • 跨源读取(Cross-origin reads): 通常被禁止。这是同源策略的核心保护机制。一个源的脚本不能直接读取另一个源返回的响应数据。例如,通过 fetch 或 XMLHttpRequest 发起的跨源请求,其响应体默认无法被 JavaScript 代码访问。

跨源资源共享 (CORS)

同源策略在保障安全的同时,也给现代 Web 应用的开发带来了限制。许多合法的应用场景需要跨源获取数据,例如前端应用从独立的 API 服务器请求数据。为了在不破坏安全的前提下满足这种需求 跨源资源共享 (Cross-Origin Resource Sharing, CORS) 机制应运而生。

CORS 并非绕过安全限制的后门,而是一种基于 HTTP 头的机制,它允许服务器明确声明哪些源有权限读取其资源。这是一个服务器端的”选择性加入”(opt-in)策略,将跨源访问的控制权交还给了资源提供方。

CORS 将跨源请求分为两类,它们的处理流程截然不同

简单请求 (Simple Requests): 简单请求不会触发一个额外的“预检”请求。其设计初衷是为了兼容历史,因为在 XMLHttpRequestCORS 出现之前,HTML 的 <form> 元素就已经可以发起跨源的 POST 请求。因此,标准制定者认为服务器开发者理应已经部署了针对这类请求的安全防护(如 CSRF 防护) 。

一个请求要成为“简单请求”,必须同时满足以下所有条件 :

  • 请求方法是 GET、HEAD 或 POST 之一。

  • HTTP 头中,除了由浏览器自动设置的头(如 Connection, User-Agent 等)之外,开发者手动设置的头只能是 CORS 安全列表中的几个,包括 AcceptAccept-LanguageContent-LanguageContent-Type

  • 当请求方法是 POST 时,Content-Type 的值必须是 application/x-www-form-urlencodedmultipart/form-datatext/plain 中的一种。

  • 请求中没有使用 ReadableStream 对象。

  • 如果使用 XMLHttpRequest,其 upload 属性上没有注册任何事件监听器。

对于简单请求,浏览器会直接发送请求,并在请求头中附加一个 Origin 字段,表明请求的来源。服务器根据这个 Origin 值判断是否允许该请求。如果允许,服务器需要在响应头中包含 Access-Control-Allow-Origin 字段,其值可以是请求的 Origin 或者是 *(表示允许任何源)。如果响应头中没有这个字段或字段值不匹配,浏览器会拦截该响应,不允许 JavaScript 代码读取,并会在控制台抛出 CORS 错误。

预检请求 (Preflighted Requests): 任何不满足“简单请求”条件的跨源请求,都会在发送实际请求之前,触发一次预检请求 。预检请求使用 OPTIONS 方法,其目的是向服务器“询问”实际请求是否安全,是否被允许。

常见的触发预检请求的场景包括:

  • 使用了 PUT、DELETE、PATCH 等非简单请求方法。

  • Content-Type 的值为 application/json

  • 请求中包含了自定义的 HTTP 头,例如 Authorization

预检请求的流程如下 :

浏览器发送一个 OPTIONS 请求到目标 URL。该请求包含 Origin 头,以及两个特殊的头:

  1. Access-Control-Request-Method: 告知服务器实际请求将使用的方法(如 PUT)。

  2. Access-Control-Request-Headers: 告知服务器实际请求将携带的自定义头(如 Content-Type, Authorization)。

服务器收到预检请求后,检查这些信息,并决定是否同意即将到来的实际请求。如果同意,服务器返回一个成功的响应(如 204 No Content),并在响应头中包含相应的 CORS 许可头:

  1. Access-Control-Allow-Origin: 必须包含请求的源。

  2. Access-Control-Allow-Methods: 必须包含实际请求将使用的方法。

  3. Access-Control-Allow-Headers: 必须包含实际请求将携带的自定义头。

浏览器收到肯定的预检响应后,才会发送实际的跨源请求。如果预检失败,浏览器将不会发送实际请求,并在控制台报错。

预检请求的性能影响与缓存

预检请求虽然保证了安全,但也带来了性能开销。每一次需要预检的跨源请求都会产生两次网络往返(一次 OPTIONS,一次实际请求),这会显著增加请求的延迟,对 API 密集型应用的影响尤为明显 。

为了缓解这个问题,CORS 规范提供了 Access-Control-Max-Age 响应头。服务器可以在预检响应中包含此头,以告知浏览器可以将该预检结果缓存多长时间(单位为秒) 。在缓存有效期内,对同一 URL 的同类请求将不再发送预检请求,直接发送实际请求,从而显著降低延迟 。

值得注意的是,浏览器自身会对 Access-Control-Max-Age 的值设置上限,这体现了不同浏览器厂商在性能与安全之间的权衡。

Firefox 的上限是 24 小时 (86400 秒) 。

Chromium (v76 及以后版本) 的上限是 2 小时 (7200 秒) 。

如果服务器未提供此头,Fetch 规范定义的默认值为 5 秒 。

在跨源交互中,Cookie 的发送行为是一个核心问题,直接关系到身份认证和会话保持。SameSite 属性是 Set-Cookie HTTP 响应头的一个关键指令,它允许服务器定义 Cookie 在跨站(Cross-site)请求中应如何被发送,是防御跨站请求伪造(CSRF)攻击的有力武器 。

SameSite 属性有三个主要值 :

  • Strict: 最严格的模式。Cookie 只会在同站请求中被发送。即使用户从一个外部网站点击链接导航到当前网站,该 Cookie 也不会被发送。

  • Lax: 现代浏览器的默认值 。在跨站请求中,Cookie 不会随着子请求(如加载图片、<iframe>)发送,但会在用户通过顶层导航(如点击链接)跳转到目标网站时发送。这在安全性和可用性之间取得了很好的平衡。

  • None: 允许 Cookie 在所有跨站请求中发送。这对于需要跨站传递状态的场景(如嵌入式第三方登录组件、广告跟踪)是必需的。然而,为了防止滥用,规范要求设置 SameSite=None 的 Cookie 必须同时设置 Secure 属性,意味着该 Cookie 只能通过安全的 HTTPS 连接传输 。

这些安全策略并非孤立存在,而是构成了一个层级分明的决策体系。同源策略是基础的“默认拒绝”规则。CORS 是服务器端的一种“明确授权”机制,用于放宽同源策略对“读”操作的限制。而 SameSite 则是服务器对单个 Cookie 设置的“发送策略”,它在浏览器端执行,其优先级非常高。

一个典型的混淆场景是: 开发者在服务器端配置了 Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: true, 并在客户端的 fetch 请求中设置了 credentials: 'include', 意图是允许跨源请求携带凭证。然而,如果服务器设置会话 Cookie 时没有指定 SameSite 属性(或使用了现代浏览器的默认值 Lax),浏览器在发起这个跨源 fetch 请求时,仍然会拒绝发送该 Cookie。这是因为 Cookie 自身的 SameSite 策略禁止了它在跨站子请求中被发送。在这个决策链中,Cookie 的策略成为了最终的守门人,覆盖了服务器的 CORS 许可和开发者的请求意图。理解这种策略的层级和优先级对于调试复杂的跨源认证问题至关重要。

声明式请求和命令式请求的同源策略

之前我们提到,浏览器的网络请求根据其发起方式,可以分为两大类: 声明式请求和命令式请求。前者由浏览器解析 HTML 文档时自动发起,后者则由开发者通过 JavaScript 代码访问码主动调用。这两种请求方式在处理跨源资源时,其默认行为和安全模型存在根本性的差异,这也是许多人感到困惑的核心所在。

  • 对于同源的声明式请求,浏览器没有任何限制。资源会被正常获取,并且其内容对于页面是完全可用的。

  • 对于跨源的声明式请求,浏览器默认采用的是 no-cors 请求模式 。no-cors 模式的特点是: 请求可以成功发送,资源也可以被下载和使用(例如,图片被显示,脚本被执行),但其响应对于发起页面的 JavaScript 代码来说是不透明的(Opaque)。

这意味着:

画布污染 (Canvas Tainting): 如果你尝试将一个通过 <img> 标签加载的跨源图片绘制到 <canvas> 元素上,然后试图通过 getImageData()toDataURL() 等方法读取画布的像素数据,浏览器会阻止出安全错误。此时,该画布被认为是“已污染”的 。

脚本错误信息屏蔽: 如果一个通过 <script> 标签加载的跨源脚本在执行时发生错误,window.onerror 事件处理器无法捕获到详细的错误信息(如错误消息、文件名、行号和列号)。你只会得到一个非常笼统的 "Script error." 提示 。这使得跨域脚本的调试变得异常困难。

  • 对于同源的命令式请求,浏览器没有限制,资源会被正常获取。

  • 对于跨源的命令式请求,浏览器默认采用的是 cors 请求模式 。cors 模式必须遵守 CORS 规则。如果目标服务器没有返回正确的 CORS 响应头,浏览器会彻底阻止响应数据到达 JavaScript 代码。

声明式元素的 no-cors 默认行为遵循的是”最小意外原则”(Principle of Least Surprise),为了保持对海量存量网页的向后兼容。而 fetch 等较新的命令式 API 则遵循”默认安全原则”(Secure by Default)。

命令式请求在跨源请求中管理凭证

Fetch API: 通过 credentials 选项来精确控制凭证的发送 。

  • ‘same-origin’ (默认值): 只在同源请求中发送凭证。

  • ‘include’: 在同源和跨源请求中都发送凭证。

  • ‘omit’: 任何情况下都不发送凭证。

XMLHttpRequest: 通过布尔类型的 withCredentials 属性实现。xhr.withCredentials = true; 的效果等同于 fetch 中的 credentials: 'include'

需要再次强调的是,Cookie 自身的 SameSite 属性拥有更高的优先级。即便一个 fetch 请求设置了 credentials: 'include',如果目标 Cookie 的 SameSite 属性为 Lax 或 Strict,浏览器依然会阻止该 Cookie 在这次跨源请求中被发送 。请求本身会发出(不带此 Cookie),但服务器将无法收到预期的会话信息。

总结

为了直观地总结声明式请求与命令式请求在 CORS 行为上的差异,下表进行了详细对比:


场景请求类型客户端设置服务器要求结果分析
跨源,无特殊设置<img>(无 crossorigin 属性)请求成功。图片正常显示。但响应对 JS 不透明(画布污染)。
fetch()mode: 'cors' (默认)必须返回 Access-Control-Allow-Origin请求失败。fetch Promise 因网络错误被拒绝。
跨源,请求匿名访问<img>crossorigin="anonymous"必须返回 Access-Control-Allow-Origin请求成功。响应对 JS 透明。
fetch()mode: 'cors' (默认),<br>credentials: 'same-origin' (默认)必须返回 Access-Control-Allow-Origin请求成功。响应对 JS 透明。
跨源,请求凭证访问<img>crossorigin="use-credentials"必须返回 Access-Control-Allow-OriginAccess-Control-Allow-Credentials: true请求成功 (若凭证有效)。响应对 JS 透明。
fetch()mode: 'cors' (默认),<br>credentials: 'include'必须返回 Access-Control-Allow-OriginAccess-Control-Allow-Credentials: true请求成功 (若凭证有效)。响应对 JS 透明。

参考阅读