跨域解决方案

297 阅读6分钟

除了上篇文章讲到的跨域资源共享之外,还有几种方式可以解决浏览器跨域问题。

JSONP(JSON with Padding)

由于同源策略,一般来说位于 server1.com 的网页无法与 server2.com 的服务器沟通,而 script 标签是个特例,它可以直接跨源请求脚本,并执行。注意:JSONP 获取到的资料并不是 json 数据,而是一段 JavaScript 脚本。

利用 script 标签进行跨域请求,请求到的是一个脚本,而非 json 数据。但是我们需要的只是后端返回的数据。在 JSONP 的模式里,服务端返回的数据是被包裹在一个函数里面的,我们需要的数据被当做这个函数的参数,服务器返回的就是对这个函数的调用。也就是说,服务端会返回一段 JavaScript 脚本,当我们通过 script 标签获取这个脚本时,浏览器会自动执行这个脚本,并调用一个函数,把我们需要的数据当做参数。我们要做的是,定义一个函数,并且把函数名传给服务端,服务端那边返回一个脚本,在脚本里面执行这个函数,并且把我们需要的数据当做这个函数的参数。这样当通过 script 请求的脚本返回时,会自动执行这个函数,并且把我们需要的数据当做参数传递进来,那我们就可以在这个函数中拿到我们需要的数据了。

例子:

假设我们需要请求的 url 是:http://server2.example.com/RetrieveUser?UserId=1823 。我们需要将回调函数名称传给服务端:

<script type="text/javascript" src="http://server2.example.com/RetrieveUser?UserId=1823&jsonp=parseResponse"></script>

服务端会在响应浏览器之前将 JSON 数据填充到回调函数(parseResponse)中。浏览器得到的是一个脚本而非单纯的 JSON,这样浏览器就可以调用该函数并进行处理。

parseResponse({"Name": "小明", "Id" : 1823, "Rank": 7})

为了要引导一个JSONP调用(或者说,使用这个模式),你需要一个script 元素。因此,浏览器必须为每一个 JSONP 要求加(或是重用)一个新的、有所需 src 值的 script 元素到 HTML DOM 里—或者说是“注入”这个元素。浏览器运行该元素,抓取src里的 URL,并运行回传的 JavaScript。

也因为这样,JSONP 被称作是一种“让用户利用script元素注入的方式绕开同源策略”的方法。

在开发中可能遇到多个 JSONP 请求的回调函数名是相同的,这个时候我们可以自己封装一个 JSONP 函数。

// 封装 jsonp 函数用来获取数据
function jsonp({ url, params, callback }) {
  return new Promise((resolve, reject) => {
    let script = document.createElement('script');
    window[callback] = function(res) {
      resolve(res);
      document.body.removeChild(script);
    }
    let paramsArr = [];
    for(let key in params) {
      paramsArr.push(`${key}=${params[key]}`);
    }
    script.src = `${url}?${paramsArr.join('&')}&callback=${callback}`;
    document.body.appendChild(script);
  });
}

// 获取数据
jsonp({
  url: 'http://localhost:3000/say',
  params: {
    name: 'kz',
    age: '21',
  },
  callback: 'show',
}).then(res => console.log(res));

优点:兼容性较好,对旧的浏览器仍然有用。

缺点:仅支持 get 方法,具有安全隐患(注入脚本)。

限制:

  • 只支持 GET 方法。
  • 响应不会被解析,浏览器获取之后会直接执行。
  • 如果出错,无法获取错误信息。

document.domain 和 iframe

我们可以给 document.domain 属性赋值,不过是有限制的。只能设置为当前域名或者其父域名。当两个窗口的父域名相同时,我们可以通过设置它们的 document.domain 为一样的值来实现跨域。

document.domain = document.domain

