跨域总结

6,339 阅读10分钟

1.什么是跨域

同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的重要安全机制。同源指:协议、域名、端口号必须一致。

同源策略控制了不同源之间的交互,例如在使用XMLHttpRequest 或 标签时则会受到同源策略的约束。这些交互通常分为三类:

  • 通常允许跨域写操作(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的跨域需要一个设置正确的Content-Type消息头。不同浏览器有不同的限制: IE, Firefox, Chrome, SafariOpera
  • <img>嵌入图片。支持的图片格式包括PNG,JPEG,GIF,BMP,SVG
  • <video> <audio>嵌入多媒体资源。
  • <object>, <embed> <applet>的插件。
  • @font-face引入的字体。一些浏览器允许跨域字体( cross-origin fonts),一些需要同源字体(same-origin fonts)。
  • <frame><iframe>载入的任何资源。站点可以使用X-Frame-Options消息头来阻止这种形式的跨域交互。

2.跨域的解决方案

jsonp

利用script标签不受跨域限制而形成的一种方案。

// index.html
function jsonp({url, param, cb}){
    return new Promise((resolve, reject)=>{
        let script = document.createElement('script')
        window[cb] = function(data){
            resolve(data);
            document.body.removeChild(script)
        }
        params = {...params, cb}
        let arrs = [];
        for(let key in params){
            arrs.push(`${key}=${params[key]}`)
        }
        script.src = `${url}?${arrs.join('&')}`
        document.body.appendChild(script)
    })
}
jsonp({
    url: 'http://localhost:3000/say',
    params: {wd: 'haoxl'},
    cb: 'show'
}).then(data=>{
    console.log(data)
})
//server.js
let express = require('express')
let app = express()
app.get('/say', function(req, res){
    let {wd,cb} = req.query
    console.log(wd)
    res.end(`${cb}('hello')`)
})
app.listen(3000)

缺点:只支持get请求,不支持post、put、delete等;不安全,容易受[xss][18]攻击。

cors

跨域资源共享标准新增了一组 HTTP 首部字段,允许服务器声明哪些源站有权限访问哪些资源。另外,规范要求,对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET 以外的 HTTP 请求,或者搭配某些 MIME 类型的 POST 请求),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。

<!--index.html-->
<body>
    Nice to meet you
</body> 
<script>
let xhr = new XMLHttpRequest;
// 强制前端设置必须带上请示头cookie
document.cookie = 'name=haoxl'
xhr.withCredentials = true
xhr.open('GET','http://localhost:4000/getData', true);
// 设置自定义请求头
xhr.setRequestHeader('name','haoxl')
xhr.onreadystatechange = function(){
    if(xhr.readyState === 4){
        if(xhr.status>=200 && xhr.status < 300 || xhr.status === 304){
            console.log(xhr.response);
            //获取后台传来的已改变name值的请示头
            console.log(xhr.getResponseHeader('name'));
        }
    }
}
xhr.send()
</script>
// server1.js
let express = require('express');
let app = express();
app.use(express.static(__dirname));
app.listen(3000)
// server2.js
let express = require('express');
let app = express();
let whiteList = ['http://localhost:3000']
app.use(function(req, res, next){
    let origin = req.headers.origin;
    if(whiteList.includes(origin)){
        //设置那个源可以访问我,参数为 * 时,允许任何人访问,但是不可以和 cookie 凭证的响应头共同使用
        res.setHeader('Access-Control-Allow-Origin', origin);
        //允许带有name的请求头的可以访问
        res.setHeader('Access-Control-Allow-Headers','name');
        // 设置哪些请求方法可访问
        res.setHeader('Access-Control-Allow-Methods', 'PUT');
        // 设置带cookie请求时允许访问
        res.setHeader('Access-Control-Allow-Credentials', true);
        // 后台改了前端传的name请示头后,再传回去时浏览器会认为不安全,所以要设置下面这个 
        res.setHeader('Access-Control-Expose-Headers','name');
        // 预检的存活时间-options请示
        res.setHeader('Access-Control-Max-Age',3)
        // 设置当预请求发来请求时,不做任何处理
        if(req.method === 'OPTIONS'){
            res.end();//OPTIONS请示不做任何处理
        }
    }
    next();
});

app.put('/getData', function(req, res){
    console.log(req.headers)
    res.setHeader('name','hello');
    res.end('hello world');
}

app.get('/getData', function(){
    res.end('Nice to meet you')
})
app.use(express.static(__dirname));
app.listen(3000)

postMessage

对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为https),端口号(443为https的默认值),以及主机 (两个页面的模数 Document.domain设置为相同的值) 时,这两个脚本才能相互通信。window.postMessage() 方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。

