大家好,我是你们今天的浏览器安全小讲师,咱们今天聊聊浏览器里一对相爱相杀的好基友:同源策略 (Same-Origin Policy) 和跨域资源共享 (CORS)。以及那个让开发者们头疼又不得不面对的 CORS 预检请求 (Preflight Request)。
第一章:什么是同源?为什么要有同源策略?
想象一下,你在网上冲浪,同时打开了两个标签页:一个是你的银行网站 https://mybank.com
,另一个是一个看起来很可爱的猫猫网站 https://cutecats.com
。 如果没有同源策略,cutecats.com
里的 JavaScript 代码就能轻轻松松地读取 mybank.com
里的 Cookie,获取你的银行账户信息,然后…你就破产了!
所以,同源策略就是浏览器为了保护用户数据而设立的一道安全屏障。它规定,只有协议、域名和端口都相同的页面,才被认为是同源的。
- 协议 (Protocol):
http
或https
- 域名 (Domain):
mybank.com
- 端口 (Port):
80
(http默认) 或443
(https默认),或者其他显式指定的端口
如果这三者中有任何一个不同,浏览器就会认为这两个页面是不同源的,并阻止其中一个页面上的 JavaScript 代码去访问另一个页面的资源。
举几个例子:
URL 1 | URL 2 | 同源吗? | 解释 |
---|---|---|---|
https://mybank.com/page1 |
https://mybank.com/page2 |
是 | 协议、域名和端口都相同。 |
http://mybank.com/page1 |
https://mybank.com/page1 |
否 | 协议不同 (http vs https)。 |
https://mybank.com/page1 |
https://api.mybank.com/ |
否 | 域名不同 (mybank.com vs api.mybank.com)。注意, api.mybank.com 被认为是 mybank.com 的一个子域名,但在同源策略中,它们仍然被视为不同的源。 |
https://mybank.com:8080 |
https://mybank.com |
否 | 端口不同 (8080 vs 443, 因为https默认是443端口)。 |
https://mybank.com |
https://evil.com |
否 | 域名不同。 |
同源策略限制了哪些行为?
主要限制了以下几种行为:
- Cookie、LocalStorage 和 IndexDB: 不同源的页面无法互相访问这些存储在浏览器中的数据。
- DOM 访问: 一个页面上的 JavaScript 代码无法访问另一个不同源页面的 DOM 结构。
- 网络请求 (XMLHttpRequest/Fetch): 如果发起跨域请求,浏览器会阻止 JavaScript 代码获取响应数据,即使服务器返回了数据。
第二章:CORS – 同源策略的“白名单”机制
同源策略虽然保护了用户安全,但也给 Web 开发带来了很多麻烦。 想象一下,你的前端应用部署在 https://myapp.com
,需要从 https://api.myapp.com
获取数据,结果因为同源策略的限制,请求被浏览器拦截了,是不是很崩溃?
CORS (Cross-Origin Resource Sharing) 就是为了解决这个问题而生的。 它是一种机制,允许服务器声明哪些来源 (origin) 可以访问自己的资源。 你可以把 CORS 看作是同源策略的一个“白名单”机制。
CORS 的工作原理:
-
浏览器发起跨域请求: 当一个页面上的 JavaScript 代码尝试向不同源的服务器发起请求时,浏览器会在请求头中添加一个
Origin
字段,表明请求的来源 (协议 + 域名 + 端口)。例如:Origin: https://myapp.com
-
服务器检查
Origin
字段: 服务器收到请求后,会检查Origin
字段的值,判断是否允许该来源访问自己的资源。 -
服务器设置响应头: 如果服务器允许该来源访问,它会在响应头中添加
Access-Control-Allow-Origin
字段,指定允许访问的来源。例如:Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Origin: *
表示允许所有来源访问,但这通常不推荐,因为它会降低安全性。Access-Control-Allow-Origin: https://myapp.com
表示只允许https://myapp.com
访问。Access-Control-Allow-Origin: null
表示允许来自本地文件 (file://) 的请求。
-
浏览器检查响应头: 浏览器收到服务器的响应后,会检查响应头中
Access-Control-Allow-Origin
字段的值。 如果Access-Control-Allow-Origin
的值与请求的Origin
匹配,或者Access-Control-Allow-Origin
的值为*
,浏览器才会允许 JavaScript 代码访问响应数据。 否则,浏览器会阻止 JavaScript 代码获取响应数据,并抛出一个 CORS 错误。
简单的 CORS 例子 (Node.js + Express):
const express = require('express');
const cors = require('cors'); // 引入 cors 中间件
const app = express();
const port = 3000;
// 使用 cors 中间件,允许所有来源访问
// app.use(cors());
// 使用 cors 中间件,只允许特定的来源访问
const corsOptions = {
origin: 'https://myapp.com', // 允许的来源
optionsSuccessStatus: 200 // some legacy browsers (IE11, various SmartTVs) choke on 204
}
app.use(cors(corsOptions));
app.get('/data', (req, res) => {
res.json({ message: 'Hello from the API!' });
});
app.listen(port, () => {
console.log(`API listening at http://localhost:${port}`);
});
在这个例子中,cors
中间件会帮我们自动设置 Access-Control-Allow-Origin
响应头。 如果取消注释 app.use(cors());
, 则允许所有来源访问。 如果使用 cors(corsOptions)
,则只允许 https://myapp.com
访问。
第三章:CORS 预检请求 (Preflight Request) – 安全性再升级
对于一些“简单请求 (Simple Request)”,浏览器会直接发起跨域请求,并在请求头中携带 Origin
字段,如上面所述。 但对于一些“复杂请求 (Complex Request)”,浏览器会先发起一个“预检请求 (Preflight Request)”,用于确认服务器是否允许真正的跨域请求。
什么是简单请求 (Simple Request)?
简单请求需要同时满足以下所有条件:
- 请求方法 (Method):
GET
,HEAD
,POST
之一 - 请求头 (Headers): 只包含以下字段 (大小写不敏感):
Accept
Accept-Language
Content-Language
Content-Type
(但只允许以下三个值:application/x-www-form-urlencoded
,multipart/form-data
,text/plain
)DPR
Downlink
ECT
RRT
Save-Data
Viewport-Width
Width
什么又是复杂请求 (Complex Request)?
只要不满足简单请求的所有条件,那就是复杂请求。 比如,使用了 PUT
, DELETE
等请求方法,或者设置了自定义的请求头,都属于复杂请求。
预检请求 (Preflight Request) 的作用:
预检请求是一个 OPTIONS
请求,它会先发送到服务器,询问服务器是否允许真正的跨域请求。 预检请求的请求头中包含以下字段:
Origin
: 请求的来源Access-Control-Request-Method
: 真正的请求将使用的 HTTP 方法 (如PUT
,DELETE
)Access-Control-Request-Headers
: 真正的请求将包含的自定义请求头
例如:
OPTIONS /resource HTTP/1.1
Origin: https://myapp.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header, Content-Type
服务器收到预检请求后,需要返回一个响应,其中包含以下响应头:
Access-Control-Allow-Origin
: 允许的来源 (必须包含请求的Origin
值,或者*
)Access-Control-Allow-Methods
: 允许的 HTTP 方法 (必须包含Access-Control-Request-Method
中指定的方法)Access-Control-Allow-Headers
: 允许的自定义请求头 (必须包含Access-Control-Request-Headers
中指定的头部)Access-Control-Max-Age
: 预检请求的缓存时间 (秒)。 在这个时间内,浏览器不会再次发送预检请求。
例如:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: PUT, DELETE
Access-Control-Allow-Headers: X-Custom-Header, Content-Type
Access-Control-Max-Age: 86400
如果服务器返回的响应头表明允许真正的跨域请求,浏览器才会发送真正的请求。 否则,浏览器会阻止请求,并抛出一个 CORS 错误。
预检请求的示例 (Node.js + Express):
const express = require('express');
const cors = require('cors');
const app = express();
const port = 3000;
const corsOptions = {
origin: 'https://myapp.com',
optionsSuccessStatus: 204, // 必须设置为 204 (No Content) 或 200 (OK),否则一些浏览器可能出现问题
allowedHeaders: ['X-Custom-Header', 'Content-Type'],
methods: ['PUT', 'DELETE']
}
app.use(cors(corsOptions)); // 应用 CORS 中间件
app.use(express.json()); // 解析 JSON 格式的请求体
app.put('/data', (req, res) => {
console.log("PUT request received");
console.log("Request body:", req.body);
res.json({ message: 'Data updated successfully!' });
});
app.delete('/data', (req, res) => {
res.json({ message: 'Data deleted successfully!' });
});
app.listen(port, () => {
console.log(`API listening at http://localhost:${port}`);
});
在这个例子中,我们使用了 cors
中间件,并配置了 allowedHeaders
和 methods
选项,以允许 PUT
和 DELETE
请求,以及 X-Custom-Header
和 Content-Type
请求头。 optionsSuccessStatus
必须设置为 204 或 200,否则一些浏览器可能会出现问题。
前端发起复杂请求的例子 (JavaScript):
fetch('https://api.myapp.com/data', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Custom-Header': 'foobar'
},
body: JSON.stringify({ key: 'value' })
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));
在这个例子中,我们使用了 PUT
方法,并设置了 Content-Type
和 X-Custom-Header
请求头,因此这是一个复杂请求。 浏览器会自动发送预检请求,如果服务器允许,才会发送真正的 PUT
请求。
CORS 常见问题和解决方案:
-
CORS 错误:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
- 原因: 服务器没有设置
Access-Control-Allow-Origin
响应头,或者设置的值与请求的Origin
不匹配。 - 解决方案: 确保服务器正确设置了
Access-Control-Allow-Origin
响应头,并且值与请求的Origin
匹配,或者设置为*
。 注意,在生产环境中,不建议将Access-Control-Allow-Origin
设置为*
,因为它会降低安全性。
- 原因: 服务器没有设置
-
CORS 错误:
Request header field X-Custom-Header is not allowed by Access-Control-Allow-Headers in preflight response.
- 原因: 服务器没有在
Access-Control-Allow-Headers
响应头中包含请求头X-Custom-Header
。 - 解决方案: 确保服务器在
Access-Control-Allow-Headers
响应头中包含了所有自定义请求头。
- 原因: 服务器没有在
-
CORS 错误:
Method PUT is not allowed by Access-Control-Allow-Methods in preflight response.
- 原因: 服务器没有在
Access-Control-Allow-Methods
响应头中包含请求方法PUT
。 - 解决方案: 确保服务器在
Access-Control-Allow-Methods
响应头中包含了所有允许的 HTTP 方法。
- 原因: 服务器没有在
-
预检请求失败 (OPTIONS 请求返回 404 或 500 等错误):
- 原因: 服务器没有正确处理
OPTIONS
请求。 - 解决方案: 确保服务器能够正确处理
OPTIONS
请求,并返回包含Access-Control-Allow-Origin
、Access-Control-Allow-Methods
和Access-Control-Allow-Headers
响应头的响应。 可以使用 CORS 中间件来简化处理。
- 原因: 服务器没有正确处理
第四章:CORS 的替代方案
除了 CORS,还有一些其他的跨域解决方案,但它们各有优缺点:
-
JSONP (JSON with Padding):
- 原理: 利用
<script>
标签的src
属性可以跨域请求资源的特性。 服务器返回一段 JavaScript 代码,这段代码会调用一个预先定义好的回调函数,并将数据作为参数传递给该函数。 - 优点: 兼容性好,可以支持老版本的浏览器。
- 缺点: 只支持
GET
请求,安全性较低,容易受到 XSS 攻击。
<!DOCTYPE html> <html> <head> <title>JSONP Example</title> </head> <body> <script> function handleResponse(data) { console.log("Received data:", data); // 处理接收到的数据 } </script> <script src="https://api.example.com/data?callback=handleResponse"></script> </body> </html>
服务器端(例如,Node.js):
const express = require('express'); const app = express(); const port = 3000; app.get('/data', (req, res) => { const data = { message: 'Hello from the API!' }; const callback = req.query.callback; if (callback) { // 返回 JSONP 格式的响应 res.send(`${callback}(${JSON.stringify(data)})`); } else { // 如果没有 callback 参数,返回普通的 JSON 响应 res.json(data); } }); app.listen(port, () => { console.log(`JSONP API listening at http://localhost:${port}`); });
- 原理: 利用
-
代理服务器 (Proxy Server):
- 原理: 前端应用向同源的代理服务器发起请求,代理服务器再向目标服务器发起跨域请求,并将响应数据返回给前端应用。 相当于把跨域请求的责任转移到了服务器端。
- 优点: 可以绕过浏览器的同源策略,支持各种 HTTP 方法。
- 缺点: 需要搭建和维护代理服务器,增加了服务器的负担。
例如,Nginx配置:
server { listen 80; server_name myapp.com; location /api/ { proxy_pass https://api.example.com/; # 目标API服务器 proxy_set_header Host api.example.com; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; add_header Access-Control-Allow-Origin *; # 允许所有来源,生产环境慎用 add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept"; } location / { root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html; # 如果使用SPA } }
-
PostMessage:
- 原理: 允许不同源的窗口之间进行通信。 通过
window.postMessage()
方法,一个窗口可以向另一个窗口发送消息,而不管它们的源是否相同。 - 优点: 可以进行双向通信,可以传输复杂的数据结构。
- 缺点: 需要手动处理消息的发送和接收,安全性需要特别注意。
例如,一个页面 (https://myapp.com/page1.html) 发送消息到另一个页面 (https://api.myapp.com/page2.html):
// https://myapp.com/page1.html const targetWindow = window.open('https://api.myapp.com/page2.html', '_blank'); targetWindow.onload = () => { targetWindow.postMessage({ message: 'Hello from page1!' }, 'https://api.myapp.com'); }; window.addEventListener('message', (event) => { if (event.origin === 'https://api.myapp.com') { console.log('Received message from page2:', event.data); } });
接收消息的页面 (https://api.myapp.com/page2.html):
// https://api.myapp.com/page2.html window.addEventListener('message', (event) => { if (event.origin === 'https://myapp.com') { console.log('Received message from page1:', event.data); event.source.postMessage({ message: 'Hello from page2!' }, event.origin); } });
- 原理: 允许不同源的窗口之间进行通信。 通过
总结:
同源策略和 CORS 是 Web 安全的重要组成部分。 理解它们的工作原理,以及如何正确配置 CORS,对于开发安全的 Web 应用至关重要。 希望今天的讲解能够帮助大家更好地理解这两个概念,并在实际开发中灵活运用。 记住,安全无小事,多一份了解,少一份风险!
好了,今天的课程就到这里, 祝大家编码愉快,永不遇到 CORS 错误!(除非是故意的,手动滑稽)