假设当前页面的 origin 为 http://example.com:80,内部嵌入了一个 iframe,它的 origin 为 http://a.example.com:80,当尝试使用 document.domain 解决跨域问题时,我们会设置 iframe 的 document.domain 为 example.com。这时如果我们直接进行跨域访问,还是会被浏览器拦截的。因为浏览器在内部为原始 document.domain 存储了域名和端口。但是 JavaScript 中的 getter 和 setter 对端口一无所知。当执行 document.domain = 'example.com' 时,端口会被设置为 null,此时因为 iframe 端口与当前页面端口(80)不相同,还是会被当做跨域处理。所以我们需要设置当前页面的 document.domain = document.domain 或者 document.domain = 'example.com',即使它的值看起来没有变化,实际上浏览器将从内部更改它的端口为 null。这样就能进行通信了。

postMessage

window.postMessage() 可以安全的实现跨源通信。一个窗口获得对另一个窗口的引用(targetWindow),然后调用 targetWindow.postMessage() 方法分发一个 MessageEvent 消息。传递给 targetWindow.postMessage() 的参数将通过消息事件对象暴露给目标窗口。

otherWindow.postMessage(message, targetOrigin, [transfer])
  • otherWindow

其他窗口的一个引用,比如:iframe 的 contentWindow 属性、执行 window.open 返回的窗口对象、命名过或者数值索引的 window.frames

  • message

要发送到其他 window 的数据,它将会被结构化克隆算法序列化。这意味着你可以不受什么限制的将数据对象安全的传送给目标窗口而无需自己序列化。

  • targetOrigin

指定能接收到消息的窗口的源。可以是字符串 "*" 或者一个 URI。在发送消息时,如果目标窗口的源(协议、域名、端口)与targetOrigin 不符,就不会发送消息。如果你明确知道应该把消息发送到哪个窗口,请始终提供一个确定的 targetOrigin,而不是 '*',不提供确切的 targetOrigin 将可能导致数据会泄露到任意对数据感兴趣的恶意站点。

  • transfer,可选

是一串和 message 同时传递的 Transferable 对象。它的所有权将转移给消息的接收方,而发送方将不再保有所有权。

监听分发的 message:

// 添加监听事件,addEventListener 第三个参数默认 false,代表冒泡阶段触发
window.addEventListener("message", receiveMessage, false);
function receiveMessage(event) {
  // chrome 没有这个属性
  // var origin = event.origin || event.originalEvent.origin;
  var origin = event.origin;
  if(origin !== 'http://example.com:8080') {
    return;
  }
  // TODO something
}

message 的属性

  • data

从其他窗口传递过来的数据。

  • origin

消息发送方的 origin。请注意,这个origin不能保证是该窗口的当前或未来origin,因为 postMessage 被调用后可能被导航到不同的位置。

  • source

对发送消息的窗口对象的引用,可以用它来建立两个窗口之间的通信。

安全问题

  • 如果您不希望从其他网站接受任何消息,请不要添加任何 message 事件监听器。
  • 如果您确实希望从其他网站接收 message,请始终使用 origin 和 source 来验证发送发的身份。
  • 当您通过 postMessage 发送消息时,请指定确定的 origin 而不是 '*'

为什么用 targetWindow.postMessage 而不是 window.postMessage

postMessage 和 onClick 一样只能由本页面触发、本页面监听、本页面处理。只不过是可以在其他页面调用本页面的 postMessage。比如说:在 a.com 页面里面嵌入了一个 b.com 的 iframe,假设通过 document.getElementById("iframe").contentWindow 取到了对 iframe window 的引用,如果我直接通过 window.postMessage() 传递消息,即使指定源为 http://b.com ,b.com 这个页面也是监听不到消息的,此时只有 a.com 从才能监听到。必须要使用 targetWindow.postMessage() b.com 页面从才能监听到消息。

那么 postMessage() 第二个参数 targetOrigin 是干什么的呢?它是为了确保我所取到的窗口是我真正想传递消息的那个窗口。只有当我获取到对方窗口的引用,并且指定对方的 origin 时,消息才会发送。即:使用对方的窗口调用 postMessage() 并指定第二个参数为对方的源时(当然也可以设置为 "*",但不建议这样做),对方才能监听到消息。

WebSocket

