同源策略
Last updated
Aug 19, 2022
# 同源的定义
同源策略是一个重要的安全策略,它用于限制一个
origin的文档或者它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。
Origin
Web内容的源由用于访问它的
URL 的方案(协议),主机(域名)和端口定义。只有当方案,主机和端口都匹配时,两个对象具有相同的起源。
同源的例子
http://example.com/app1/index.html http://example.com/app2/index.html | same origin because same scheme (http ) and host (example.com ) |
---|
http://Example.com:80 http://example.com | same origin because a server delivers HTTP content through port 80 by default |
不同源的例子
http://example.com/app1 https://example.com/app2 | different schemes |
---|
http://example.com http://www.example.com http://myapp.example.com | different hosts |
http://example.com http://example.com:8080 | different ports |
如果两个 URL 的
protocol、
port (如果有指定的话)和
host 都相同的话,则这两个 URL 是同源。这个方案也被称为“协议/主机/端口元组”,或者直接是 “元组”。(“元组” 是指一组项目构成的整体,双重/三重/四重/五重/等的通用形式)。
在页面中通过 about:blank
或 javascript:
URL 执行的脚本会继承打开该 URL 的文档的源,因为这些类型的 URLs 没有包含源服务器的相关信息。
# 源的更改
满足某些限制条件的情况下,页面是可以修改它的源。脚本可以将
document.domain
的值设置为其当前域或其当前域的父域。如果将其设置为其当前域的父域,则这个较短的父域将用于后续源检查。
例如:假设
http://store.company.com/dir/other.html 文档中的一个脚本执行以下语句:
1
| document.domain = "company.com";
|
这条语句执行之后,页面将会成功地通过与 http://company.com/dir/page.html
的同源检测(假设http://company.com/dir/page.html
将其 document.domain
设置为“company.com
”,以表明它希望允许这样做 - 更多有关信息,请参阅
document.domain
)。然而,company.com
不能设置 document.domain
为 othercompany.com
,因为它不是 company.com
的父域。
端口号是由浏览器另行检查的。任何对document.domain
的赋值操作,包括 document.domain = document.domain
都会导致端口号被重写为 null
。因此 company.com:8080
不能仅通过设置 document.domain = "company.com"
来与company.com
通信。必须在他们双方中都进行赋值,以确保端口号都为 null
。
注意:使用 document.domain
来允许子域安全访问其父域时,您需要在父域和子域中设置 document.domain 为相同的值。这是必要的,即使这样做只是将父域设置回其原始值。不这样做可能会导致权限错误。
# 导致跨域的操作
同源策略控制不同源之间的交互,例如在使用
XMLHttpRequest
或<img>
标签时则会受到同源策略的约束。这些交互通常分为三类:
- 跨域写操作(Cross-origin writes)一般是被允许的。例如链接(links),重定向以及表单提交。特定少数的HTTP请求需要添加
preflight。
- 跨域资源嵌入(Cross-origin embedding)一般是被允许(后面会举例说明)。
- 跨域读操作(Cross-origin reads)一般是不被允许的,但常可以通过内嵌资源来巧妙的进行读取访问。例如,你可以读取嵌入图片的高度和宽度,调用内嵌脚本的方法,或
availability of an embedded resource.
以下是可能嵌入跨源的资源的一些示例:
<script src="…"></script>
标签嵌入跨域脚本。语法错误信息只能被同源脚本中捕捉到。<link rel="stylesheet" href="…">
标签嵌入CSS。由于CSS的
松散的语法规则,CSS的跨域需要一个设置正确的 HTTP 头部 Content-Type
。不同浏览器有不同的限制:
IE,
Firefox,
Chrome,
Safari (跳至CVE-2010-0051)部分 和
Opera。- 通过
<img>
展示的图片。支持的图片格式包括PNG,JPEG,GIF,BMP,SVG,… - 通过
<video>
和
<audio>
播放的多媒体资源。 - 通过
<object>
、
<embed>
和 <applet>
嵌入的插件。 - 通过
@font-face
引入的字体。一些浏览器允许跨域字体( cross-origin fonts),一些需要同源字体(same-origin fonts)。 - 通过
<iframe>
载入的任何资源。站点可以使用
X-Frame-Options 消息头来阻止这种形式的跨域交互。
# 跨域API
JavaScript 的 API 中,如 iframe.contentWindow
、
window.parent
、
window.open
和
window.opener
允许文档间直接相互引用。当两个文档的源不同时,这些引用方式将对
Window 和
Location对象的访问添加限制,如下两节所述。
为了能让不同源中文档进行交流,可以使用
window.postMessage
。
# 跨域数据存储
访问存储在浏览器中的数据,如
localStorage 和
IndexedDB,是以源进行分割。每个源都拥有自己单独的存储空间,一个源中的 JavaScript 脚本不能对属于其它源的数据进行读写操作。
Cookies 使用不同的源定义方式。一个页面可以为本域和其父域设置 cookie,只要是父域不是公共后缀(public suffix)即可。Firefox 和 Chrome 使用
Public Suffix List 检测一个域是否是公共后缀(public suffix)。Internet Explorer 使用其内部的方法来检测域是否是公共后缀。不管使用哪个协议(HTTP/HTTPS)或端口号,浏览器都允许给定的域以及其任何子域名(sub-domains) 访问 cookie。当你设置 cookie 时,你可以使用 Domain
、Path
、Secure
、和 HttpOnly
标记来限定其可访问性。当你读取 cookie 时,你无法知道它是在哪里被设置的。 即使您只使用安全的 https 连接,您看到的任何 cookie 都有可能是使用不安全的连接进行设置的。
# 解决跨域
# JSONP
在HTML标签里,一些标签比如script、img这样的获取资源的标签是没有跨域限制的,利用这一点,我们可以这样干:
后端写个小接口
1
2
3
4
5
6
7
8
9
10
11
12
13
| // 处理成功失败返回格式的工具
const {successBody} = require('../utli')
class CrossDomain {
static async jsonp (ctx) {
// 前端传过来的参数
const query = ctx.request.query
// 设置一个cookies
ctx.cookies.set('tokenId', '1')
// query.cb是前后端约定的方法名字,其实就是后端返回一个直接执行的方法给前端,由于前端是用script标签发起的请求,所以返回了这个方法后相当于立马执行,并且把要返回的数据放在方法的参数里。
ctx.body = `${query.cb}(${JSON.stringify(successBody({msg: query.msg}, 'success'))})`
}
}
module.exports = CrossDomain
|
简单版前端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| <!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script type='text/javascript'>
// 后端返回直接执行的方法,相当于执行这个方法,由于后端把返回的数据放在方法的参数里,所以这里能拿到res。
window.jsonpCb = function (res) {
console.log(res)
}
</script>
<script src='http://localhost:9871/api/jsonp?msg=helloJsonp&cb=jsonpCb' type='text/javascript'></script>
</body>
</html>
|
简单封装一下前端这个套路
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| /**
* JSONP请求工具
* @param url 请求的地址
* @param data 请求的参数
* @returns {Promise<any>}
*/
const request = ({url, data}) => {
return new Promise((resolve, reject) => {
// 处理传参成xx=yy&aa=bb的形式
const handleData = (data) => {
const keys = Object.keys(data)
const keysLen = keys.length
return keys.reduce((pre, cur, index) => {
const value = data[cur]
const flag = index !== keysLen - 1 ? '&' : ''
return `${pre}${cur}=${value}${flag}`
}, '')
}
// 动态创建script标签
const script = document.createElement('script')
// 接口返回的数据获取
window.jsonpCb = (res) => {
document.body.removeChild(script)
delete window.jsonpCb
resolve(res)
}
script.src = `${url}?${handleData(data)}&cb=jsonpCb`
document.body.appendChild(script)
})
}
// 使用方式
request({
url: 'http://localhost:9871/api/jsonp',
data: {
// 传参
msg: 'helloJsonp'
}
}).then(res => {
console.log(res)
})
2.空iframe加for
|
JSONP只能发GET请求,因为本质上script加载资源就是GET,那么如果要发POST请求怎么办呢?
后端写个小接口
1
2
3
4
5
6
7
8
9
10
| // 处理成功失败返回格式的工具
const {successBody} = require('../utli')
class CrossDomain {
static async iframePost (ctx) {
let postData = ctx.request.body
console.log(postData)
ctx.body = successBody({postData: postData}, 'success')
}
}
module.exports = CrossDomain
|
前端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| const requestPost = ({url, data}) => {
// 首先创建一个用来发送数据的iframe.
const iframe = document.createElement('iframe')
iframe.name = 'iframePost'
iframe.style.display = 'none'
document.body.appendChild(iframe)
const form = document.createElement('form')
const node = document.createElement('input')
// 注册iframe的load事件处理程序,如果你需要在响应返回时执行一些操作的话.
iframe.addEventListener('load', function () {
console.log('post success')
})
form.action = url
// 在指定的iframe中执行form
form.target = iframe.name
form.method = 'post'
for (let name in data) {
node.name = name
node.value = data[name].toString()
form.appendChild(node.cloneNode())
}
// 表单元素需要添加到主文档中.
form.style.display = 'none'
document.body.appendChild(form)
form.submit()
|
# CORS (推荐)
CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)
跨域资源共享 CORS 详解。看名字就知道这是处理跨域问题的标准做法。CORS有两种请求,简单请求和非简单请求。
这里引用上面链接阮一峰老师的文章说明一下简单请求和非简单请求。
浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。
只要同时满足以下两大条件,就属于简单请求。
(1) 请求方法是以下三种方法之一:
(2)HTTP的头信息不超出以下几种字段:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain
1.简单请求
后端
1
2
3
4
5
6
7
8
9
10
11
12
| // 处理成功失败返回格式的工具
const {successBody} = require('../utli')
class CrossDomain {
static async cors (ctx) {
const query = ctx.request.query
// *时cookie不会在http请求中带上
ctx.set('Access-Control-Allow-Origin', '*')
ctx.cookies.set('tokenId', '2')
ctx.body = successBody({msg: query.msg}, 'success')
}
}
module.exports = CrossDomain
|
前端什么也不用干,就是正常发请求就可以,如果需要带cookie的话,前后端都要设置一下,下面那个非简单请求例子会看到。
1
2
3
| fetch(`http://localhost:9871/api/cors?msg=helloCors`).then(res => {
console.log(res)
})
|
2.非简单请求
非简单请求会发出一次预检测请求,返回码是204,预检测通过才会真正发出请求,这才返回200。这里通过前端发请求的时候增加一个额外的headers来触发非简单请求。

后端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // 处理成功失败返回格式的工具
const {successBody} = require('../utli')
class CrossDomain {
static async cors (ctx) {
const query = ctx.request.query
// 如果需要http请求中带上cookie,需要前后端都设置credentials,且后端设置指定的origin
ctx.set('Access-Control-Allow-Origin', 'http://localhost:9099')
ctx.set('Access-Control-Allow-Credentials', true)
// 非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求(preflight)
// 这种情况下除了设置origin,还需要设置Access-Control-Request-Method以及Access-Control-Request-Headers
ctx.set('Access-Control-Request-Method', 'PUT,POST,GET,DELETE,OPTIONS')
ctx.set('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, t')
ctx.cookies.set('tokenId', '2')
ctx.body = successBody({msg: query.msg}, 'success')
}
}
module.exports = CrossDomain
|
一个接口就要写这么多代码,如果想所有接口都统一处理,有什么更优雅的方式呢?见下面的koa2-cors。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| const path = require('path')
const Koa = require('koa')
const koaStatic = require('koa-static')
const bodyParser = require('koa-bodyparser')
const router = require('./router')
const cors = require('koa2-cors')
const app = new Koa()
const port = 9871
app.use(bodyParser())
// 处理静态资源 这里是前端build好之后的目录
app.use(koaStatic(
path.resolve(__dirname, '../dist')
))
// 处理cors
app.use(cors({
origin: function (ctx) {
return 'http://localhost:9099'
},
credentials: true,
allowMethods: ['GET', 'POST', 'DELETE'],
allowHeaders: ['t', 'Content-Type']
}))
// 路由
app.use(router.routes()).use(router.allowedMethods())
// 监听端口
app.listen(9871)
console.log(`[demo] start-quick is starting at port ${port}`)
|
前端
1
2
3
4
5
6
7
8
9
10
| fetch(`http://localhost:9871/api/cors?msg=helloCors`, {
// 需要带上cookie
credentials: 'include',
// 这里添加额外的headers来触发非简单请求
headers: {
't': 'extra headers'
}
}).then(res => {
console.log(res)
})
|
# 代理 (Nginx)
想一下,如果我们请求的时候还是用前端的域名,然后有个东西帮我们把这个请求转发到真正的后端域名上,不就避免跨域了吗?这时候,Nginx出场了。
Nginx配置
1
2
3
4
5
6
7
8
9
10
| server{
# 监听9099端口
listen 9099;
# 域名是localhost
server_name localhost;
#凡是localhost:9099/api这个样子的,都转发到真正的服务端地址http://localhost:9871
location ^~ /api {
proxy_pass http://localhost:9871;
}
}
|
前端就不用干什么事情了,除了写接口,也没后端什么事情了
1
2
3
4
5
6
7
8
9
10
11
| // 请求的时候直接用回前端这边的域名http://localhost:9099,这就不会跨域,然后Nginx监听到凡是localhost:9099/api这个样子的,都转发到真正的服务端地址http://localhost:9871
fetch('http://localhost:9099/api/iframePost', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
msg: 'helloIframePost'
})
})
|
Nginx转发的方式似乎很方便!但这种使用也是看场景的,如果后端接口是一个公共的API,比如一些公共服务获取天气什么的,前端调用的时候总不能让运维去配置一下Nginx,如果兼容性没问题(IE 10或者以上),CROS才是更通用的做法吧。
# 同源策略限制下Dom查询的正确打开方式
1.postMessage
window.postMessage()
是HTML5的一个接口,专注实现不同窗口不同页面的跨域通讯。
为了演示方便,我们将hosts改一下:127.0.0.1 crossDomain.com,现在访问域名crossDomain.com就等于访问127.0.0.1。
这里是
http://localhost:9099/#/crossDomain,发消息方
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| <template>
<div>
<button @click="postMessage">给http://crossDomain.com:9099发消息</button>
<iframe name="crossDomainIframe" src="http://crossdomain.com:9099"></iframe>
</div>
</template>
<script>
export default {
mounted () {
window.addEventListener('message', (e) => {
// 这里一定要对来源做校验
if (e.origin === 'http://crossdomain.com:9099') {
// 来自http://crossdomain.com:9099的结果回复
console.log(e.data)
}
})
},
methods: {
// 向http://crossdomain.com:9099发消息
postMessage () {
const iframe = window.frames['crossDomainIframe']
iframe.postMessage('我是[http://localhost:9099], 麻烦你查一下你那边有没有id为app的Dom', 'http://crossdomain.com:9099')
}
}
}
</script>
|
这里是
http://crossdomain.com:9099,接收消息方
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| <template>
<div>
我是http://crossdomain.com:9099
</div>
</template>
<script>
export default {
mounted () {
window.addEventListener('message', (e) => {
// 这里一定要对来源做校验
if (e.origin === 'http://localhost:9099') {
// http://localhost:9099发来的信息
console.log(e.data)
// e.source可以是回信的对象,其实就是http://localhost:9099窗口对象(window)的引用
// e.origin可以作为targetOrigin
e.source.postMessage(`我是[http://crossdomain.com:9099],我知道了兄弟,这就是你想知道的结果:${document.getElementById('app') ? '有id为app的Dom' : '没有id为app的Dom'}`, e.origin);
}
})
}
}
</script>
|
结果可以看到:

2.document.domain
这种方式只适合主域名相同,但子域名不同的iframe跨域。
比如主域名是
http://crossdomain.com:9099,子域名是
http://child.crossdomain.com:9099,这种情况下给两个页面指定一下document.domain即document.domain = crossdomain.com就可以访问各自的window对象了。
3.canvas操作图片的跨域问题
这个应该是一个比较冷门的跨域问题,张大神已经写过了我就不再班门弄斧了
解决canvas图片getImageData,toDataURL跨域问题
# HTTPS的页面发送不了HTTP请求
有些人说是跨域问题,真的是这样吗?
同源策略:1. 协议相同 2. 域名相同 3.端口相同
尽管HTTPS访问HTTP确实不符合同源策略中的协议相同,但是在现代浏览器里,即使是域名相同的请求,也是会出现以下报错,而不是跨域报错。
这也很好理解,毕竟混合内容的安全策略是在浏览器端判定的,而是否能跨域要看服务器返回的Response头,请求都被浏览器block掉了,也就不存在是否跨域的问题。
那什么是混合内容?
混合内容:初始 HTML 内容通过安全的 HTTPS 连接加载,但其他资源(例如,图像、视频、样式表、脚本)则通过不安全的 HTTP 连接加载[1]。因为页面通过 HTTPS 加载的初始请求是安全的,但是又加载了不安全的HTTP内容,因此称之为混合内容。
因为HTTPS的S本身就是Secure的意思,现代浏览器最初会针对此类型的内容显示警告,以向用户表明此页面包含不安全的资源。但是即使显示警告,页面也已经加载,用户的安全仍然受到了威胁。所以没过多久,Chrome和Firefox就直接阻断掉了这类的请求。
这就是HTTPS页面为什么发送不了HTTP的原因。
突破方式
尽管现在主流浏览器都已经block掉了HTTPS页面上的HTTP请求,但是我们还是可以通过被动混合内容来发送get请求。
被动混合内容:指的是不与页面其余部分进行交互的内容,包括图像、视频和音频内容,以及无法与页面其余部分进行交互的其他资源。
主动混合内容: 指的是能与页面交互的内容,包括浏览器可下载和执行的脚本、样式表、iframe、flash 资源及其他代码。[1]
因为攻击者可以通过不安全的HTTP内容来攻击安全的HTTPS页面,所以这类请求被严格阻断掉了————这也是为什么我们的Fetch请求被干掉了。所以我们可以在迫不得已的情况下,用img.src的方式来发送请求。当然,请求方法只能是get。
1
2
3
4
| sendHttpRequest: () => {
const img = new Image();
img.src = 'http://xxx.com//你的请求'
}
|
这时候,浏览器只会在控制台报warning,而不会block我们的请求。
出于HTTPS的安全策略,浏览器会阻断HTTPS上的非安全请求(HTTP)请求,但是我们可以使用被动混合内容的方式来跨越这个安全策略。