浏览器同源策略与 AJAX 跨域方法汇总
Category(分类): NET Protocol Status: 优
什么是同源策略?
如果进行过前端开发,肯定或多或少听说过、接触过同源策略。那么什么是同源策略呢?
要了解同源策略,首先需要理解“源”(Origin)。源不是完整的 URL,而是由 URL 中的以下三部分组成:
scheme(协议) + host(主机) + effective port(有效端口)
例如:
http://www.jianshu.com/p/bc7b8d542dcd
可以拆解为:
http :// www.jianshu.com /p/bc7b8d542dcd
scheme host pathname
对于一个更完整的 URL:
http://www.jianshu.com:80/p/bc7b8d542dcd#sample?query=text
各部分可以表示为:
protocol | host | port | pathname | hash | query string |
|---|---|---|---|---|---|
http | www.jianshu.com:80 | 80 | /p/bc7b8d542dcd | #sample?query=text | 空 |
location.protocol | location.host | location.port | location.pathname | location.hash | location.search |
需要注意:
location.host可能包含端口,例如www.example.com:8080;location.port在使用默认端口时通常返回空字符串,而不是80或443;- Hash 不会发送到服务器,也不属于 Origin;
- 路径和查询参数不参与同源判断;
- 默认端口会按协议进行规范化,例如
http://example.com通常等价于http://example.com:80。
因此,同源是指两个页面的 scheme、host 和有效端口相同。
以下 URL 都是相对于:
http://www.jianshu.com/p/bc7b8d542dcd
进行判断:
| URL | 是否同源 | 非同源原因 |
|---|---|---|
http://www.jianshu.com/p/0b2acb50f321 | 是 | 路径不同不影响 Origin |
https://www.jianshu.com/p/0b2acb50f321 | 否 | 协议不同 |
http://www.jianshu.com:8080/p/0b2acb50f321 | 否 | 端口不同 |
http://www.jianshu2.com/p/0b2acb50f321 | 否 | 主机不同 |
简单来说,同源策略就是浏览器出于安全考虑,限制不同源之间的脚本互相读取资源的一种安全机制。常见限制包括:
- Fetch、XMLHttpRequest 读取跨源响应的权限;
- 访问跨源页面的 DOM;
- 访问 LocalStorage、IndexedDB 等按源隔离的存储;
- 未经 CORS 授权时读取跨源图片的 Canvas 像素;
- 跨源窗口对象的大多数属性。
Cookie 的规则与 LocalStorage、IndexedDB 不完全相同。Cookie 主要根据域名和路径匹配,不按端口隔离,同时还受到 Secure、HttpOnly、SameSite 和有效期等属性影响。
有些跨源资源加载行为本身是允许的,例如:
<img src="https://example.com/image.png" alt="">
<link rel="stylesheet" href="https://example.com/style.css">
<script src="https://example.com/script.js"></script>
<iframe src="https://example.com/page.html"></iframe>
<video src="https://example.com/video.mp4"></video>
<audio src="https://example.com/audio.mp3"></audio>
但“允许加载”不代表“脚本可以读取全部内容”:跨源 iframe 的 DOM 仍然不能直接访问,跨源图片的像素通常不能读取,跨源脚本则可能被执行。因此,不能把 <script> 当作安全的数据读取接口。
为什么实际开发中会有跨域 AJAX 请求?
由于浏览器同源策略的影响,页面脚本不能直接读取未经授权的跨源 AJAX 响应。但实际开发中,跨源接口非常常见。
1. 调用已有 API 或公开 API
假设当前新闻详情页是:
http://www.yournews.com/p/123
相关推荐接口已经在公司其他产品线实现:
http://www.mynews.com/recommend?query=123
虽然两个地址可能属于同一家公司,但由于主机不同,页面脚本读取接口响应时仍然是跨源请求。
2. 前后端分离项目本地联调
本地前端页面可能运行在:
http://127.0.0.1:8085
后端接口运行在:
http://127.0.0.1:3000
虽然主机都是 127.0.0.1,但端口不同,所以属于不同源。开发时需要使用 CORS、开发服务器代理或后端反向代理完成联调。
跨域请求一定发不出去吗?
不一定:
- 简单跨源请求通常会发送到服务器,但浏览器可能阻止脚本读取响应;
- 需要预检的请求会先发送
OPTIONS,预检失败时真正请求可能不会发送; - 表单、图片等跨源请求可能发送,但响应内容通常不会暴露给原页面脚本;
- 服务器之间的请求不受浏览器同源策略限制。
因此,跨域主要是浏览器脚本的访问和读取权限问题,不是简单的网络不可达问题。
跨域方案示例
下面假设有两个项目:
- myweb:当前开发的前端站点,运行在
http://127.0.0.1:8085; - thirdparty:需要调用的后端服务,运行在
http://127.0.0.1:3000。
为了简化项目创建过程,可以使用 Express Generator:
npm install -g express-generator
express --view=pug myweb
express --view=pug thirdparty
也可以直接使用:
npx express-generator --view=pug myweb
npx express-generator --view=pug thirdparty
假设 thirdparty 提供以下接口:
http://127.0.0.1:3000/info/normal
接口代码:
const express = require('express')
const router = express.Router()
const data = {
name: 'alienzhou',
desc: 'a developer'
}
router.get('/normal', (req, res) => {
res.json(data)
})
module.exports = router
myweb 页面在点击按钮时请求该接口:
// http://127.0.0.1:8085/index.js
document.getElementById('btn-1').addEventListener('click', () => {
const xhr = new XMLHttpRequest()
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status >= 200 && xhr.status < 300) {
alert(xhr.responseText)
} else {
console.error('请求失败:', xhr.status)
}
}
}
xhr.open('GET', 'http://127.0.0.1:3000/info/normal', true)
xhr.send()
})
如果服务端没有返回允许当前源的 CORS 响应头,浏览器控制台可能出现类似错误:
Origin http://127.0.0.1:8085 is not allowed by Access-Control-Allow-Origin.
XMLHttpRequest cannot load http://127.0.0.1:3000/info/normal due to access control checks.
下面介绍几种常见解决方案。
一、使用代理(Proxy)
这种方法本质上是把请求转移到服务端处理。浏览器只请求与页面同源的 myweb 后端,myweb 后端再请求 thirdparty 服务。
同源策略是浏览器需要遵循的规则,服务器到服务器的请求不受浏览器同源策略限制。代理服务器通常需要完成以下步骤:
- 接收客户端请求;
- 将请求转发给目标服务器;
- 接收目标服务器响应;
- 将状态码、响应头和响应体转发给客户端。
1. 只代理 GET 请求的示例
下面的示例通过 Node.js 原生 HTTP 模块转发 /proxy/ 下的 GET 请求。访问:
http://127.0.0.1:8085/proxy/info/normal
代理会请求:
http://127.0.0.1:3000/info/normal
const http = require('http')
const express = require('express')
const router = express.Router()
router.get('/proxy/*', (req, res) => {
// originalUrl 包含查询字符串,去掉 /proxy 前缀后转发
const targetPath = req.originalUrl.replace(/^\/proxy/, '') || '/'
const proxyRequest = http.request(
{
hostname: '127.0.0.1',
port: 3000,
path: targetPath,
method: 'GET',
headers: {
accept: req.headers.accept || '*/*'
},
timeout: 5000
},
proxyResponse => {
res.status(proxyResponse.statusCode || 502)
for (const [key, value] of Object.entries(proxyResponse.headers)) {
if (value !== undefined) {
res.setHeader(key, value)
}
}
proxyResponse.pipe(res)
}
)
proxyRequest.on('timeout', () => {
proxyRequest.destroy(new Error('代理请求超时'))
})
proxyRequest.on('error', error => {
console.error(error)
if (!res.headersSent) {
res.status(502).send('Bad Gateway')
}
})
proxyRequest.end()
})
module.exports = router
前端改为请求同源地址:
document.getElementById('btn-1').addEventListener('click', () => {
const xhr = new XMLHttpRequest()
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status >= 200 && xhr.status < 300) {
alert(xhr.responseText)
} else {
console.error('请求失败:', xhr.status)
}
}
}
xhr.open('GET', '/proxy/info/normal', true)
xhr.send()
})
该方法的优点是目标服务 http://127.0.0.1:3000/info/normal 不需要增加 CORS 配置。
缺点包括:
- 需要有自己的后端服务能够接收并转发请求;
- 纯静态页面需要额外启动开发服务器或使用托管平台的代理能力;
- 如果请求包含 Cookie、Authorization、特殊请求头或请求体,需要在代理中明确处理;
- 生产环境需要限制目标地址,防止 SSRF;
- 还需要处理超时、错误、状态码、缓存、二进制数据和 Hop-by-Hop Headers。
上面的代码只演示 GET。若要代理 POST、PUT 等方法,还需要转发请求方法、请求体和允许的请求头,不能简单地调用 request.get()。
二、CORS
CORS(Cross-Origin Resource Sharing,跨源资源共享)是浏览器跨源读取 HTTP 响应的标准机制,需要浏览器和服务器共同支持。
浏览器通常会在跨源请求中携带 Origin 请求头,例如:
Origin: http://127.0.0.1:8085
服务器通过响应头声明允许的源:
Access-Control-Allow-Origin: http://127.0.0.1:8085
浏览器检查响应中的 CORS 头,如果当前源被允许,页面脚本才可以读取响应。
Access-Control-Allow-Origin: * 表示允许任意源进行非凭据跨源读取,但不能与:
Access-Control-Allow-Credentials: true
同时使用。
1. 简单请求
通常需要同时满足以下条件:
条件 1:请求方法是以下之一:
GETHEADPOST
条件 2:Content-Type 是以下之一:
text/plainmultipart/form-dataapplication/x-www-form-urlencoded
此外,手动设置的请求头必须满足 CORS safelisted request-header 的限制,XMLHttpRequest 上传对象也不能注册会触发预检的事件监听器。
简单请求通常可以直接发送,但服务端仍必须返回正确的 Access-Control-Allow-Origin,浏览器才会允许脚本读取响应。
2. 需要预检的请求
不符合简单请求条件的请求通常需要预检。浏览器会在正式请求前发送 OPTIONS 请求,并通过以下请求头描述真正请求:
Origin: http://127.0.0.1:8085
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: name
服务器需要返回类似:
Access-Control-Allow-Origin: http://127.0.0.1:8085
Access-Control-Allow-Methods: GET, PUT, OPTIONS
Access-Control-Allow-Headers: Content-Type, name
Access-Control-Max-Age: 600
Express 示例:
const express = require('express')
const app = express()
const allowList = new Set(['http://127.0.0.1:8085'])
app.use((req, res, next) => {
const origin = req.headers.origin
if (origin && allowList.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, name')
res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS')
res.setHeader('Access-Control-Allow-Credentials', 'true')
res.setHeader('Access-Control-Expose-Headers', 'name')
res.setHeader('Access-Control-Max-Age', '600')
}
if (req.method === 'OPTIONS') {
res.sendStatus(204)
return
}
next()
})
app.put('/info/cors', (req, res) => {
res.setHeader('name', 'jw')
res.json({
name: 'alienzhou',
desc: 'a developer'
})
})
app.listen(3000)
3. 带凭据的 CORS 请求
默认情况下,跨源 XHR/Fetch 不会携带 Cookie。客户端需要显式设置 withCredentials:
document.getElementById('btn-1').addEventListener('click', () => {
const xhr = new XMLHttpRequest()
xhr.withCredentials = true
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) {
return
}
if (xhr.status >= 200 && xhr.status < 300) {
alert(xhr.responseText)
// name 不是简单响应头,需要服务端设置 Expose-Headers
console.log(xhr.getResponseHeader('name'))
}
}
xhr.open('PUT', 'http://127.0.0.1:3000/info/cors', true)
xhr.setRequestHeader('name', 'hw')
xhr.send()
})
服务端需要返回:
Access-Control-Allow-Origin: http://127.0.0.1:8085
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: name
不能返回:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Cookie 是否能发送还取决于域名、路径、SameSite、Secure 和有效期等规则。127.0.0.1:8085 与 127.0.0.1:3000 虽然不同源,但 Cookie 的域匹配不包含端口。
CORS 不是 CSRF 防护机制。服务端仍应使用 CSRF Token、SameSite Cookie、Origin/Referer 检查和权限校验。
CORS 的优点:
- 浏览器原生支持;
- 配置清晰,能够支持多种 HTTP 方法;
- 可以通过预检机制控制请求方法和请求头;
- 支持明确的凭据和响应头暴露策略。
CORS 的缺点:
- 需要目标服务配合修改响应头;
- 复杂请求可能增加一次预检请求;
- 旧浏览器兼容性较差;
- 错误配置可能导致敏感数据被不可信源读取。
三、JSONP
JSONP 是跨域领域中历史较早的一种方案。由于浏览器允许 <script> 标签加载跨源脚本,服务端可以把客户端需要的数据包装成 JavaScript 返回:
callback({ name: 'alienzhou' })
具体流程如下:
- 在 myweb 页面预先定义一个回调函数,例如
myCallback; - 动态创建
<script>标签,将跨域接口地址设置给src; - 把回调函数名作为查询参数传给 thirdparty;
- thirdparty 返回以该回调函数包裹的数据;
- 浏览器执行返回脚本,调用页面中预先定义的函数。
1. 原生 JavaScript 示例
function jsonp({ url, params = {}, callback = 'jsonpCallback', timeout = 5000 }) {
return new Promise((resolve, reject) => {
if (!/^[A-Za-z_$][\w$]*$/.test(callback)) {
reject(new Error('非法的 JSONP 回调名'))
return
}
const script = document.createElement('script')
const query = new URLSearchParams({ ...params, callback })
let timer
function cleanup() {
clearTimeout(timer)
delete window[callback]
script.remove()
}
window[callback] = data => {
cleanup()
resolve(data)
}
script.onerror = () => {
cleanup()
reject(new Error('JSONP 请求失败'))
}
timer = setTimeout(() => {
cleanup()
reject(new Error('JSONP 请求超时'))
}, timeout)
script.src = `${url}${url.includes('?') ? '&' : '?'}${query}`
document.head.appendChild(script)
})
}
jsonp({
url: 'http://127.0.0.1:3000/info/jsonp',
params: {},
callback: 'myCallback'
}).then(data => {
alert(JSON.stringify(data, null, 2))
})
2. thirdparty 服务端示例
const express = require('express')
const router = express.Router()
const data = {
name: 'alienzhou',
desc: 'a developer'
}
router.get('/jsonp', (req, res) => {
const callback = req.query.callback
if (!/^[A-Za-z_$][\w$]*$/.test(callback || '')) {
res.status(400).send('Invalid callback')
return
}
res
.type('js')
.send(`${callback}(${JSON.stringify(data)})`)
})
module.exports = router
服务端不能把任意用户输入直接拼接到脚本中,否则可能造成 XSS。JSONP 只能使用 GET,不能用于 POST、PUT、DELETE 等方法;它也不能访问任意不支持 JSONP 的网站。
3. jQuery JSONP 示例
$.ajax({
url: 'http://127.0.0.1:3000/info/jsonp',
dataType: 'jsonp',
jsonp: 'callback',
jsonpCallback: 'myCallback'
}).done(res => {
alert(JSON.stringify(res, null, 2))
})
JSONP 的优点是兼容性较好,适合维护旧系统。缺点是只能 GET、没有标准的响应错误读取机制,并且响应会作为 JavaScript 执行,安全性不如 CORS。
四、三种方案的选择
- 代理:目标服务不能修改时,或者希望前端始终请求同源地址时使用;
- CORS:能够修改目标服务时,优先使用标准方案;
- JSONP:仅用于旧浏览器或旧接口兼容,目标服务必须明确支持,并且不能传递敏感数据。
上面示例的完整代码可以参考:cross-domain-demo。
git clone https://github.com/alienzhou/cross-domain-demo.git
总结
同源策略是浏览器的重要安全机制,它在保护用户数据的同时,也限制了页面脚本直接读取跨源响应的能力。跨域请求并不一定不会发送,关键是浏览器是否允许页面脚本读取响应,以及请求是否需要预检。
常见解决方案包括:
- 代理:通过同源后端转发请求,浏览器不直接读取跨源接口;
- CORS:通过服务端响应头授权浏览器读取跨源 HTTP 响应;
- JSONP:利用
<script>加载跨源 JavaScript,仅支持 GET,属于历史兼容方案。
在实际项目中,应优先考虑 CORS、同源反向代理和开发服务器代理,并同时做好认证、授权、CSRF 防护和输入校验。