WebSocket 是一种通信协议,使用 ws(非加密)或者 wss(加密)作为协议头。该协议不会受到同源策略的限制。只要服务器支持,就可以使用它进行跨源通信。当客户端通过 HTTP 协议建立 WebSocket 通信时,SOP(Same-Origin Policy)和 CORS(Cross-Origin Resource Standard)施加的跨域数据访问限制不适用于通过 WebSocket 传输的数据。SOP 只能控制对 HTTP 响应对象的访问,无法指示浏览器限制对通过 WebSocket 传输的数据的访问。

客户端请求:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

服务器响应:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

字段说明:

  • Connection:必须为 Upgrade,表示客户端希望升级协议。
  • UpgradeWebSocket,表示协议升级到 WebSocket
  • Origin:请求源,不可缺失,否则服务器需要回复 HTTP 403 状态码。

当我们尝试与不支持 WebSocket 协议的服务器建立连接:

image.png

正如预期的那样:浏览器确实阻止了对不支持 WebSocket 协议的服务器响应的访问。因为 WebSocket 握手发生在 HTTP 上,服务器返回了 HTTP 404 响应,但是没有 Access-Control-Allow-Origin 响应头。如果 Access-Control-Allow-Origin 的值设置正确,那么浏览器会将返回的 HTTP 响应暴露给应用程序。

当我们与支持 WebSocket 协议的服务器建立连接时:

image.png

服务器接收到跨域 HTTP Upgrade 请求,与浏览器建立连接后,不受浏览器 SOP 的限制。由于 SOP 不会限制 WebSocket 响应,每个支持 WebSocket 的服务器都应该验证 HTTP Upgrade 请求的源。

关键要点:

  • 默认情况下,当跨域请求发生时,浏览器会阻止访问响应对象。
  • CORS 可以使浏览器在发送跨域请求时授予我们访问响应数据的权限。
  • SOP 和 CORS 仅适用于 HTTP URI 方案。
  • 通过源验证限制对 WebSocket 服务器的访问。

node 中间件代理

原理:利用一个中间代理服务器,将我们的请求转发到跨域服务器上。同源策略是浏览器遵循的标准,服务器向服务器发送跨域请求无需遵循同源策略。我们只要在代理服务器上修改响应,让它遵循同源策略即可。

img

