跨域与同源策略问题
什么是同源策略
同源策略(SOP same origin policy)指的是,两个网页的协议、域名和端口都相同。 但 Windows RT IE 是例外的,对它而言,端口号并不是同源策略的组成部分之一。
同源策略的变化
同源策略最初的要求是,同源的网页才能打开同源网页下的 cookie。cookie 实际上是一种对浏览器用户总是可见,但对 javascript 代码不总可见的内容。
同源策略大多数关于读,而且同源总能用沙箱解释
但现代的同源策略起了轻微的变化:
为了网络安全,浏览器为每一个“源”(Origin)都创建了一个独立的、互不干扰的沙箱环境。您可以把每个沙箱想象成一个独立的“安全屋”,里面存放着属于这个源的所有资源:它的 DOM 结构、数据存储(localStorage 等)以及运行的脚本。
同源策略就是这个沙箱模型的核心规则:一个沙箱里的脚本,不能随意访问另一个沙箱里的任何资源。
限制存储和当前页面的资源
DOM 访问限制
当一个页面(如a.com
)通过<iframe>
嵌入另一个不同源的页面(如 b.com
)时,浏览器实际上是创建了两个独立的沙箱。
a.com
的 DOM 存在于它自己的沙箱中。<iframe>
中b.com
的 DOM 则存在于另一个完全独立的沙箱中。
沙箱坚固的“墙壁”阻止了a.com
的脚本伸入b.com
的沙箱去操作其 DOM,反之亦然。这就防止了恶意页面通过内嵌来窃取另一个页面上的敏感信息。
- 例外情况:过去,如果两个沙箱的源主域相同(如
a.example.com
和b.example.com
),可以通过将双方的document.domain
都设置为父域example.com
,在沙箱之间“开一扇小门”。
数据存储限制
客户端数据同样被严格地存放在各自的沙箱里。
localStorage
和IndexedDB
这两者是沙箱的“标准住户”,被严格限制在创建它们的源所对应的沙箱内部,外部无法访问。Cookie
Cookie
的规则比较特殊,它不完全受沙箱的约束,有自己的一套“通行证”规则。Cookies
使用不同的源定义方式。一个页面可以为本域和任何父域设置Cookie
,只要是父域不是公共后缀(public suffix)即可。设置cookie时,你可以使用Domain
,Path
,Secure
,和Http-Only
标记来限定其访问性。- 公共后缀 (Public Suffix):指那些不代表独立实体的顶级域名,浏览器不允许为这些域名设置站点范围的 Cookie。例如:
.com
、.org
、.net
.co.uk
(英国).gov.au
(澳大利亚政府).com.cn
(中国)
- 父域设置示例:通过在
sub.example.com
页面设置Domain=.example.com
,可以让www.example.com
也能访问到这个Cookie
。
沙箱如何限制网络读写
沙箱不仅隔离本地资源,也管理着对外的网络通信。
跨源写操作 (Cross-origin Writes)
通常被允许,但有条件:沙箱对“写”操作的限制比“读”要宽松。它允许那些在 AJAX 技术出现前就已经存在的、会改变服务器状态的传统跨域请求,例如:
<a>
标签的链接跳转- 页面重定向
- 简单的
<form>
表单提交
预检请求 (Preflight Request):然而,这种允许并非无条件的。对于一些现代的、可能带有副作用的 HTTP 请求(例如使用 PUT、DELETE 方法,或者发送
Content-Type
为application/json
的 POST 请求),浏览器会采取更谨慎的策略。它会首先发送一个轻量的“预检”请求(使用 OPTIONS 方法)到目标服务器,像是在正式写入数据前,先问一下服务器:“我能用这种方式和你通信吗?”。只有在服务器通过预检响应明确表示许可后,真正的请求才会被沙箱放行。
跨源读操作 (Cross-origin Reads)
- 通常不被允许:这是沙箱最核心的防护机制之一,因为“读”操作可能导致敏感数据泄露。
- 工作原理:当沙箱内的脚本(如
XMLHttpRequest
或fetch
)试图读取一个来自不同源的响应时,请求本身可以被发送出去,服务器也会返回数据。但当响应数据回到浏览器时,沙箱的“守卫”会检查响应的来源。如果发现它来自一个不同的源,守卫就会将数据拦截下来,阻止沙箱内的脚本读取它,这也就是常说的“能发 request,不能收 response”。
跨源资源嵌入 (Cross-origin Embedding)
- 通常被允许:沙箱允许页面通过标签嵌入来自不同源的资源。这恐怕也是现阶段很多 CSRF 攻击的根源,因为
<img>
的 src 可以是一个会产生副作用的 GET 请求 URL,这个请求会悄无声息地发生。 - 允许嵌入的标签示例
<script src="..."></script>
<link href="..."></link>
<img src="...">
<iframe src="..."></iframe>
<video>
和<audio>
不受同源限制的例外
WebSocket
WebSocket是一种通信协议,使用ws://
(非加密)和wss://
(加密)作为协议前缀。该协议不实行同源政策,只要服务器支持,就可以通过它进行跨源通信。
从浏览器的角度看,它确实允许 JavaScript 代码向任何来源(any origin)发起 WebSocket 连接请求,这一点和图片、CSS、JS 脚本等资源的加载行为类似。但是,这并不意味着连接是无条件的。安全性检查的责任转移到了服务器端。
核心问题:为什么 WebSocket “不需要” SOP 保护?
WebSocket 并没有被豁免,而是采用了与 HTTP 请求(AJAX/Fetch)不同的安全模型。
HTTP 请求 (AJAX/Fetch) 的安全模型:请求后验证
- 模型:浏览器先发送请求,然后在收到服务器的响应后,检查响应头里有没有
Access-Control-Allow-Origin
(CORS) 头部。 - 谁负责:浏览器是安全检查的主要执行者。它看到脚本想读取跨域响应,就会主动进行拦截,除非服务器明确通过 CORS 头部表示“许可”。
- 比喻:你派人(请求)去隔壁公司拿一份文件(数据)。你的人到达后,隔壁公司把文件给了他。但在他回来把文件交给你(脚本)之前,你公司大楼的保安(浏览器)会检查他有没有对方公司开的“文件交接许可单”(CORS 头部)。没有许可单,保安就把文件没收了,你拿不到。
WebSocket 的安全模型:连接前验证
- 模型:WebSocket 连接不是一个简单的 HTTP 请求,它始于一个“HTTP - 升级(Upgrade)”请求。在这个初始的握手请求中,浏览器会自动添加一个 Origin 头部,告诉服务器这个连接请求来自哪个源(例如 Origin: https://evil.com)。
- 谁负责:服务器是安全检查的主要执行者。服务器收到握手请求后,必须检查这个 Origin 头部。如果这个来源是它所允许的,它就同意升级请求,建立连接。如果是不允许的来源,服务器就应该直接拒绝这个握手请求,连接从一开始就无法建立。
- 比喻:你想和隔壁公司建立一条专线电话(WebSocket 连接)。你让你的电话总机(浏览器)去呼叫对方。总机会告诉对方:“我是 A 公司的总机(Origin 头部),想和你们建立专线通话。” 隔壁公司的总机(服务器)会看这个来电显示,如果 A 公司在他们的白名单上,就接通电话,专线建立。如果不在,就直接挂断,通话根本不会开始。
总结
所以,WebSocket 并不是没有安全策略,而是它的安全策略从“浏览器在事后检查 CORS 头部”转移到了“服务器在连接建立前检查 Origin 头部”。
AJAX/Fetch 的保护是浏览器端的,防止脚本读取未经授权的跨域数据。
WebSocket 的保护是服务器端的,防止未经授权的页面与自己建立持久的双向通信。
一旦 WebSocket 连接成功建立,后续在这条“管道”里传输的数据就不再受同源策略的逐条审查了,因为在建立连接的那一刻,双方已经确认了彼此的身份并同意了通信。
preflight 问题
为什么
想象一下,你要给一位重要客户(服务器)打一通非常规的、可能会占用他很长时间的电话(比如一个 PUT 或 DELETE 请求,这可能会修改或删除数据)。
如果你直接打过去就开始说正事,可能会打断客户正在进行的重要会议,造成不好的后果。
一个更礼貌和安全的方式是:
- 预先发条短信(Preflight Request):“王总您好,我准备和您电话沟通一下关于删除项目A数据的操作,您现在方便吗?这个电话可能会涉及数据修改。”
- 客户回复短信(Preflight Response):
- 同意:“可以,你打过来吧。我允许你谈论这个话题。”
- 拒绝:“我现在不方便,或者我们公司规定不允许电话里谈论删除数据的事。”
- 发起正式通话(Actual Request):只有在收到同意的短信后,你才会拨打那通正式的电话。如果被拒绝,你就不会再打这个电话了。
这里的**“预先发短信确认”**这个动作,就是 Preflight(预检)。
非简单跨域请求
Preflight(预检请求) 是浏览器在发送**“非简单跨域请求”之前,自动发起的一个HTTP OPTIONS 请求**。这个请求的目的就是去问服务器,即将要发送的这个真实请求是否安全、是否被服务器所允许。
它就像是浏览器和服务器之间的一次**“安全握手”或“投石问路”**。
触发条件:为什么会发生 Preflight?
- 请求方法不是 GET, HEAD, POST 之一。比如使用了 PUT, DELETE, PATCH 等方法。
- POST 请求的
Content-Type
不是application/x-www-form-urlencoded
,multipart/form-data
或text/plain
。比如,现在最常见的前后端交互方式是发送application/json
格式的数据,这就会触发 Preflight。 - 请求中包含了自定义的 Header。比如,前端为了身份验证,在请求头里加了一个
Authorization
:Bearer <token>
,这也会触发 Preflight。
核心原因:这些“非简单请求”都具备一个特点——它们是在 AJAX 出现后才被广泛用于网页的技术,可能会对服务器数据进行修改(比如 PUT 和 DELETE),或者携带了传统 HTML 表单无法发送的复杂信息(比如 JSON 数据和自定义 Header)。浏览器为了保护服务器,必须先问一声:“服务器老兄,你认识这些新玩法吗?你允许我这么做吗?”
工作流程
浏览器(发起 Preflight):自动发送一个 OPTIONS 请求到目标 URL。这个请求包含几个关键的 Header:
Access-Control-Request-Method
: 告诉服务器,我接下来想用什么方法(比如 PUT)。Access-Control-Request-Headers
: 告诉服务器,我接下来想带哪些自定义请求头(比如 Authorization)。Origin
: 告诉服务器,这个请求来自哪个源(比如https://my-app.com
)。
服务器(响应 Preflight):服务器收到这个 OPTIONS 请求后,检查这些信息,并根据自己的跨域策略(CORS 配置)来决定是否同意。
- 如果同意,服务器会返回一个 200 或 204 的成功响应,并且响应头里必须包含以下信息来“授权”:
Access-Control-Allow-Origin
: “我允许https://my-app.com
这个源访问我。”Access-Control-Allow-Methods
: “我允许的请求方法包括 GET, POST, PUT 等。”Access-Control-Allow-Headers
: “我允许你携带Content-Type
和Authorization
这些请求头。”Access-Control-Max-Age
(可选): “在接下来的N秒内,同样的请求不用再发 Preflight 了,我直接给你授权。”(用于性能优化)
- 如果不同意,服务器就不会返回上述这些 Header。
- 如果同意,服务器会返回一个 200 或 204 的成功响应,并且响应头里必须包含以下信息来“授权”:
浏览器(决策):浏览器收到 Preflight 的响应后:
- 如果响应中的授权信息与即将发送的真实请求匹配,浏览器就会发送真实的 PUT 请求。
- 如果不匹配或者服务器压根没返回这些授权 Header,浏览器就会拦截真实的 PUT 请求,并在控制台抛出我们熟悉的 CORS 错误。
这里面有很容易被混淆的流程是,请求的时候是Access-Control-Request-Method
,响应的是时候是Access-Control-Allow-Methods
。
问题排查:为什么我的请求失败了?
这是开发者最常遇到 Preflight 的场景。当一个跨域请求失败时,我们打开浏览器控制台的“网络(Network)”面板,会看到:
- 一个 OPTIONS 请求的状态是 200 OK,但后面的真实请求(如 PUT)是红色的 (failed)。
- 或者 OPTIONS 请求本身就是红色的 (failed)。
这通常意味着服务器端的 CORS 配置出了问题。我们在排查时,就是在检查服务器为什么没有正确地响应 Preflight 请求。比如:
- 是不是
Access-Control-Allow-Origin
没包含我们的前端地址? - 是不是
Access-Control-Allow-Methods
忘了加 PUT? - 是不是
Access-Control-Allow-Headers
忘了加我们自定义的那个 Header?
简单请求
所有的 PUT 都会触发 preflight,但是只有一部分 post 会触发 preflight。
- 只有 GET, HEAD, POST 这三种方法有机会成为“简单请求”。
- 当一个 POST 请求同时满足以下所有条件时,它被视为“简单请求”:
Content-Type
是以下三者之一:application/x-www-form-urlencoded
(HTML 表单提交的默认类型)multipart/form-data
(HTML 表单用于上传文件时的类型)text/plain
- 没有自定义的请求头 (比如
Authorization
,X-Custom-Header
等)。
为什么这些是“简单”的? 因为这些是传统 HTML <form>
标签就能发出的请求。在 AJAX 和现代 Web API 出现之前,浏览器就已经支持这种形式的跨域表单提交了。因此,为了向后兼容并维持互联网长久以来的运作方式,规范将这类请求视为“简单”和“安全”的,服务器理应有能力处理它们,所以不需要预检。
当一个 POST 请求不满足上述“简单”条件时,它就变成了“非简单请求”,需要预检。最常见的情况是:
Content-Type
是application/json
: 这是现代前后端分离应用中最常见的 API 数据格式。由于它不是传统 HTML 表单能发出的类型,所以被认为是非简单的。- 请求中包含了自定义 Header: 只要你加了任何一个不在“简单请求”允许列表里的 Header(比如为了身份验证加了
Authorization
头),整个请求就变成了非简单的。
所以,常见的 POST RESTful 调用通常都需要考虑跨域问题。
总结
Preflight 是一种由浏览器自动触发的、用于保护服务器安全的前置检查机制。
当我们谈论它时,我们实际上是在谈论现代 Web 应用中跨域通信的安全模型:它如何区分“简单”和“复杂”的交互,如何通过一次额外的 OPTIONS “握手”来确保服务器知情并同意那些可能改变其状态的请求,以及在出现问题时,如何根据这个机制去排查服务器端的配置错误。
options 问题
在现代 Web 开发的跨域(CORS)场景下,OPTIONS 方法的核心和最主要的角色就是专门用来执行 **Preflight(预检)**请求。
然而,从更广泛的 HTTP 协议定义来看,OPTIONS 并非 仅仅 为 Preflight 而生。它有一个更通用的、原始的用途。
我们来区分一下这两个角色:
主要角色:CORS 预检请求 (Preflight)
这是我们今天几乎所有开发者遇到 OPTIONS 的场景。
- 目的:在发送“非简单”跨域请求(如 PUT 或带 application/json 的 POST)之前,由浏览器自动发起,用来询问服务器是否允许即将到来的真实请求。
- 谁发起:浏览器自动发起,开发者通常不需要手写 OPTIONS 请求。
- 服务器响应:服务器需要返回一系列
Access-Control-*
相关的响应头(如Access-Control-Allow-Methods
,Access-Control-Allow-Headers
)来“授权”。 - 本质:这是一次安全握手,是浏览器强制执行的 CORS 安全策略的一部分。
原始角色:查询服务器能力
这是 HTTP/1.1 规范中 OPTIONS 方法的通用定义,这个用途比 CORS 的概念要早。
- 目的:客户端(不一定是浏览器)可以主动发送一个 OPTIONS 请求给服务器的某个 URL,用来查询该 URL 支持哪些 HTTP 请求方法。
- 谁发起:任何 HTTP 客户端都可以手动发起。
- 服务器响应:服务器应该在响应头中返回一个 Allow 字段,列出所有支持的方法。例如:Allow: GET, POST, PUT, HEAD。
- 本质:这是一个发现机制或探测机制。就像你走到一个自动售货机前,按下一个“查询”按钮,机器会点亮所有有货的商品按钮,告诉你“这些是你现在可以按的”。
举个例子:
一个 API 客户端想知道它是否可以更新某个用户信息,它可以先发送一个 OPTIONS 请求:
1 |
|
如果服务器允许对该用户进行读取、更新和删除,它可能会这样响应:
1 |
|
客户端收到这个响应后,就知道:“哦,我可以用 PUT 方法去更新这个用户的信息。”
特性 | CORS Preflight (主要用途) | 查询服务器能力 (原始用途) |
---|---|---|
目的 | 跨域安全检查,为真实请求“铺路” | 发现指定 URL 支持哪些 HTTP 方法 |
触发方 | 浏览器在特定条件下自动触发 | 客户端 (浏览器、代码、工具) 手动发起 |
关键响应头 | Access-Control-Allow-Methods Access-Control-Allow-Headers |
Allow |
常见程度 | 非常常见,是前后端分离开发的日常 | 相对少见,多用于 RESTful API 测试工具 |
在实际工作中,我们几乎可以认为 OPTIONS 就是专门为 Preflight 服务的。 尽管它有一个更通用的原始定义,但那个定义在日常开发中用得很少,而 CORS Preflight 则是 OPTIONS 方法如今最重要、最无可替代的舞台。
云设施下的配置
在现代应用中,似乎我们都是配置反向代理的策略(特别是在 aws 的 cdn 或者 alb 里),来决定什么时候返回
Access-Control-Allow-Methods、Access-Control-Allow-Headers,而不是由代码逻辑决定返回什么头,头里返回什么东西,这正常吗?
将 CORS 策略(包括Access-Control-Allow-Methods
,Access-Control-Allow-Headers
,Access-Control-Allow-Origin
等)从应用代码中剥离,统一在**基础设施层面(如反向代理、API 网关、CDN、ALB)**进行配置,是主流且推荐的做法。
为什么这是一种好方法?
关注点分离 (Separation of Concerns)
- 应用代码:应该专注于核心的业务逻辑。一个处理订单的 API,其代码最应该关心的是如何创建、验证和存储订单数据。
- 基础设施:应该负责处理横切关注点 (Cross-Cutting Concerns),如安全性、路由、限流、日志记录和 CORS。
- 结论:CORS 本质上是一个安全和浏览器通信的策略,它与“如何处理订单”这个业务逻辑无关。将它放在基础设施层,可以让应用开发者更专注于业务,让架构更清晰。
集中化管理与一致性
- 在一个微服务架构中,你可能有几十甚至上百个服务。如果每个服务都在自己的代码里配置 CORS,很容易出现不一致、遗漏或错误。
- 在 API Gateway 或 ALB 这种统一入口点配置 CORS,可以确保所有下游服务都遵循同一套安全策略。更新策略时,也只需要修改一个地方,大大降低了维护成本和风险。
性能优化
- 大量的 Preflight OPTIONS 请求是“非简单”跨域请求的常态。
- 如果由反向代理或 CDN 来处理这些 OPTIONS 请求,它们可以直接响应,而无需将请求转发到后端的应用服务器。
- 这极大地减轻了应用服务器的负担,因为它不再需要花费 CPU 和内存去处理这些“探测性”的请求,可以把资源留给真正处理业务的 GET, POST, PUT 等请求。
安全性
- 在基础设施层面统一强制执行 CORS 策略,可以防止某个服务的开发者因疏忽而在代码中配置了过于宽松的策略(比如
Access-Control-Allow-Origin: *
),从而引入安全漏洞。策略由安全或运维团队在网关层统一把控,更加稳妥。
简化开发
- 应用开发者不再需要关心 CORS 的复杂细节。他们不需要在每个项目中都安装和配置 CORS 相关的库或中间件,从而简化了开发和部署流程。
什么时候在代码中处理 CORS 仍然有意义?
尽管在基础设施层处理是最佳实践,但在某些特定场景下,代码层面的处理仍然是合理甚至必要的:
动态和精细化的 CORS 策略:
- 场景:假设你希望
Access-Control-Allow-Origin
的值是动态的。例如,你允许多个租户使用你的 API,并且只允许来自该租户自己注册的域名的请求。 - 原因:这种逻辑(“去数据库里查一下当前 API Key 对应的租户域名是什么”)是业务逻辑的一部分,很难在静态的代理配置中实现。这时,就需要在应用代码中动态生成 CORS 响应头。
简单的单体应用:
- 场景:你只有一个简单的、独立的后端服务,前面没有复杂的 API Gateway 或 ALB。
- 原因:在这种情况下,为了实现 CORS 而引入一套复杂的反向代理是不必要的开销。直接在代码中使用一个中间件(如 Node.js 的 cors 包)来处理,是最简单直接的方案。
本地开发环境:
- 场景:在本地开发时,为了方便前端调试,通常会在后端代码里启用一个非常宽松的 CORS 策略。
- 原因:这比在本地也搭建一套完整的反向代理要方便得多。
配置位置 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
基础设施层(推荐) | • 关注点分离 • 集中管理 • 性能高 • 更安全 • 简化开发 |
• 无法处理非常动态的、依赖业务逻辑的策略 | • 生产环境 • 微服务架构 • 大多数现代应用 |
应用代码层 | • 灵活,可实现动态策略 • 设置简单 |
• 逻辑耦合 • 配置分散 • 性能开销 • 可能不一致 |
• 需要动态策略的场景 • 简单的单体应用 • 本地开发 |
如何绕开同源策略限制
修改源
这个方法但只能升源,不能跨源,但能改变 cookie 从属的域。
页面可以修改自己的源,但只能用它的脚本将document.domain
的值设置成其当前域或当前域的超级域。如果将其设置为当前域的超级域,则较短的域将用于后续原始检查。
MDN 里举了一个例子,假设文档中的一个脚本在http://store.company.com/dir/other.html
执行以下语句:
1 |
|
页面将会成功地通过对http://company.com/dir/page.html
的同源检测。而同理,company.com
不能设置document.domain
为othercompany.com
。
但改域还是要注意端口号问题:
- 你不能给
document.domain
赋值时带上端口号。执行document.domain = "company.com:8080"
; 会直接抛出安全错误。 - 根据 HTML 规范,当
document.domain
被成功赋值后,该源的端口号在用于同源检测时,会被重置为 null。 - 假设页面 A 来自
company.com:8080
,页面 B 来自company.com:8000
。它们不同源(因为端口不同)。但如果两个页面都执行了document.domain = "company.com"
;,那么在进行同源比较时,它们的源都变成了 (https
,company.com
,null
)。因为协议、域名、端口(现在都是 null)都相同了,所以它们现在被认为是同源的。
还有一个需要对父页面重新赋值的注意事项:
使用document.domain允许子域安全访问其父域时,您需要设置document.domain在父域和子域中具有相同的值。这是必要的,即使这样做只是将父域设置回其原始值。否则可能会导致权限错误。
document.domain 的真正作用:是放宽脚本之间对 DOM 的访问权限。最典型的场景是,一个页面 (parent.html
from company.com
) 中有一个iframe
,这个iframe
加载了另一个页面 (child.html
from store.company.com
)。在默认的同源策略下,parent.html
的脚本无法访问 child.html
的 window 对象,反之亦然。当两个页面都设置了document.domain = "company.com"
; 后,它们就可以互相访问对方的 DOM 了。它的目的在于脚本交互,而非 Cookie 共享。
iframe
如果两个网页不同源,就无法拿到对方的DOM。典型的例子是iframe窗口和window.open方法打开的窗口,它们与父窗口无法通信。
HTML5为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。
这个API为window对象新增了一个window.postMessage方法,允许跨窗口通信,不论这两个窗口是否同源。
举例来说,父窗口http://aaa.com向子窗口http://bbb.com发消息,调用postMessage方法就可以了。
postMessage 是除了修改 document.domain 以外的另一种方法。
特性 | document.domain | window.postMessage | 结论 |
---|---|---|---|
核心用途 | 专门为了直接访问 DOM。 | 专门为了安全地传递消息。 | postMessage 用途更通用 |
限制 | 极其严格:只能在主域相同的子域之间使用 (e.g., a.company.com 和 b.company.com)。 | 无限制:可以在任何两个窗口之间使用,无论它们是否同源 (a.com 和 b.com 完全可以)。 | postMessage 适用性 完胜 |
安全模型 | 不安全,权限过大。一旦设置成功,就相当于完全打开了 DOM 访问的大门,对方窗口可以对你的页面做任何 DOM 操作,控制粒度为 0。 | 安全,权限可控。你只传递你需要的数据,而不是整个 DOM 的控制权。接收方必须验证消息来源 (event.origin),确保只接受信任窗口的消息。 | postMessage 安全性 完胜 |
通信方式 | 同步的、直接的 DOM 操作。 | 异步的、事件驱动的 (message 事件监听)。 | postMessage 更符合现代编程范式 |
数据类型 | 只能通过操作 DOM 来”传递”信息,非常笨拙。 | 可以传递字符串、JSON 对象等任何可序列化的数据,非常灵活。 | postMessage 更灵活 强大 |
使用场景 | 遗留系统,或者有特殊需求必须在子域间直接操作 DOM 的古老场景。 | 所有需要跨窗口通信的现代 Web 应用,如:内嵌的 iframe 服务、第三方组件、Web Widget 等。 | postMessage 是现代 标准 |
经典的“不经授权的 js 片段读取内存里的信息”的问题
第一层防御:浏览器自身的安全沙箱(同源策略)
localStorage
和IndexedDB
是沙箱的“标准住户”,被严格限制在创建它们的源所对应的沙箱内部。例如,b.com
的脚本绝对无法读取a.com
的localStorage
。第二层防御:防止恶意脚本注入(防范 XSS) 同理,如果攻击者通过 XSS 攻击将脚本注入了
a.com
,那么这个脚本就位于沙箱之内,可以自由读取a.com
的localStorage
和IndexedDB
中的所有数据。这再次凸显了 XSS 防御是保护沙箱内部数据不被窃取的第二层生命线。第三层防御:代码层面的封装(闭包) 作为纵深防御的最后一环,开发者可以在代码层面增加保护。例如,一个极其敏感的令牌(Token),如果直接存储在
localStorage
中,一旦发生 XSS,令牌就会被轻易盗走。但如果开发者利用 JavaScript 的闭包(Closure),将这个令牌保存在一个模块的私有变量中,并且只暴露一个使用该令牌的特定函数。那么,即使 XSS 脚本被注入,它也无法直接访问到这个被闭包“隐藏”起来的令牌,只能调用暴露出来的函数,极大地增加了攻击难度。这是代码架构层面的最后一道防线。
AJAX 的方法
简单 header 和非简单 header
默认情况下,只有七种 simple response headers (简单响应首部)可以暴露给外部:
- Cache-Control
- Content-Language
- Content-Length
- Content-Type
- Expires
- Last-Modified
- Pragma
non-simple-headers 可以用如下方式返回:
Access-Control-Expose-Headers: Content-Length, X-Kuma-Revision
在处理跨域 AJAX 请求时,浏览器不仅会遵循同源策略限制对响应体(Response Body)的读取,同样也会限制对响应头(Response Headers)的访问。这是一种纵深防御策略,旨在防止敏感信息通过响应头泄露。
第一道关卡:简单响应头(Simple Response Headers)
默认情况下,浏览器这名“安全管家”只会将一小部分它认为“安全”的、不包含敏感信息的响应头放行,让你的 JavaScript 代码可以访问到。这个白名单就是您提到的七种简单响应头:
- Cache-Control
- Content-Language
- Content-Length
- Content-Type
- Expires
- Last-Modified
- Pragma
场景举例: 假设你的网站my-app.com
通过fetch
请求了api.example.com
的一个接口。当响应返回后,你的 JavaScript 代码尝试执行 response.headers.get('Content-Type')
,这是可以成功获取到值的,因为Content-Type
在这个安全白名单里。
第二道关卡:暴露非简单响应头(Non-Simple Headers)
现在,假设api.example.com
的响应中包含了一些自定义的、非常有用的头部信息,比如:
X-Request-ID
: 用于追踪和调试的请求唯一 ID。X-RateLimit-Remaining
: 告知客户端当前速率限制下还剩多少次请求。
如果你的代码尝试直接response.headers.get('X-Request-ID')
,将会得到 null
。因为这个头部不在安全白名单内,浏览器默认将其拦截,不会暴露给脚本。
如何解决?—— 服务器的“通行证”
要解决这个问题,需要服务器在响应中明确地给浏览器签发一张“通行证”,告诉浏览器:“嘿,这些额外的响应头是安全的,请把它们暴露给发起请求的脚本吧!”
这张通行证就是Access-Control-Expose-Headers
响应头。
服务器响应示例:
1 |
|
当浏览器收到这个响应后,它会检查Access-Control-Expose-Headers
,看到X-Request-ID
和 X-RateLimit-Remaining
被列入了许可名单。此时,你的 JavaScript 代码再执行 response.headers.get('X-Request-ID')
就能成功拿到 abc-123-xyz-789 这个值了。
总结
这个机制是 CORS 标准中一个精妙的安全设计。它遵循了最小权限原则:默认情况下,浏览器只暴露最必要、最无害的信息;任何额外的权限(无论是读取响应体还是读取特定的响应头),都必须由提供资源的服务器通过明确的Access-Control-*
头部来授予。这确保了数据的控制权始终掌握在资源所有者手中。
JSONP
起因
一切的起点源于浏览器的同源策略(Same-Origin Policy)。该策略是浏览器的核心安全基石,它严格限制一个源(Origin)的文档或脚本如何与另一个源的资源进行交互。在 AJAX 请求中,这意味着 XMLHttpRequest 和后来的 fetch 默认无法请求不同源的服务器数据。
然而,开发者们发现了一个“例外”:HTML 的<script>
标签。它的 src 属性可以加载并执行来自任何源的 JavaScript 文件,而不受同源策略的限制。
JSONP 的核心思想,正是利用了这个特性。它将一个本应是“数据请求”的过程,伪装成了一个“脚本加载”的过程,从而巧妙地绕过了同源策略的限制。
实现
<script>
标签的存在,生动地说明了同源策略不限制普通的http get
请求获取嵌入式资源。本质上就是让代码的上文写好,生成一个script
标签请求,让服务器把下文写好。
客户端:定义函数(上文)并发起请求
客户端首先需要定义一个全局的“回调函数”,这个函数用来接收和处理从服务器获取的数据。然后,它会动态地创建一个<script>
标签,将其src
指向服务器的 API 地址,并通过 URL 参数(通常是 callback
)告诉服务器回调函数的名字。
1 |
|
服务器端:生成调用代码(下文)
服务器接收到请求后,会解析 URL 中的callback
参数,得到客户端定义好的函数名(在这个例子里是 handleResponse
)。然后,服务器将要返回的数据(通常是 JSON 格式)作为参数,包裹在这个函数调用语句中,最后将这段拼接好的 JavaScript 代码返回给客户端。
1 |
|
服务器最终返回给浏览器的内容是这样一段文本:
1 |
|
核心弱点与现代替代方案
尽管 JSONP 设计巧妙,但它的成功是建立在“信任”的基础之上,这也正是它最大的弱点。
- 严重的安全风险:第三方域危险-JSONP 的本质是加载并执行来自第三方域的脚本。如果你信任的服务器被攻击或本身就是恶意的,它返回的脚本中就可能包含任意恶意代码。这些代码将在你的页面上以你的权限运行,可以窃取用户的 Cookie、篡改页面内容,引发 XSS 攻击。
- 仅支持 GET 请求:由于
<script>
标签的 src 只能发起 GET 请求,JSONP 天生无法实现 POST、PUT、DELETE 等具有写入或修改性质的操作。 - 糟糕的错误处理:如果脚本加载失败(如 404 Not Found 或服务器 500 错误),浏览器不会提供像 fetch 的
.catch()
那样清晰可靠的错误捕获机制,难以进行优雅的错误处理。
现代替代方案:CORS
由于上述缺陷,JSONP 在现代 Web 开发中已基本被 CORS(跨域资源共享) 所取代。CORS 是一个 W3C 标准,它允许服务器通过设置 Access-Control-Allow-Origin 等 HTTP 响应头,来授权指定的源进行跨域请求。它更安全(返回的是纯数据,而非可执行代码)、更强大(支持所有 HTTP 方法)且拥有完善的错误处理机制,是当今处理跨域问题的首选方案。
特性 | CORS (跨域资源共享) | JSONP (JSON with Padding) | 优劣对比 |
---|---|---|---|
安全模型 | 安全:浏览器获取的是纯文本数据,绝不会执行。数据是否可用,由服务器通过 HTTP 头明确授权。 | 极度危险:本质是请求并执行一段来自第三方的 JavaScript 代码。你必须无条件信任该服务器不会发送恶意代码。 | CORS 完胜。这是最根本的区别。CORS 遵循了“数据与代码分离”的原则,而 JSONP 混淆了两者,为 XSS 攻击打开了大门。 |
HTTP 方法 | 支持所有方法:GET,POST,PUT,DELETE,HEAD 等。 | 仅支持 GET:因为它依赖 <script> 标签的 src 属性。 |
CORS 胜出。现代 Web 应用需要丰富的 HTTP 方法进行 RESTful 交互,JSONP 无法满足。 |
错误处理 | 健壮:可以被 try...catch 或 .catch() 捕获。浏览器会报告详细的 HTTP 状态码(如 404, 500)和网络错误。 |
脆弱:无法直接判断请求是否成功。通常只能用 setTimeout 超时机制来“猜测”请求失败,非常不可靠。 |
CORS 胜出。提供了开发者期望的、标准化的错误处理流程。 |
标准化 | W3C 官方标准:是现代浏览器内置的、推荐的跨域解决方案。 | 非官方的“模式”:一种被广泛使用的“黑客”技巧或约定,但并非标准。 | CORS 胜出。作为官方标准,它有更好的兼容性、可预见性和未来支持。 |
把控制权放在服务端(CORS 模型)
CORS 的工作模式,可以类比为去一个私人俱乐部:
- 你(客户端 your-app.com):想进入俱乐部(访问服务器 api.server.com 的资源)。
- 保安(浏览器):拦住你,因为你不属于这个俱乐部(跨域了)。保安不会自己决定放你进去,他需要查看来宾名单。
- 你对保安说:你去问问俱乐部老板(服务器),我 your-app.com 在不在他的来宾名单上。
- 保安(浏览器):向俱乐部老板(服务器)发送一个请求(这个请求被称为“预检请求” Preflight Request)。
- 俱乐部老板(服务器):查看自己的来宾名单(Access-Control-Allow-Origin 配置)。
- 情况A:名单上写着 your-app.com 可以进入。老板告诉保安:“放他进来。”
- 情况B:名单上没有你。老板告诉保安:“把他赶走。”
- 保安(浏览器):根据老板的指令,决定是让你通过(执行真正的请求),还是拒绝你(在控制台报错)。
在这个模型中:
- 所有权决定控制权:资源在服务器上,所以只有服务器有权决定谁能访问它。这符合现实世界的逻辑。
- 明确授权(Explicit is better than implicit):服务器必须明确地列出允许访问的源。这避免了意外或模糊的授权,安全性更高。
- 浏览器是中立的执行者:浏览器不偏袒任何一方,它只是忠实地执行服务器制定的安全策略。这使得安全策略可靠且可预测。
结论:将跨域访问的控制权交给服务器,是确保数据所有者能够自主掌控其资源安全的关键。CORS 正是基于这一核心原则设计的,因此它构建了一个远比 JSONP 更安全、更强大的现代 Web 安全体系。
CORS(Cross-Origin Resource Sharing)
先把请求分成简单请求(simple request)和非简单请求(not-so-simple request)。
简单请求
对于简单请求,就是在 request 里面表明当前的request 来自哪个 origin。换言之,A 要跨域到 B,A 至少要表明自己。
如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段(详见下文),就知道出错了,从而抛出一个错误,被XMLHttpRequest的onerror回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200。
如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。
1 |
|
其中各个字段的含义:
Access-Control-Allow-Origin:该字段是必须的。它的值要么是请求时Origin字段的值,要么是一个,表示接受任意域名的请求(习惯大方的程序员当然会选择后者了)。
Access-Control-Allow-Credentials:该字段可选。它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。设为true,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器。这个值也只能设为true,如果服务器不要浏览器发送Cookie,删除该字段即可。
Access-Control-Expose-Headers:该字段可选。CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。**如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。**上面的例子指定,getResponseHeader(‘FooBar’)可以返回FooBar字段的值。
非简单请求
非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUT或DELETE,或者Content-Type字段的类型是application/json。
非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为”预检”请求(preflight)。
浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。
简而言之,在 JQuery 时代,由浏览器而不是由 JQuery 自动发出的 OPTIONS 请求,就是 preflight 请求。
一个例子如下。
1 |
|
上面代码中,HTTP请求的方法是PUT,并且发送一个自定义头信息X-Custom-Header。
浏览器发现,这是一个非简单请求,就自动发出一个”预检”请求,要求服务器确认可以这样请求。下面是这个”预检”请求的HTTP头信息。
1 |
|
服务器收到了这个请求,返回一个这样的响应:
1 |
|
上面的HTTP回应中,关键的是Access-Control-Allow-Origin字段,表示http://api.bob.com可以请求数据。该字段也可以设为星号,表示同意任意跨源请求。
如果浏览器否定了”预检”请求,会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror回调函数捕获。控制台会打印出如下的报错信息。
到此我们可以看到,跨域相关的许可信息,都是放在 header 里而不是放在 body 里的。
一旦服务器通过了”预检”请求,以后每次浏览器正常的CORS请求,就都跟简单请求一样,会有一个Origin头信息字段。服务器的回应,也都会有一个Access-Control-Allow-Origin头信息字段。这两个字段,是浏览器和服务器自动添加上去,保证通话过程始终在对跨域的警惕和授权中度过。
附一个小问题:CSRF 攻防问题
img src
攻击可以攻击所有 get 请求。所以现代的电子邮箱默认都不显示邮件图片,否则不知道哪个图片会攻击某个还能使用 get url 访问的网站。- 隐藏表单不受同源策略影响,post 也需要做专门防御。任意的空白网页里都可能存在一个表单,在 onload 函数里就把表单提交了。
最完善的做法,应该是做一些有时效性的 token 放在网页里。像 Rails 的方案,就是一个隐藏表单里的 token,还要配合 referer 使用(这个字段能不能被 javascript 修改是个复杂问题)。
tomcat 关于会话的实现
对 tomcat 而言,会话存在于 cookie、url重写(“;jsessionid=xxxxxx”)、隐藏表单域、ssl 属性。
使用的一般字段是 JSESSIONID。
engine -> host -> context -> manager。
tomcat session 组件图如下所示,其中 Context 对应一个 webapp 应用,每个 webapp 有多个 HttpSessionListener, 并且每个应用的 session 是独立管理的,而 session 的创建、销毁由 Manager 组件完成,它内部维护了 N 个 Session 实例对象。在前面的文章中,我们分析了 Context 组件,它的默认实现是 StandardContext,它与 Manager 是一对一的关系,Manager 创建、销毁会话时,需要借助 StandardContext 获取 HttpSessionListener 列表并进行事件通知,而 StandardContext 的后台线程会对 Manager 进行过期 Session 的清理工作。
context 与 manager 是一对一的关系,而且 manger 负责通过以下代码创建 session:
1 |
|
tomcat8.5 提供了 4 种实现,默认使用 StandardManager,tomcat 还提供了集群会话的解决方案,但是在实际项目中很少运用。
- StandardManager:Manager 默认实现,在内存中管理 session,宕机将导致 session 丢失;但是当调用 Lifecycle 的 start/stop 接口时,将采用 jdk 序列化保存 Session 信息,因此当 tomcat 发现某个应用的文件有变更进行 reload 操作时,这种情况下不会丢失 Session 信息
- DeltaManager:增量 Session 管理器,用于Tomcat集群的会话管理器,某个节点变更 Session 信息都会同步到集群中的所有节点,这样可以保证 Session 信息的实时性,但是这样会带来较大的网络开销
- BackupManager:用于 Tomcat 集群的会话管理器,与DeltaManager不同的是,某个节点变更 Session 信息的改变只会同步给集群中的另一个 backup 节点
- PersistentManager:当会话长时间空闲时,将会把 Session 信息写入磁盘,从而限制内存中的活动会话数量;此外,它还支持容错,会定期将内存中的 Session 信息备份到磁盘。
本文的主要参考文献: