🟡关于芋圆 (域,源)你真的了解吗?

774 阅读17分钟

前端开发中,因为游览器的同源策略,总会遇到跨域的问题。

  • 那什么是跨域,什么是同源策略?

  • 如何规避同源策略?

  • JSONP 原理是什么?如何实现?

  • CORS是什么?如何实现?

  • 代理是什么?前端代理如何实现?

本篇文章将会对什么这些疑惑进行解答。

  1. 基本概念了解

  1. URL

URL (Universal Resource Locator) 统一资源定位符 。 是网上资源的家庭住址 。其指向的可能是一个 HTML文档 ,图片 ,或者视频等资源。

URL 由 协议(scheme) ,域名(domain) ,端口 (port),路径(path) ,参数( query ) , 锚点(anchor) 组成。

对于锚点,也可以叫 片段识别符(fragment identifier)

  1. 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不同源 ,端口不同
  1. SOP ( 同源策略)

Same-origin policy (同源策略) 是游览器的一种重要的安全策略!它限制了当前源的文档或脚本是如何与其他源的资源的交互。

简单来说,就是为了安全 ,游览器有一种策略,叫同源策略 。如果当前源的文档或脚本要与其他源的资源进行交互,就会受到游览器的限制。

比如 cookie ,storage 不能共享 ,不能执行其他源的脚本 ,不能获取其他源的 DOM 等

比如开发时常见的报错:

因为端口不同,导致的源不同,在请求时(资源交互)受到游览器限制,因此报错!

跨源网络访问限制

  1. XMLHttpRequest 和 Fetch 发送的请求 ,一般受同源策略的限制。 允许发送,不允许读取,不允许带上 cookie
  1. Img, video,script, link 等标签一般是允许跨域的
  1. Font 字体资源部分游览器受同源策略限制 (部分游览器可能不限制)

  2. CORS (跨域资源共享)

CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。

从实际来看,它就是通过配置请求头和响应头允许资源跨域共享。

因为需要配置请求头和响应头,因此 CORS 需要游览器和服务器的同时配合。 前端 发起的请求需要符合 CORS 标准 , 服务器也需要设置响应头,允许跨域请求。

  1. 游览器的跨域

游览器的跨域是由于同源策略的原因,使游览器不能读取其他源的资源,并且在请求时,游览器不会携带该域下的 cookie 。

跨域的导致原因就是因为不同源。也就是跨域不仅仅是因为域名的不同。

注意: 游览器是可以发送跨域请求 ,但是不能读取跨域请求回来的资源。游览器在跨域时不会带上 cookie

  1. 规避同源策略

同源策略的影响,主要体现在两个方面。

  • 跨域窗口之间的通信

比如 A 不能访问 B 页面的 DOM ,也不能执行 B页面的脚本 (A B 不同源)

A B 之间不共享 cookie 和 storage

  • 跨域请求

比如游览器发送出去的跨域请求不会带上本域名下的 cookie

当前域不能读取跨域请求的响应数据。

  1. 跨域窗口通信

  2. 同父域名

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.comccc.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 共享。

以上主要介绍的是,在跨域页面有相同的父域名的情况下可以使用的分发。

但如果没有相同的父域名,该如何解决跨域页面之间的通信呢?

  1. 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");
})
  1. 片段识别符(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
  1. 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>
  1. 跨域请求

同源策略对跨域的请求也有一定的限制 。

比如 跨域请求时 ,不会带上本源的 cookie 。

不会让当前源的脚本读取到其他源的请求响应

  1. 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>
  1. websocket

WebSocket是一种通信协议,使用ws://(非加密)和wss://(加密)作为协议前缀。该协议不实行同源政策,

因此只要服务器支持,就可以通过它进行跨源通信。

  1. 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-urlencodedmultipart/form-datatext/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-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma

如果想拿到其他字段,就必须在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)
  })
}
  1. 代理 (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. 问题

本篇文章,是内部分享会用来分享给师弟师妹们的,所以留了几个问题给他们

  1. 问题1:

虽然同源策略限制了 DOM 的访问。但是并不限制页面的加载和窗口样式的修改。

也就是我可以打开一个 iframe ,设置透明。并且在透明的 iframe 下面设置一些按钮。诱导你点击。但其实你点击到的是 iframe 页面。也就是我不能直接操作 iframe 里的 DOM ,但是我可以诱导你操作。

问题就是,如何防范这种情况?

  1. 问题2:

同源策略能否防范 CSRF 攻击

  1. 问题3:

游览器不会执行跨域脚本。但如果是 通过 script 标签 从其他域请求脚本的文件。

能否被执行?

为什么?

参考

浏览器的同源策略 - Web 安全 | MDN

跨源资源共享(CORS) - HTTP | MDN

CORS - 术语表 | MDN

Origin - 术语表 | MDN

浏览器同源政策及其规避方法 - 阮一峰的网络日志

跨域资源共享 CORS 详解 - 阮一峰的网络日志

跨源通信、跨域访问 | PHP 技术论坛