<!-- index.html(http://127.0.0.1:5500) -->
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script>
  $.ajax({
    url: 'http://localhost:3000',
    type: 'post',
    data: { name: 'xiamen', password: '123456' },
    contentType: 'application/json;charset=utf-8',
    success: function (result) {
      console.log(result) // {"title":"fontend","password":"123456"}    
    },
    error: function (msg) {
      console.log(msg)
    }
  })
</script>
// server1.js 代理服务器(http://localhost:3000)
const http = require('http')
// 第一步:接受客户端请求
const server = http.createServer((request, response) => {
  // 代理服务器,直接和浏览器直接交互,需要设置CORS 的首部字段
  response.writeHead(200, {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': '*',
    'Access-Control-Allow-Headers': 'Content-Type'
  })
  // 第二步:将请求转发给服务器
  const proxyRequest = http
    .request(
      {
        host: '127.0.0.1',
        port: 4000,
        url: '/',
        method: request.method,
        headers: request.headers
      },
      serverResponse => {
        // 第三步:收到服务器的响应
        var body = ''
        serverResponse.on('data', chunk => {
          body += chunk
        })
        serverResponse.on('end', () => {
          console.log('The data is ' + body)
          // 第四步:将响应结果转发给浏览器
          response.end(body)
        })
      }
    )
    .end()
})
server.listen(3000, () => {
  console.log('The proxyServer is running at http://localhost:3000')
})
// server2.js(http://localhost:4000)
const http = require('http')
const data = { title: 'fontend', password: '123456' }
const server = http.createServer((request, response) => {
  if (request.url === '/') {
    response.end(JSON.stringify(data))
  }
})
server.listen(4000, () => {
  console.log('The server is running at http://localhost:4000')
})

createProxyMiddleware

const apiProxy = createProxyMiddleware('/api', {
    // 目标源
    target: 'http://www.example.com',
    // 是否换源
    changeOrigin: true,
    // 代理 websocket
    ws: true,
    pathRewrite: {
        '^/api/old-path': '/api/new-api',  // 重写路径
        '^/api/remove/path': '/path',  // 移除路径
    },
    router: {
        'dev.localhost:3000': 'http://localhost:8000',
    }
})

Cookie

什么是第一方 Cookie 和第三方 Cookie?

与当前网站的域名(即浏览器地址栏中显示的内容)相匹配的 Cookie 称为第一方 Cookie

来自当前网站域名之外的 Cookie 称为第三方 Cookie

第一方和第三方 Cookie 不是一个绝对的定义,是相对于用户当时所处的上下文来决定的。同一个 Cookie 可以是第一方的也可以是第三方的,这处决于用户当时所在的网站。

Domain、Path 和 SameSite 的区别

Domain 和 Path:定义了 Cookie 的作用域,即允许 Cookie 发送给哪些 URL。如果不指定 Domain,默认为 origin,不包含子域名。但是如果指定了 Domain,则一般包含其子域名。例如,设置 Domain=mozilla.org,则 Cookie 也会包含在子域名 developer.mozilla.org 中。

SameSite:它允许您声明该 Cookie 是否仅限于第一方或者同一站点上下文。

  • Strict,只有当 Cookie 的站点与浏览器地址栏 URL 显示的站点相匹配时,才会发送该 Cookie。
  • Lax,默认值,Cookies 允许与顶级导航一起发送,并将与第三方网站发起的GET请求一起发送。
  • None,SameSite=None ==需与 Secure 属性配对==。

首先区分两个概念:请求资源的 URL 和请求的来源(origin)。比如访问一个网站 origin.com,在这个网站中向 destination.com 发送一个 HTTP 请求,在这个场景中,origin.com 是请求的来源,而 destination.com 是请求资源的 URL。

SameSite 用来控制当请求资源的 URL 与请求的来源(当前浏览器地址栏 URL)不同时,是否携带 Cookie;而当 Domain 和 Path 与请求资源的 URL 相匹配的时候携带 Cookie,不匹配则不携带 Cookie,这就是它们的区别

例如,当 domain 是 a.com,当发送请求到 b.com 时,无论该请求是否从 b.com 网站发送,都不会携带该 Cookie。同时,如果 SameSite 属性值为 strict,只要你不处于 b.com 网站上,对 b.com 的 HTTP 请求就不会携带该 Cookie。

SameSite: Lax 在什么时候会携带 Cookie

跨站点发送 Lax Cookie 必须满足以下两个条件:

  1. 请求必须是顶级导航,你可以视为等同于 URL 发生更改的情况。例如,用户点击链接跳转到另一个站点。
  2. 请求方法必须是安全的,例如,get、head 方法,但不是 post、patch、put、delete 方法。

例如:

  • 假设用户在 site-a.com 点击链接跳转到 site-b.com,这是一个跨站点请求,满足 Lax 条件,是一个顶级导航并且是一个 get 请求,因此 Lax Cookie 被发送到 site-b.com。但是不会发送 Strict Cookie。
  • 用户在 site-a.com 上面,并且嵌入了一个 iframe 加载 site-b.com,这也是一个跨站点请求,但它不是顶级导航(因为用户仍在 site-a.com 上面,即加载 iframe 时 URL 栏不会发生变化)。因此,Lax Cookie 不会发送到 site-b.com。不单单是 iframesourceimg 或者 AJAX 请求也是如此。
  • 用户在 site-a.com 上面向 site-b.com 提交了一个表单,这同样是一个跨站点请求,但是请求方法是不安全的(POST),不符合 Lax Cookie 跨站点的标准,因此不会携带 Lax Cookie 到 site-b.com 页面。

浏览器决定是否携带 Cookie 的步骤:

SameSite ----> Domain ----> Path ----> other attributes

image.png