前端开发中,因为游览器的同源策略,总会遇到跨域的问题。
那什么是跨域,什么是同源策略?
如何规避同源策略?
JSONP 原理是什么?如何实现?
CORS是什么?如何实现?
代理是什么?前端代理如何实现?
本篇文章将会对什么这些疑惑进行解答。
-
基本概念了解
-
URL
URL (Universal Resource Locator) 统一资源定位符 。 是网上资源的家庭住址 。其指向的可能是一个 HTML文档 ,图片 ,或者视频等资源。
URL 由 协议(scheme) ,域名(domain) ,端口 (port),路径(path) ,参数( query ) , 锚点(anchor) 组成。
对于锚点,也可以叫 片段识别符(fragment identifier)
-
Origin (源)
源表示的是 Web 中资源的源 。它由资源的 URL 中的 方案 (协议),主机 (域名) 和端口定义。
仅当 方案 (协议),主机 (域名) 和端口 都相同时,才是同源。
以下是同源和非同源的例子:
| 举例 | 是否同源 |
|---|---|
http://example.com/app1/index.html``http://example.com/app2/index.html | 同源 ,仅路径不同 |
http://Example.com:80``http://example.com | 同源 ,大小写不区分,游览器默认端口为 80 |
http://example.com/app1``https://example.com/app2 | 不同源 ,协议不同 |
http://example.com``http://www.example.com``http://myapp.example.com | 不同源 ,域名不同 |
http://example.com``http://example.com:8080 | 不同源 ,端口不同 |
-
SOP ( 同源策略)
Same-origin policy (同源策略) 是游览器的一种重要的安全策略!它限制了当前源的文档或脚本是如何与其他源的资源的交互。
简单来说,就是为了安全 ,游览器有一种策略,叫同源策略 。如果当前源的文档或脚本要与其他源的资源进行交互,就会受到游览器的限制。
比如 cookie ,storage 不能共享 ,不能执行其他源的脚本 ,不能获取其他源的 DOM 等
比如开发时常见的报错:
因为端口不同,导致的源不同,在请求时(资源交互)受到游览器限制,因此报错!
跨源网络访问限制
- XMLHttpRequest 和 Fetch 发送的请求 ,一般受同源策略的限制。 允许发送,不允许读取,不允许带上 cookie
- Img, video,script, link 等标签一般是允许跨域的
-
Font 字体资源部分游览器受同源策略限制 (部分游览器可能不限制)
-
CORS (跨域资源共享)
CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。
从实际来看,它就是通过配置请求头和响应头允许资源跨域共享。
因为需要配置请求头和响应头,因此 CORS 需要游览器和服务器的同时配合。 前端 发起的请求需要符合 CORS 标准 , 服务器也需要设置响应头,允许跨域请求。
-
游览器的跨域
游览器的跨域是由于同源策略的原因,使游览器不能读取其他源的资源,并且在请求时,游览器不会携带该域下的 cookie 。
跨域的导致原因就是因为不同源。也就是跨域不仅仅是因为域名的不同。
注意: 游览器是可以发送跨域请求 ,但是不能读取跨域请求回来的资源。游览器在跨域时不会带上 cookie
-
规避同源策略
同源策略的影响,主要体现在两个方面。
- 跨域窗口之间的通信
比如 A 不能访问 B 页面的 DOM ,也不能执行 B页面的脚本 (A B 不同源)
A B 之间不共享 cookie 和 storage
- 跨域请求
比如游览器发送出去的跨域请求不会带上本域名下的 cookie
当前域不能读取跨域请求的响应数据。
-
跨域窗口通信
-
同父域名
Dom
JS 不能访问到非同源的其他文档的 DOM 元素。比如使用 iframe 打开其他文档时。
那如果没有这个限制,会有什么危害呢?
我是一个坏人,我开发了一个网页。并且通过一些手段,将链接放到其他不干不净的网页的广告里面。你预览不干不净的网页 ,不小心点了进来。进来后,我的网页里又放了些不干不净的东西,免费预览那种,你以为你赚了,还分享给好友。
但是你不知道。你的信息已经被我窃取。
那是如何干的呢?
<!DOCTYPE html>
<html lang="en">
<body>
<iframe src="https://www.yuque.com/dashboard" frameborder="0" width="1200px" height="700px"></iframe>
</body>
</html>
比如这段代码可以打开你的语雀,并且可以获取你的文章(如果你游览器登录了语雀)。
但由于同源限制。你读取 DOM 时是会报错的。
setTimeout(() => {
window.frames[0].contentWindow.document
}, 4000) // 延迟让 dom 渲染完
但如果两个窗口一级域名相同,只是二级域名不同,那么设置document.domain属性,就可以规避同源政策,拿到DOM。
document.domain (不建议使用,已弃用)
document.domain可以修改文档的域名 (必须是当前源的一部分)。当两个文档的一级域名相同。但二级或三级等域名不同时,可以通过设置它为一级域名。从而去规避 同源策略 的影响。
比如 bbb.aaa.com 和 ccc.aaa.com 下分别有两个页面。
两个页面都设置
document.domain = 'aaa.com'
然后无论是在哪个页面中通过 iframe 打开另一个页面。都可以访问到 DOM节点。
Cookie
受同样策略限制。非同源是不能共享 cookie 的。也就是 A 页面和 B 页面不同源 ,那么 cookie 是相互之间看不见,拿不到的。
否则其他网页读取了你其他页面的 cookie ,轻则隐私不保,重则财产损失。
Cookie 有自己的限制规则。一般跨域的情况下 , cookie 不共享,发送请求时, cookie 不携带。
但需要注意,
- 端口不同时, cookie 在不同系统中是共享的。比如 A系统和 B 系统都不是在同服务器,同域名。但是端口不同。但在游览器中 cookie 是可以共享的。
- 父域名不能访问子域名的 cookie , 但子域名下的文档可以访问到父域名下的 cookie
document.domain
当两个子域名有相同的一级域名时 ,相当于将页面的 document.domain 都设置成一级域名 。就可以实现 cookie 共享。
Cookie 属性 domain
Cookie 有自己的 domain 属性 。不同子域名的 cookie 如果 domain 属性都是一级域名,也可以实现 子域名间 cookie 共享。
以上主要介绍的是,在跨域页面有相同的父域名的情况下可以使用的分发。
但如果没有相同的父域名,该如何解决跨域页面之间的通信呢?
-
window.name
浏览器窗口有window.name属性。这个属性的最大特点是,无论是否同源,只要在同一个窗口里,前一个网页设置了这个属性,后一个网页可以读取它。
实践步骤:
- A 页面打开一个窗口 。 此窗口打开的是 B 页面 。 A, B 不同源 。
- B 页面设置
window.name属性 。如何通过localhost跳回到与 A 页面同源的一个页面。
- A 页面通过获取该窗口的
window对象即可读取 name 属性
console.log(window.frames[0].name);
console.log(document.querySelector('iframe').contentWindow.name);
实践代码:
将四个文件放在同一个目录下 。在根目录通过 node 分别执行 appA.js 和 appB.js 文件。
在游览器中输入 localhost:3000 , 一秒后在页面看到 from PageB 即数据传输成功!
PageA.html
<!DOCTYPE html>
<html lang="en">
<head>
<title>PageA</title>
</head>
<body>
<h2>PageA</h2>
<hr>
<iframe src="http://localhost:3001/index.html" frameborder="1" width="40px" height="40px"></iframe>
<hr>
</body>
<script>
setTimeout(() => {
console.log(window.frames[0].name);
let button = document.createElement('button')
button.textContent = document.querySelector('iframe').contentWindow.name
document.body.append(button)
}, 1000)
</script>
</html>
PageB.html
<!DOCTYPE html>
<html lang="en">
<head>
<title>PageB</title>
</head>
<body>
<h3>PageB</h3>
</body>
<script>
window.onload = () => {
window.name = "From PageB"
location = "http://localhost:3000/empty"
}
</script>
</html>
appA.js
const http = require('http')
const fs = require('fs')
http.createServer((req, res) => {
console.log(req.url)
if (req.url === '/empty') {
res.end("123")
return
}
res.end(fs.readFileSync('./pageA.html'))
}).listen(3000, () => {
console.log("appA server on:localhost:3000");
})
appB.js
const http = require('http')
const fs = require('fs')
http.createServer((req, res) => {
res.end(fs.readFileSync('./pageB.html'))
}).listen(3001, () => {
console.log("appB server on:localhost:3001");
})
-
片段识别符(fragment identifier)
判断识别符是 URL 后的哈希值 。也可以说是锚点值。
游览器可以改变它,也可以读取它。重点是它的改变,不会时游览器使进行刷新。
这使它也成为了传递数据的一个方法.
父窗口可以把信息,写入子窗口的片段标识符
var src = originURL + '#' + data;
document.getElementById('myIFrame').src = src;
子窗口通过监听hashchange事件得到通知。
window.onhashchange = checkMessage;
function checkMessage() {
var message = window.location.hash; // ...
}
同样的,子窗口也可以改变父窗口的片段标识符。
window.parent.location.href= target + "#" + hash
-
postMessage
window.postMessage() 是一个可以支持跨域通信的 API 。
父子窗口之间可以通过此 API 发送消息。并且通过监听 message事件获取消息。
语法
otherWindow.postMessage(message,origin ,[transfer ])
- otherWindow
窗口的引用。比如页面中通过 iframe 打开的子窗口。
- Message
需要发送的信息。可以是任意数据。但数据会被处理以保证数据的安全
- Origin
通过窗口的 origin 属性来指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示无限制)或者一个 URI。
在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配 targetOrigin 提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。
事件对象
通过监听 message 事件。当接收到其他窗口发送来的信息时。可以接收到 message 事件对象。
window.addEventListener('message',(mes)=>{
console.log(mes.data)
})
事件对象包括几个关键属性
- data
postMessage 传输过来的数据
- origin
由哪个源发送过来的数据 。此属性尤为重要。你需要判断此源是否安全。防止恶意攻击
- source
发送方窗口的引用 。相当于对方页面的 window 引用 。
代码实践
通过 live server 启动即可测试。 此 API 本身就支持跨域。
page1.html
<!DOCTYPE html>
<html lang="en">
<head>
<title>page1</title>
</head>
<body>
<h1>page1</h1>
<button onclick="a()" class="show">post</button>
<hr>
<iframe name="iframe" src="http://127.0.0.1:5500/%E9%A1%B5%E9%9D%A2%E9%80%9A%E4%BF%A1/postMessage/page2.html"
frameborder="1" width="400px" height="400px"></iframe>
</body>
<script>
function a() {
window.frames[0].postMessage('a message from page1', 'http://127.0.0.1:5500');
}
window.addEventListener('message', (v) => {
document.querySelector('.show').textContent = v.data
})
</script>
</html>
page2.html
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<h1>page2</h1>
<button id="show"></button>
</body>
<script>
window.addEventListener('message', (v) => {
document.querySelector('#show').textContent = v.data
/* 通过 source 获取父窗口,通过 postMessage 传参给父窗口 */
v.source.postMessage('a message form page2', 'http://127.0.0.1:5500')
})
</script>
</html>
-
跨域请求
同源策略对跨域的请求也有一定的限制 。
比如 跨域请求时 ,不会带上本源的 cookie 。
不会让当前源的脚本读取到其他源的请求响应
-
JSONP
JSONP 是一种古老的技术了 。 因为游览器对通过 a , script , link ,img 标签的 src ,或 href 发送的请求不会有跨域的限制。
原理
JSONP 的原理就是利用 script 标签发送请求。通过回调获取响应数据。响应的数据会被 script 当做脚本执行。
不过, JSONP 只能发送 GET 请求。并且在数据交流上,也不是很方便。
Exapel
通过 node 执行 app.js 文件 。
app.js
const http = require('http')
const url = require('url')
http.createServer(function (req, res) {
let {
pathname,
query
} = url.parse(req.url)
// 获取回调函数,将数据作为参数传入。
if (pathname === '/getinfo') {
let fun = query.split('=')[1]
let ans = fun + `('jack')`
// ans 在字符串表示是 : 函数(参数)
res.end(ans)
} else
res.end("Holle")
}).listen(3000)
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<title>page</title>
</head>
<body>
<h1>Page</h1>
<button id="show"></button>
</body>
<script>
// 回调函数
function getData(data) {
document.querySelector('#show').textContent = data
}
window.onload = function () {
function Get(_url) {
let url = _url + "?" + 'callback=getData'
let script = document.createElement('script')
script.setAttribute('type', 'text/javascript');
script.setAttribute('src', url)
// 监听请求完成,并删除 script
script.onload = function (res) {
document.body.removeChild(script)
}
// 插入页面,发送请求
document.body.append(script)
}
Get('http://localhost:3000/getinfo')
}
</script>
</html>
-
websocket
WebSocket是一种通信协议,使用ws://(非加密)和wss://(加密)作为协议前缀。该协议不实行同源政策,
因此只要服务器支持,就可以通过它进行跨源通信。
-
CORS
通过配置请求头和响应头,使请求满足 CROS 标准 。 就可以实现跨域请求。
目前游览器基本支持 CROS ,因为需要设置响应头,因此也需要服务器的支持。
不过 :在较新版 Edge 和 Chrome 游览器都限制了 跨源 cookie 的发送 。即使 CORS 中 配置了允许跨域携带 cookie
关于这点可以去了解 Samesite cookie
简单请求和非简单请求
浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。
只要同时满足以下两大条件,就属于简单请求。
(1) 请求方法是以下三种方法之一:
- HEAD
- GET
POST
(2)HTTP的头信息不超出以下几种字段:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type:只限于三个值
application/x-www-form-urlencoded、multipart/form-data、text/plain
凡是不同时满足上面两个条件,就属于非简单请求。
浏览器对这两种请求的处理,是不一样的。
简单请求
基本流程
对于简单请求,游览器会直接请求。在请求头中,会添加一个 origin 字段。表示此请求发送源。
服务器可以根据此字段,判断请求源是否安全,是否响应数据。
如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。
- Access-Control-Allow-Origin
- Access-Control-Allow-Credentials
-
Access-Control-Expose-Headers
Example :
app.all('*', function (req, res, next) {
// 配置跨域和允许
res.header('Access-Control-Allow-Origin', 'http://127.0.0.1:5500');
// 是否允许跨域 cookie
res.header('Access-Control-Allow-Credentials', true)
// 允许游览器读取的其他请求头
res.header('Access-Control-Expose-Headers','headA headB')
next();
});
- Access-Control-Allow-Origin
该字段是必须的。它的值要么是请求时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里面指定。
withCredentials 属性
上面说到,CORS请求默认不发送Cookie和HTTP认证信息。
如果要把Cookie发到服务器,
一方面要服务器同意,指定Access-Control-Allow-Credentials字段,值为 true
另一方面,前端也必须在AJAX请求中设置withCredentials属性。
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
注意: 如果要发送Cookie,Access-Control-Allow-Origin就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie依然遵循同源政策,只有用服务器域名设置的Cookie才会上传,其他域名的Cookie并不会上传,且(跨源)原网页代码中的document.cookie也无法读取服务器域名下的Cookie。
非简单请求
预检请求
对于非简单请求,游览器并不会直接发送请求,而是先发一个 "预检"请求(preflight)。请求方法是 OPTIONS
它的作用就是确定服务器是否允许接下来将要发送的请求,并携带请求方法,请求头等信息。只有得到肯定的答复,游览器才会发送正式的请求。
比如你发送一个 PUT 请求时 ,或者Content-Type字段的类型是application/json ,或者其他超出简单请求范围的情况。
通过游览器的 network 检查:
域请求通过后发送正式请求
预请求的请求方法是 OPTIONS
预请求请求头
除了Origin字段,"预检"请求的头信息包括两个特殊字段。
- Access-Control-Request-Method
该字段是必须的,用来列出浏览器的CORS请求会用到哪些HTTP方法,比如 GET , POST , PUT,DELETE
- Access-Control-Request-Headers
该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段。
比如 Content-Type , Token
预请求响应头
服务器在接收到 "预检"请求以后 。通过设置相应头,以表示其允许的请求方法,请求头 。如果都在允许范围内,即允许跨域请求,正常响应。
- Access-Control-Allow-Methods
该字段必需,它的值是逗号分隔的一个字符串。
表明服务器支持的所有跨域请求的方法。
注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次"预检"请求。
比如 Access-Control-Allow-Methods:'GET,POST,PUT,DELETE
- Access-Control-Allow-Headers
如果请求的请求头包括Access-Control-Request-Headers字段,则Access-Control-Allow-Headers字段是必需的。
它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在"预检"中请求的字段。
- Access-Control-Allow-Credentials
该字段与简单请求时的含义相同。
- Access-Control-Max-Age
该字段可选,用来指定本次预检请求的有效期,单位为秒。
有默认值,不同游览器默认时间不同。
在有效时间内,重新发起该请求则不会发送预请求,而是直接发送。
实践一波
App.js
测试 cookie 使用了 express-session 。install 一下 express 和 express-session 。 Node 启动即可
const express = require('express')
const session = require('express-session')
const app = express()
app.use(session({
secret: 'cros-test',
resave: false,
saveUninitialized: true,
cookie: {
secure: false, // HTTPS 才有效
maxAge: 60 * 1000,
}
}))
app.all('*', function (req, res, next) {
// 配置跨域
res.header('Access-Control-Allow-Origin', 'http://127.0.0.1:5500');
res.header('Access-Control-Allow-Credentials', true)
res.header('Access-Control-Allow-Methods', 'GET,POST,PUT')
res.header('Access-Control-Allow-Headers', 'content-type')
next();
});
app.get('/get', (req, res) => {
let code = req.session.code || 1000
req.session.code = Number(code) + 1
res.send({
code: 1,
msg: "请求成功!",
data: {
code
}
})
})
app.listen(3001, () => {
console.log(3001);
})
index.html
通过 live server 启动即可 。注意域名端口要与服务器一致
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page</title>
<script src="./js/ajax.js"></script>
</head>
<body>
<h1>Page</h1>
<hr>
<button onclick="getCode()" id="button">验证码:loading</button>
</body>
<script src="./js/index.js"></script>
</html>
js
// ajax.js
function Get(url) {
return new Promise((resolve) => {
let XML = new XMLHttpRequest()
XML.open("GET", url)
// 设置后为非普通请求
XML.setRequestHeader('content-type', 'application/json')
// 允许 cookie
XML.withCredentials = true
XML.send(null)
XML.onreadystatechange = function () {
if (XML.readyState === XML.DONE) {
resolve(JSON.parse(XML.response))
}
}
})
}
// index.js
let url = 'http://localhost:3001/get'
function getCode() {
Get(url).then(res => {
document.querySelector('#button').innerText = '验证码:' + res.data.code|| 'error'
}).catch(err => {
console.log(err)
})
}
-
代理 (proxy)
游览器有同源策略。但是服务器没有。
也就是,如果我们在当前源上开启一个服务器叫 proxy server。那么游览器可以直接向该服务器发送请求。因为同源,因此不受游览器限制。
而 proxy server 服务器接收到请求后,将该请求转发到后台的服务器。服务器接收到请求后,发送响应数据给 proxy server 。 然后 proxy server 再将结果发送给游览器。
(对于代理,内容比较多,这里主要讲正向代理的原理,并附上代码。)
正,反代理
代理分为正向代理 (forward proxy) 和反向代理 (reverse proxy)。
正向代理一般设在客户端。比如 VPN 就是一种正向代理。
这里的客户端是相对服务器而言的。并不是说就是一定设置在我们的电脑本机上。
而反向代理一般设在服务器上 ,比如 nginx 。
他们除设置地方不同,作用也不同。
正向代理可以隐藏客户端,扩大请求范围。
比如 VPN 可以让我们访问国外的请求。并且服务器不知道这个请求实际上来自哪里
而反向代理可以隐藏具体的服务器 ,分发请求,负载均衡,防止恶意攻击等
举一个通俗易懂 ( bu tai qia dang ) 的栗子:
你是幕后大 BOSS 。 为了自身安全,你让一个人假冒你,去跟另一个 BOSS 交易。不过你不知道,对方也不是真的 BOSS 。
实践尝试
Github地址:github.com/wtdsn/my-pr…
-
问题
本篇文章,是内部分享会用来分享给师弟师妹们的,所以留了几个问题给他们
- 问题1:
虽然同源策略限制了 DOM 的访问。但是并不限制页面的加载和窗口样式的修改。
也就是我可以打开一个 iframe ,设置透明。并且在透明的 iframe 下面设置一些按钮。诱导你点击。但其实你点击到的是 iframe 页面。也就是我不能直接操作 iframe 里的 DOM ,但是我可以诱导你操作。
问题就是,如何防范这种情况?
- 问题2:
同源策略能否防范 CSRF 攻击
- 问题3:
游览器不会执行跨域脚本。但如果是 通过 script 标签 从其他域请求脚本的文件。
能否被执行?
为什么?