window.postMessage() 方法被调用时,会在所有页面脚本执行完毕之后(e.g., 在该方法之后设置的事件、之前设置的timeout 事件,etc.)向目标窗口派发一个MessageEvent消息。

语法:otherWindow.postMessage(message, targetOrigin, [transfer]);

  • otherWindow:指目标窗口,也就是给哪个window发消息,是 window.frames 属性的成员或者由 window.open 方法创建的窗口;
  • message 属性是要发送的消息,类型为 String、Object (IE8、9 不支持);
  • targetOrigin:属性来指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示无限制)或者一个URI。
  • transfer:是一串和message 同时传递的 Transferable 对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。

message属性有:

  • data 属性为 window.postMessage 的第一个参数;
  • origin 属性表示调用window.postMessage() 方法时调用页面的当前状态;
  • source 属性记录调用 window.postMessage() 方法的窗口信息;

案例:a.html 给b.html发消息

// a.html
<iframe src="http://localhost:4000/b.html" id="frame" onload="load()"></iframe>
<script>
function load(params){
    let frame = document.getElementById('frame');
    //获取iframe中的窗口,给iframe里嵌入的window发消息
    frame.contentWindow.postMessage('hello','http://localhost:4000')
    // 接收b.html回过来的消息
    window.onmessage = function(e){
        console.log(e.data)
    }
}
</script>
// b.html
<script>
//监听a.html发来的消息
window.onmessage = function(e){
    console.log(e.data)
    //给发送源回消息
    e.source.postMessage('nice to meet you',e.origin)
}
</script>

window.name

页面可能会因某些限制而改变他的源。脚本可以将 document.domain 的值设置为其当前域或其当前域的超级域。如果将其设置为其当前域的超级域,则较短的域将用于后续源检查。

a和b是同域的http://localhost:3000, c是独立的http://localhost:4000。 a通过iframe引入c,c把值放到window.name,再把它的src指向和a同域的b,然后在iframe所在的窗口中即可取出name的值。

// a.html
<iframe src="http://localhost:4000/c.html" onload="load()"></iframe>
<script>
let first = true
function load(){
    if(first){
        let iframe = document.getElementById('iframe');
        // 将a中的iframe再指向b
        iframe.src='http://localhost:3000/b.html';
        first = false;
    }else{
        //在b中则可得到c给窗口发的消息
        console.log(iframe.contentWindow.name);
    }
}
</script>
// c.html
<script>
window.name = 'nice to meet you'
</script>
//server.js
let express = require('express')
let app = express();
app.use(express.static(__dirname));
app.listen(4000);

location.hash

window.location 只读属性,返回一个Location对象,其中包含有关文档当前位置的信息。**window.location : 所有字母必须小写!**只要赋给 location 对象一个新值,文档就会使用新的 URL 加载,就好像使用修改后的 URL 调用了window.location.assign() 一样。需要注意的是,安全设置,如 CORS(跨域资源共享),可能会限制实际加载新页面。

案例:a、b同域,c单独一个域。a现在想访问c:a通过iframe给c传一个hash值,c收到hash值后再创建一个iframe把值通过hash传递给b,b将hash结果放到a的hash值中。

// a.html
<iframe src="http://localhost:4000/c.html#iloveyou"></iframe>
<script>
//接收b传来的hash值
window.onhashchange = function(){
    console.log(location.hash)
}
</script>

// c.html
//接收a传来的hash值
console.log(location.hash)
//创建一个iframe,把回复的消息传给b
let iframe = document.createElement('iframe');
iframe.src='http://localhost:3000/b.html#idontloveyou';
document.body.appendChild(iframe);
//b.html
<script>
//a.html引的c, c又引的b,所以b.parent.parent即是a
window.parent.parent.location.hash = location.hash
</script>

window.domain

window.domain:获取/设置当前文档的原始域部分。 案例:解决一级域与二级域之间通信。 模拟时需要创建两个不同域的域名用来测试,打开C:\Windows\System32\drivers\etc 该路径下找到 hosts 文件,在最下面创建一个一级域名和一个二级域名。改为:

127.0.0.1   www.haoxl.com
127.0.0.1   test.haoxl.com

预设a.html = www.haoxl.com, b.html = test.haoxl.com

// a.html
<iframe src="http://test.haoxl.com" onload="load()"></iframe>
<script>
function load(){
    //告诉页面它的主域名,要与b.html的主域名相同,这样才可在a中访问b的值
    document.domain = 'haoxl.com'
    function load(){
        // 在a页面引入b页面后,直接通过下面方式获取b中的值
        console.log(frame.contentWindow.a);
    }
}
</script>
// b.html
document.domain = 'haoxl.com'
var a = 'hello world'

