前端开发一定会遇到跨域,它的专业术语叫”同源安全策略”,什么情况会促发跨域呢?遇到跨域我们怎么办呢?
带着这两个问题我们来一起总结 💃
第一个问题,跨域是怎么促发的,翻阅了 mdn 文档对源的大致定义是
如果两个 URL 的 协议、端口 (en-US)(如果有指定的话)和主机 都相同的话,则这两个 URL 是同源的。这个方案也被称为“协议/主机/端口元组”,或者直接是“元组”。
下表给出了与 URL http://store.company.com/dir/page.html 的源进行对比的示例:
URL |
结果 |
原因 |
http://store.company.com/dir2/other.html |
同源 ✌️|只有路径不同 |
|
http://store.company.com/dir/inner/another.html |
同源 ✌️ |
只有路径不同 |
https://store.company.com/secure.html |
失败 💣 |
协议不同 |
http://store.company.com:81/dir/etc.html |
失败 💣 |
端口不同(http:// 默认端口是 80) |
http://news.company.com/dir/other.html |
失败 💣 |
主机不同 |
mdn 还介绍了源的继承、文件源、源的更改等内容,***点击展开*** 👉
源的继承
在页面中通过 about:blank 或 javascript: URL 执行的脚本会继承打开该 URL 的文档的源,因为这些类型的 URL 没有包含源服务器的相关信息。
例如,about:blank 通常作为父脚本写入内容的新的空白弹出窗口的 URL(例如,通过 Window.open())。如果此弹出窗口也包含 JavaScript,则该脚本将从创建它的脚本那里继承对应的源。
data: URL 将获得一个新的、空的安全上下文。
文件源
现代浏览器通常将使用 file:/// 模式加载的文件的来源视为不透明的来源。这意味着,假如一个文件包括来自同一文件夹的其他文件,它们不会被认为来自同一来源,并可能引发 CORS 错误。
请注意,URL 规范指出,文件的来源与实现有关,一些浏览器可能将同一目录或子目录下的文件视为同源文件,尽管这有安全影响。
源的更改
警告: 这里描述的方法(使用 document.domain setter)已被弃用,因为它破坏了同源策略所提供的安全保护,并使浏览器中的源模型复杂化,导致互操作性问题和安全漏洞。
满足某些限制条件的情况下,页面是可以修改它的源。脚本可以将 document.domain 的值设置为其当前域或其当前域的父域。如果将其设置为其当前域的父域,则这个较短的父域将用于后续源检查。
例如:假设 http://store.company.com/dir/other.html 文档中的一个脚本执行以下语句:
document.domain = "company.com";
Copy to Clipboard
这条语句执行之后,页面将会成功地通过与 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 (en-US) Permissions-Policy,或该文档在沙箱 <iframe> 下,它将抛出一个“SecurityError” DOMException,并且用这种方法改变源并不影响 Web API 使用的源检查(例如 localStorage、indexedDB、BroadcastChannel、SharedWorker)。更详尽的失败案例列表可以在 Document.domain 的错误章节找到。
备注: 使用 document.domain 来允许子域安全访问其父域时,需要在父域和子域中设置 document.domain 为相同的值。这是必要的,即使这样做只是将父域设置回其原始值。不这样做可能会导致权限错误。
</pre>
第二个问题怎么办,主要 解决跨域 方法有以下38种,我们每一种来好好了解一下: 💪
- JSONP ✨ 通过 script 标签请求跨域 JSON 资料,指定回调函数处理返回数据。
JSONP 出现的早,兼容性好,只支持get请求,需要前后端一起配合。
在前端中,不管是 script 标签的 src 属性,还是 img 标签的 src 属性,还是 a 标签的 href 属性,
还是 link 标签的 href 属性,还是 iframe 标签的 src 属性,
其实都不受同源策略的限制。jsonp 跨域就是巧妙的运用了这一特性实现的跨域。
实现方法: 在点击发送按钮之前,创建一个script标签,给script标签的src属性设置为服务器的请求地址,
参数用问好拼接,callback参数是用来接受请求返回值的参数,所以一定要写!
<button click="sendFunc()">点击发送请求</button>
<script>
function sendFunc(){
let frame = document.createElement('script');
frame.src = 'http://localhost:5000/api/list?name=zhang&age=18&callback=func;
document.('body').append(frame);
};
function func(res){
console.log(res);
}
</script>
服务器端实现:
router.get('/api/list', (req, res) => {
console.log(req.query, '123');
let data = {
message: 'success!',
name: req.query.name,
age: req.query.age
}
data = JSON.stringify(data)
res.end('func(' + data + ')');
})
- CORS ✨ 服务端设置 Access-Control-Allow-Origin 来明确允许跨域请求。
由服务端来配置响应头
header(“Access-Control-Allow-Origin:*”)
const app2 = express()
app2.get('/', function (req, res) {
res.header('Access-Control-Allow-Origin', '*')
res.send('你好')
})
app2.listen(91)
客户端直接发请求即可
<script>
fetch("http://localhost:91").then(res => res.text()).then(data => { alert(data) })
</script>
上面的配置是最基础的,实际项目中我们还有更加细化的配置
res.header('Access-Control-Allow-Origin', ALLOW_ORIGIN)
res.header('Access-Control-Allow-Credentials', ALLOW_ORIGIN)
res.header('Access-Control-Expose-Headers', ALLOW_ORIGIN)
有时还会配置一个options用来先判断一次是否允许跨域,这就是发起请求会返回一个options和一份数据的原因;
options请求是跨域请求之前的预检查,会返回服务端支持的请求方法(get、post…)
- postMessage 跨域iframe之间传送消息。
window.postMessage() 方法允许来自一个文档的脚本可以传递文本消息到另一个文档里的脚本,而不用管是否跨域。
一个文档里的脚本还是不能调用在其他文档里方法和读取属性,但他们可以用这种消息传递技术来实现安全的通信。
这项技术称为“跨文档消息传递”,又称为“窗口间消息传递”或者“跨域消息传递”。
postMessage() 方法,该方法允许有限的通信 —— 通过异步消息传递的方式 —— 在来自不同源的脚本之间。
postMessage 可用于解决以下方面的问题:
- 页面和其打开的新窗口的数据传递
- 页面与嵌套的 iframe 消息传递
- 多窗口之间消息传递
- 想要使用 postMessage 实现跨域通信和页面间数据通信,只要记住 window 提供的 postMessage 方法和 message 事件就ok了。🚀🚀🚀
语法
otherWindow.postMessage(message, targetOrigin, [transfer]);
otherWindow
其他窗口的一个引用,比如 iframe 的 contentWindow 属性、执行 window.open 返回的窗口对象、或者是命名过或数值索引的 window.frames。
message
要发送的数据。它将会被结构化克隆算法序列化,所以无需自己序列化(部分低版本浏览器只支持字符串,所以发送的数据最好用JSON.stringify() 序列化)。
targetOrigin
通过 targetOrigin 属性来指定哪些窗口能接收到消息事件,其值可以是字符串“*”(表示无限制)或者一个 URI(如果要指定和当前窗口同源的话可设置为”/“)。在发送消息的时候,如果目标窗口的协议、主机地址或端口号这三者的任意一项不匹配 targetOrigin 提供的值,那么消息就不会发送。
接收消息
如果指定的源匹配的话,那么当调用 postMessage() 方法的时候,在目标窗口的Window对象上就会触发一个 message 事件。
window.addEventListener("message", (event)=>{
var origin = event.origin;
if (origin !== "http://example.org:8080")
return;
}, false);
event 的属性有:
- data: 从其他 window 传递过来的数据副本。
- origin: 调用 postMessage 时,消息发送窗口的 origin。例如:“http://example.com:8080”。
- source: 对发送消息的窗口对象的引用。可以使用此来在具有不同 origin 的两个窗口之间建立双向数据通信。
使用场景
当想要在Web页面中嵌入一个来自其他站点的模块或者“gadget”的时候,利用 postMessage() 和 message 事件实现的跨域消息传递是很有用的。
首先 gadget 的开发者可以将 gadget 内容定义在一个 HTML 页面中,它负责监听 message 事件,并将它们分发给对应的 js 函数去处理。然后,嵌入 gadget 的Web页面就可以通过 postMessage() 方法传递消息来和 gadget 进行交互了。
完整例子
1⃣️ 不同 origin 的两个窗口之间建立双向数据通信
window.addEventListener('message', (e) => {
console.log(e.data)
})
const targetWindow = window.open('http://localhost:10001/user');
setTimeout(()=>{
targetWindow.postMessage('来自10002的消息', 'http://localhost:10001')
}, 3000)
window.addEventListener('message', (e) => {
console.log(e.data)
if (event.origin !== "http://localhost:10002")
return;
e.source.postMessage('来自10001的消息', e.origin)
})
2⃣️ 页面与嵌套的 iframe 消息传递
http://www.domain1.com/a.html
<iframe id="iframe" src="http://www.domain2.com/b.html"></iframe>
<script>
var iframe = document.getElementById('iframe');
iframe.onload = function() {
iframe.contentWindow.postMessage('来自domain1的消息', 'http://www.domain2.com');
};
window.addEventListener('message',(e) => {
console.log(e.data);
}, false);
</script>
http://www.domain2.com/b.html
<script>
window.addEventListener('message',(e) => {
console.log(e.data);
if(e.origin !== 'http://www.domain1.com')
return;
window.parent.postMessage('来自domain2的消息', e.origin);
}, false);
</script>
安卓平台差异处理
window.Android_handleMessage = message => {
let data = decodeURIComponent(escape(window.atob(message)));
};
安全问题
- 如果你不希望从其他网站接收 message,请不要为 message 事件添加任何事件监听器。
- 如果你确实希望从其他网站接收message,请始终使用 origin 和 source 属性验证发件人的身份。
- 当你使用 postMessage 将数据发送到其他窗口时,始终指定精确的目标 origin,而不是 *。
兼容性
所有主流浏览器(包括IE8)都支持。
- webSocket 建立的 websocket 连接跨域。
webSocket 协议是 HTML5 的新协议。能够实现浏览器与服务器全双工通信,同时允许跨域,是服务端推送技术的一种很好的实现。
webSocket 本身不存在跨域问题,所以我们可以利用 webSocket 来进行非同源之间的通信。
使用 Node.js 建立 WebSocket 连接的简单例子:
npm i websocket
const WebSocketServer = require('websocket').server
const http = require('http')
const port = 8000
let time = 0
const server = http.createServer((request, response) => {
console.log(`${new Date().toLocaleDateString()} Received request for ${request.url}`)
response.writeHead(404)
response.end()
})
server.listen(port, () => {
console.log(`${new Date().toLocaleDateString()} Server is listening on port ${port}`)
})
const wsServer = new WebSocketServer({
httpServer: server
})
wsServer.on('request', (request) => {
console.log(request.origin, '=======request.origin=======')
const connection = request.accept(null, request.origin)
console.log(`${new Date().toLocaleDateString()} 已经建立连接`)
setInterval(() => {
const obj = {
title: '标题' + time++,
value: '内容' + time++
}
connection.send(JSON.stringify(obj))
}, 2000)
connection.on('message', (message) => {
console.log('message========>', message)
if (message.type === 'utf8') {
console.log('Received Message: ' + message.utf8Data);
} else if (message.type === 'binary') {
console.log('Received Binary Message of ' + message.binaryData.length + ' bytes');
}
});
connection.on('close', (reasonCdoe, description) => {
console.log(`${new Date().toLocaleDateString()} ${connection.remoteAddress} 断开链接`)
})
})
const ws = new WebSocket('ws://127.0.0.1:8000')
ws.onopen = (res) => {
console.log('onopen readyState',ws.readyState)
console.log('onopen 连接成功==========>', res)
}
ws.onmessage = ({ data }) => {
console.log('onmessage readyState',ws.readyState)
console.log('onmessage 有新消息啦=======>', JSON.parse(data))
ws.send('客户端发送的消息')
}
ws.onclose = () => {
console.log('onclose readyState',ws.readyState)
console.log('onclose websocket连接关闭=======>')
}
ws.onerror = (error) => {
console.log('onerror readyState',ws.readyState)
console.log('onerror 发生错误==========>', error)
}
这个例子中:
-使用ws模块的Server对象创建一个WebSocket服务器,监听8080端口
-服务器在接收到连接时会发送一条消息,并注册message事件的监听器
-客户端使用ws模块连接到服务器,连接成功后发送一条消息
-客户端注册message事件的监听器来接收服务器发来的消息
-这样客户端和服务器就建立了WebSocket连接,可以双向通信了
WebSocket协议是建立在TCPsocket上的,用于浏览器和服务器之间的数据交换。与HTTP不同,WebSocket提供了全双工通信通道,允许服务器主动推送消息给客户端。
所以用它可以实现真实时的双向通信,很适合用于聊天应用,监控系统等场景。
server-sent events 服务端推送跨域数据
SSE(Server-Sent Events)是一种基于 HTTP 协议的推送技术。
服务端可以使用 SSE 来向客户端推送数据,但客户端不能通过 SSE 向服务端发送数据。
相较于 WebSocket,SSE 更简单、更轻量级,但只能实现单向通信。
在跨域环境下,使用 SSE 技术进行数据交互可以解决 AJAX 请求受到同源策略限制的问题。
通过 SSE,客户端可以与服务器建立持久连接,当服务器有更新的数据时会自动推送给客户端。
使用 SSE 需要在服务器响应头中添加 “Access-Control-Allow-Origin” 字段,指定允许访问的域。
客户端可以使用 EventSource API 与服务器建立 SSE 连接,通过事件监听来获取服务器发送的数据。
客户端代码
var source = new EventSource('http://127.0.0.1:6788/EventSource-test')
source.onopen = function (event) {
console.log('成功与服务器连接')
}
source.onmessage = function (event) {
console.log('未命名事件', event.data)
}
source.onerror = function (error) {
console.log('错误')
}
source.addEventListener("myEve", function (event) {
console.log("myEve", event.data)
})
服务端代码
const fs = require('fs')
const express = require('express')
const app = express()
app.get('/EventSource-test', (ewq, res) => {
res.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache"
})
res.write(':注释' + '\n\n')
res.write('data:' + '消息内容1' + '\n\n')
res.write(
'event: myEve' + '\n' +
'data:' + '消息内容2' + '\n' +
'retry:' + '2000' + '\n' +
'id:' + '12345' + '\n\n'
)
setInterval(() => {
res.write('data:' + '定时消息' + '\n\n')
}, 2000)
})
app.listen(6788, () => {
console.log(`server runing on port 6788 ...`)
})
document.domain + iframe 设置同一个 document.domain 来跨子域。
在两个主域名相同的,二级域名下,可以通过设置 document.domain 为同一个主域名来进行数据传递
window.name 在 iframe 载入其他域页面前,先将数据存入 window.name,后读取数据。
利用同一个窗体切换地址后 window.name 不会被改变,先通过当前窗体给 window.name
设置值后切换到和 iframe 中同源页面,就可以和 iframe 内进行数据交互了。
location.hash 在 iframe 载入其他域页面前,先将数据存入 location.hash ,后读取数据。
思路:通过 A 页面实时监听自己路径上的 hash 如果有变化触发事件,然后 A 页面中有 iframe 指向 B 页面,
B 页面在初始化时判断自己的 hash 值,根据 hash 值的不同触发不同的逻辑,在 B 页面
可以通过 parent.location.href = ‘xxx’ 来修改父页面 A 的 hash 值.
flash跨域访问解决办法
flash访问另一个域的数据,flash player 会自动从改域加载策略文件(crossdomain.xml),如果访问的数据所在的域在策略文件中,则数据将可访问。
下面的策略文件表示允许 abcd.com 上的 flash 访问来自 www.abc.com,abcd.com 和www.cba.com 文档数据
也可以使用通配符允许访问所有域文档
对于crossdomain.xml文件存放位置,建议将其存放于站点根目录中
- XHR+CORS 允许跨域的CORSheader。
(1)Access-Control-Allow-Origin
该字段是必须的。它的值要么是请求时 Origin 字段的值,要么是一个 *,表示接受任意域名的请求。
(2)Access-Control-Allow-Credentials
该字段可选。它的值是一个布尔值,表示是否允许发送 Cookie。默认情况下,Cookie 不包括在 CORS 请求之中。
设为 true,即表示服务器明确许可,Cookie 可以包含在请求中,一起发给服务器。这个值也只能设为 true,
如果服务器不要浏览器发送 Cookie,删除该字段即可。
(3)Access-Control-Expose-Headers
该字段可选。CORS 请求时,XMLHttpRequest 对象的 getResponseHeader() 方法只能拿到6个基本字段:
Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。
如果想拿到其他字段,就必须在 Access-Control-Expose-Headers 里面指定。
(4)Access-Control-Request-Method
指定实际请求HTTP的请求方式
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "POST");
response.setHeader("Access-Control-Allow-Headers", "x-requested-with,content-type");
response.addHeader("Access-Control-Allow-Credentials", "true");
window.location 后端重定向到自身域。
window.location = ‘xxx’
navigator.sendBeacon 作为GET请求在页面unload时异步发送数据包。
本身就支持跨域,允许发送少量的数据 http post
跨域资源嵌入:使用script link img a iframe 等嵌入跨域资源。
- script src=”…” 标签嵌入跨域脚本。src 可以认为是浏览器发起跨域请求获得,语法错误信息只能在同源脚本中捕捉到。
- link rel=”stylesheet” href=”…” 标签嵌入 CSS。href可以认为是浏览器发起跨域请求获得,由于CSS的松散的语法规则,CSS 的跨域需要一个设置正确的 Content-Type 消息头。
- img 、video 、 audio 嵌入多媒体资源。
- object 、embed 和 applet 的插件。
- @font-face 引入的字体。一些浏览器允许跨域字体( cross-origin fonts),一些需要同源字体(same-origin fonts)。
- frame 和 iframe 载入的任何资源。站点可以使用X-Frame-Options消息头来阻止这种形式的跨域交互。 可能引发点击劫持:网络安全-点击劫持(ClickJacking)的原理、攻击及防御
- SharedWorker 共享的 WebWorker 跨域。
Shared Worker 是 Worker 家族的另一个成员。普通的 Worker 之间是独立运行、数据互不相通;而多个 Tab 注册的 Shared Worker 则可以实现数据共享。
Shared Worker 在实现跨页面通信时的问题在于,它无法主动通知所有页面,因此我们会使用轮询的方式,来拉取最新的数据。思路如下:
让 Shared Worker 支持两种消息。一种是 post,Shared Worker 收到后会将该数据保存下来;另一种是 get,Shared Worker 收到该消息后会将保存的数据通过 postMessage 传给注册它的页面。也就是让页面通过 get 来主动获取(同步)最新消息。具体实现如下:
首先,我们会在页面中启动一个 Shared Worker,启动方式非常简单:
const sharedWorker = new SharedWorker('../util.shared.js', 'ctc');
然后,在该 Shared Worker 中支持 get 与 post 形式的消息:
let data = null;
self.addEventListener('connect', function(e) {
const port = e.ports[0];
port.addEventListener('message', function(event) {
if (event.data.get) {
data && port.postMessage(data);
} else {
data = event.data;
}
});
port.start();
});
之后,页面定时发送 get 指令的消息给 Shared Worker,轮询最新的消息数据,并在页面监听返回信息:
setInterval(function() {
sharedWorker.port.postMessage({ get: true });
}, 1000);
sharedWorker.port.addEventListener(
'message',
(e) => {
const data = e.data;
const text = '[receive] ' + data.msg + ' —— tab ' + data.from;
console.log('[Shared Worker] receive message:', text);
},
false
);
sharedWorker.port.start();
最后,当要跨页面通信时,只需给 Shared Worker postMessage 即可:
sharedWorker.port.postMessage(mydata);
注意,如果使用 addEventListener 来添加 Shared Worker 的消息监听,需要显式调用 MessagePort.start 方法,即上文中的 sharedWorker.port.start();如果使用 onmessage 绑定监听则不需要。
- ServiceWorkers 注册的 ServiceWorker 可以跨域响应 fetch 事件。
PS: serviceworker 乃是服务工作者,主要用于充当离线时的后端服务。
Service Worker 是一个可以长期运行在后台的 Worker,能够实现与页面的双向通信。多页面共享间的 Service Worker 可以共享,将 Service Worker 作为消息的处理中心(中央站)即可实现广播效果。
首先,需要在页面注册 Service Worker:
navigator.serviceWorker.register('../util.sw.js').then(function() {
console.log('Service Worker 注册成功');
});
其中 ../util.sw.js 是对应的 Service Worker 脚本。Service Worker 本身并不自动具备“广播通信”的功能,需要我们添加些代码,将其改造成消息中转站:
self.addEventListener('message', function(e) {
console.log('service worker receive message', e.data);
e.waitUntil(
self.clients.matchAll().then(function(clients) {
if (!clients || clients.length === 0) {
return;
}
clients.forEach(function(client) {
client.postMessage(e.data);
});
})
);
});
我们在 Service Worker 中监听了 message 事件,获取页面(从 Service Worker 的角度叫 client)发送的信息。然后通过 self.clients.matchAll() 获取当前注册了该 Service Worker 的所有页面,通过调用每个 client(即页面)的 postMessage 方法,向页面发送消息。这样就把从一处(某个 Tab 页面)收到的消息通知给了其他页面。
处理完 Service Worker,我们需要在页面监听 Service Worker 发送来的消息:
navigator.serviceWorker.addEventListener('message', function(e) {
const data = e.data;
const text = '[receive] ' + data.msg + ' —— tab ' + data.from;
console.log('[Service Worker] receive message:', text);
});
最后,当需要同步消息时,可以调用 Service Worker 的 postMessage 方法:
navigator.serviceWorker.controller.postMessage(mydata);
document.domain+iframe:设置iframe同document.domain,实现跨域通信。
在两个主域名相同的,二级域名下,可以通过设置 document.domain 为同一个主域名来进行数据传递
【window.opener】window.opener访问跨域opener。
跨域传cookie:document.cookie、cookie相关header。
document.cookie 跨域和 document.domain 处理方式一样
反向代理