websocket

WebSocket对象提供了用于创建和管理 WebSocket 连接,以及可以通过该连接发送和接收数据的 API。它是基于TCP的全双工通信,即服务端和客户端可以双向进行通讯,并且允许跨域通讯。基本协议有ws://(非加密)和wss://(加密)

//socket.html
let socket = new WebSocket('ws://localhost:3000');
// 给服务器发消息
socket.onopen = function() {
    socket.send('hello server')
}
// 接收服务器回复的消息
socket.onmessage = function(e) {
    console.log(e.data)
}

// server.js
let express = require('express');
let app = express();
let WebSocket = require('ws');//npm i ws
// 设置服务器域为3000端口
let wss = new WebSocket.Server({port:3000});
//连接
wss.on('connection', function(ws){
    // 接收客户端传来的消息
    ws.on('message', function(data){
        console.log(data);
        // 服务端回复消息
        ws.send('hello client')
    })
})

Nginx

Nginx (engine x) 是一个高性能的HTTP反向代理服务器,也是一个IMAP/POP3/SMTP服务器。

案例:在nginx根目录下创建json/a.json,里面随便放些内容

// client.html
let xhr = new XMLHttpRequest;
xhr.open('get', 'http://localhost/a.json', true);
xhr.onreadystatechange = function() {
    if(xhr.readyState === 4){
        if(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304 ){
            console.log(xhr.response);
        }
    }
}
// server.js
let express = require('express');
let app = express();
app.use(express.static(__dirname));
app.listen(3000);
// nginx.conf
location / {// 代表输入/时默认去打开root目录下的html文件夹
    root html;
    index index.html index.htm;
}
location ~.*\.json{//代表输入任意.json后去打开json文件夹
    root json;
    add_header "Access-Control-Allow-Origin" "*";
}

http-proxy-middleware

NodeJS 中间件 http-proxy-middleware 实现跨域代理,原理大致与 nginx 相同,都是通过启一个代理服务器,实现数据的转发,也可以通过设置 cookieDomainRewrite 参数修改响应头中 cookie 中的域名,实现当前域的 cookie 写入,方便接口登录认证。

  • vue框架:利用 node + webpack + webpack-dev-server 代理接口跨域。在开发环境下,由于 Vue 渲染服务和接口代理服务都是 webpack-dev-server,所以页面与代理接口之间不再跨域,无须设置 Headers 跨域信息了。
module.exports = {
    entry: {},
    module: {},
    ...
    devServer: {
        historyApiFallback: true,
        proxy: [{
            context: '/login',
            target: 'http://www.proxy2.com:8080',  // 代理跨域目标接口
            changeOrigin: true,
            secure: false,  // 当代理某些 https 服务报错时用
            cookieDomainRewrite: 'www.domain1.com'  // 可以为 false,表示不修改
        }],
        noInfo: true
    }
}
  • 非vue框架的跨域(2 次跨域)
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>nginx跨域</title>
</head>
<body>
    <script>
        var xhr = new XMLHttpRequest();

        // 前端开关:浏览器是否读写 cookie
        xhr.withCredentials = true;

        // 访问 http-proxy-middleware 代理服务器
        xhr.open('get', 'http://www.proxy1.com:3000/login?user=admin', true);
        xhr.send();
    </script>
</body>
</html>
// 中间代理服务器
var express = require("express");
var proxy = require("http-proxy-middleware");
var app = express();

app.use(
    "/",
    proxy({
        // 代理跨域目标接口
        target: "http://www.proxy2.com:8080",
        changeOrigin: true,

        // 修改响应头信息,实现跨域并允许带 cookie
        onProxyRes: function(proxyRes, req, res) {
            res.header("Access-Control-Allow-Origin", "http://www.proxy1.com");
            res.header("Access-Control-Allow-Credentials", "true");
        },

        // 修改响应信息中的 cookie 域名
        cookieDomainRewrite: "www.proxy1.com" // 可以为 false,表示不修改
    })
);

app.listen(3000);
// 服务器
var http = require("http");
var server = http.createServer();
var qs = require("querystring");

server.on("request", function(req, res) {
    var params = qs.parse(req.url.substring(2));

    // 向前台写 cookie
    res.writeHead(200, {
        "Set-Cookie": "l=a123456;Path=/;Domain=www.proxy2.com;HttpOnly" // HttpOnly:脚本无法读取
    });

    res.write(JSON.stringify(params));
    res.end();
});

server.listen("8080");