Skip to content

同源

同源策略是一个重要的安全策略,它用于限制一个文档或者它加载的脚本是否能与另一个源的资源 进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。

如果两个 URL 的协议、域名、端口号都相同的话,则这两个 URL 是同源,只要有一个不一样, 就是跨域。ajax 请求时,为了安全,浏览器要求当客户端与服务器必须同源,否则会抛出跨域的 错误,而加载 img、link、script、iframe 等不受同源策略限制。

IE 中的特例:两个相互之间高度互信的域名,如公司域名,则不受同源策略限制。另外 IE 未将 端口号纳入到同源策略的检查中。

sh
http://store.company.com/dir/page.html

# 同源 只有路径不同
http://store.company.com/dir2/other.html	
# 同源 只有路径不同
http://store.company.com/dir/inner/another.html	
# 失败 协议不同
https://store.company.com/secure.html	
# 失败 端口不同 http 默认端口是 80
http://store.company.com:81/dir/etc.html	
# 失败 三级域名不同
http://company.com/dir2/other.html	
# 失败 三级域名不同
http://news.company.com/dir/other.html	
# 失败
http://192.168.1.1/dir/other.html

CORS(跨域资源共享)

CORS 需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE 浏览器不能低于 IE10。

js
const whiteSet = new Set(['http://127.0.0.1:56254'])

const cors = app => {
  app.use(async (ctx, next) => {
    const { req, res } = ctx
    const { origin } = req.headers

    if (!whiteSet.has(origin)) {
      ctx.status = 403
      ctx.body = { error: 'Origin not allowed' }
      return
    }

    // 该字段是必须的。它的值要么是请求时 Origin 字段的值,要么是一个 *,表示接受任意
    // 域名的请求,这个字段只能是 * 或者一个具体的值,无法通过 , 分隔设置多个值,
    // 注意 http://localhost 和 http://127.0.0.1 不是同一个源,
    // 这个响应头必须同时在预检请求和正式请求中设置,如果正式请求中没有设置,
    // 即使预检请求通过,依然会报跨域错误
    res.setHeader('Access-Control-Allow-Origin', origin)
    // 该字段可选。它的值是一个字符串,表示是否允许发送 Cookie。默认情况下,
    // Cookie 不包括在CORS 请求之中。设为 'true',即表示允许发送 Cookie
    // 这个值也只能设为 'true',如果服务器禁止浏览器发送 Cookie,删除该字段即可
    // 只有当 Access-Control-Allow-Origin 不是 "*" 时,才能返回该字段(即允许 
    // credentials 时必须指定具体域)。
    // 允许后,前端还必须设置 withCredentials: true 才能携带 cookie。
    // 这个响应头必须同时在预检请求和正式请求中设置,如果正式请求中没有设置,
    // 即使预检请求通过,依然会报跨域错误
    res.setHeader('Access-Control-Allow-Credentials', 'true')

    if (req.method === 'OPTIONS') {
      // 该字段可选,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。
      // 注意,响应头中返回的是所有支持的方法,而不单是浏览器请求的那个方法,
      // 这是为了避免多次"预检"请求。对于 GET, POST, HEAD, OPTIONS 方法是无法拦截的,
      // 即使不写上,预检请求通过后,正式请求使用 GET, POST, HEAD 中的任意一个
      // 都不会被拦截
      // 只需要在预检请求中设置
      res.setHeader('Access-Control-Allow-Methods', 'PUT, DELETE')

      // 该字段可选,它也是一个逗号分隔的字符串,表明服务器支持的所有特殊请求头信息字段,
      // 只需要在预检请求中设置
      res.setHeader("Access-Control-Allow-Headers", 'Authorization, Content-Type, X-Custom-Header')

      // 该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是 20 天
      // (1728000秒),即允许缓存该条回应1728000秒(即20天),在此期间,不用发出
      // 另一条预检请求。
      // 值为 -1, 表示禁用缓存,每次请求前都要发送 OPTIONS 预检请求。
      // 注意 Access-Control-Max-Age 设置针对完全一样的 url,当 url 包含参数时,
      // 其中一个 url 的 Access-Control-Max-Age 设置对另一个 url 没有效果。
      // 只需要在预检请求中设置
      res.setHeader('Access-Control-Max-Age', '3600')

      return ctx.status = 204
    }

    // 该字段可选。CORS 请求时,XMLHttpRequest 对象的 getResponseHeader() 方法
    // 只能拿到 6 个基本字段:Expires、Cache-Control、Last-Modified、
    // Content-Type、Content-Language、Pragma(Pragma: public,作用是告诉浏览器,
    // 响应内容可以被公开缓存)。这里没有 Etag,但是不妨碍浏览器获取,因为只是限制
    // xhr.getResponseHeader() 能拿到的响应头,如果想拿到其他字段,就必须在这里面
    // 指定
    // 只需要在正式请求中设置
    res.setHeader('Access-Control-Expose-Headers', 'Foo, Bar')
    // res.setHeader('Foo', '123')

    await next()
  })
}

module.exports = cors

简单请求

如果同时满足下面两个条件,就是简单请求。

  • 请求方法是以下三种方法之一:

    • HEAD

    • GET

    • POST

  • HTTP的头信息不超出以下几种字段

    • Accept

    • Accept-Language

    • Content-Language

    • Last-Event-ID

    • Content-Type:只限于以下三个值

      • application/x-www-form-urlencoded

      • multipart/form-data

      • text/plain

  • 一个携带 Cookie 的简单请求依然是简单请求

简单请求的基本流程

对于简单请求,浏览器认为足够安全,直接发出 CORS 请求,请求头中新增一个 Origin 字段用来说明本次请求来自哪个源(协议 + 域名 + 端口号)

sh
origin http://127.0.0.1:56254

服务器会返回一个正常的 HTTP 响应,浏览器发现响应头中没有 Access-Control-Allow-Origin 字段或者有该字段,但是值和 Origin 不一致,浏览器会触发正式请求的 XMLHttpRequest 的 onerror 回调。注意,预检请求不会触发 xhr 回调,甚至预检请求不通过时,你可以给预检请求返回 200,但是正式请求依旧会报错

非简单请求的基本流程

非简单请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为预检请求(不会携带 Cookie),相比简单请求会多出 Access-Control-Request-Method 字段

sh
OPTIONS / HTTP/1.1
# 如果有设置特殊请求头,则会请求头中多出这个字段
Access-Control-Request-Headers: my-header
Access-Control-Request-Method: GET
Origin: http://127.0.0.1:56254

浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及是否使用了允许的 HTTP 请求方法和请求头字段,是否允许 cookie,只有得到肯定答复,预检请求才会通过,预见请求通过后浏览器才会发出正式的 XMLHttpRequest 请求,否则就报错,预见请求响应头中没有任何字段,被XMLHttpRequest 对象的 onerror 回调函数捕获。控制台会打印出如下的报错信息。

withCredentials

javascript
const xhr = new XMLHttpRequest()
xhr.withCredentials = true

如果省略 withCredentials 设置,有的浏览器还是会一起发送 Cookie。可以显式关闭 withCredentials。

javascript
xhr.withCredentials = false

正向代理

原理:利用 http-proxy-middleware 这个中间件进行正向代理,将请求转发给其他的服务器。 webpack-dev-server 会自动开启一个代理服务器,当本地发送请求的时候,代理服务器会接受 这个情求,并将这个请求转发给目标服务器,目标服务器返回数据后,代理服务器又会将数据返回 给浏览器,由于代理服务器和客户端是同源的,不会存在跨域的问题。

javascript
devServer: {
  // 服务器代理,解决开发环境下跨域问题。
  proxy: {
    '/api': {
      target: 'http://localhost:3000',
      pathRewrite: {'^/api': ''},
      ws: true, // 用于支持 websocket
      changeOrigin: true // 控制请求头中的 host 值
    },
    '/api2': {
      target: 'http://localhost:3001',
      pathRewrite: {'^/api2': ''},
    }
  }
}

反向代理

如 Nginx 反向代理,前端请求都是通过 Nginx 转发到 Node 服务器(如 localhost:8080),对于浏览器来说前端和后端是同源的,而服务器之间不存在跨域,因此就不需要在 Node 服务器上单独设置 CORS 了。

JSONP

原理: 基于 script 不受同源策略限制

客户端

html
<script>
  // 回调函数要在 jsonp 请求之前定义好,且不能定义在 jsonp 的那个 script 中,
  // 因为 script 如果设置了 src 那么里面的代码是不会被解析的。
  function cb (data) {
    console.log(data)
  }
</script>

<script src='http://127.0.0.1:3000/getData?callback=cb'></script>

服务器

js
// 服务器:
// 1.准备数据 data = {...}
// 2.给客户端返回数据 'func(' + JSON.stringify(data) + ')' 浏览器拿到这段字符串后,
// 会当作js代码解析执行
app.get('/getData', (req, res, next) => {
  const { callback } = req.query
  const data = {
    foo: 'foo',
    bar: 'bar'
  }

  res.send(`${ callback }(${ JSON.stringify(data) })`)
})

优点

简单,兼容性好。

缺点

  • 只能处理 GET 请求

  • 每个请求在后台都要做处理,很麻烦。

  • 不安全,可能遭受 xss 攻击。

并发的 jsonp 如何区分数据返回后的回调函数

  • 每个请求都使用不同的回调,但是会带来服务器端的性能问题,因为服务器端可以通过 cdn 对相同请求进行缓存,如果回调不同会导致缓存失效,流向直接冲向源站。

  • 如果使用相同的回调, 发送请求前判断是否有相同的 jsonp 请求正在发送,如果发生冲突 则取消本次请求,等上一个请求发送完再重新请求,也就是把并行转为串行执行。

jsonp 怎么处理多个组件需要调用的方法名相同,产生了命名冲突

js
function jsonp (url, params, callback) {
  return new Promise((resolve, reject) => {
    // url拼接处理
    let keys = Object.keys(params),
      i = 0,
      length = keys.length,
      key,
      val,
      parmasStr = '?'

    for (; i < length; i++) {
      key = keys[i]
      val = params[key]
      parmasStr += `${key}=${val}&`
    }

    parmasStr += `callback=${callback}`
    url += parmasStr

    // 创建script标签
    let script = document.createElement('script')
    script.src = url

    // 添加回调函数, 服务器返回数据时会被执行
    window[callback] = function (data) {
      resolve(data)
      // 移除标签
      document.head.removeChild(script)
    }

    // 添加标签
    document.head.appendChild(script)
  })
}

jsonp('http://localhost:3000/getData', {name: 'Tom'}, 'getData')
  .then(val => {
    console.log(val)
  })

postMessage + iframe

html
  // 3000/index.html

  <iframe id='iframe' src='http://127.0.0.1:3001' frameborder='0' style="display: none;"></iframe>

  <script>
    // 向3001/index.html发送信息
    iframe.onload = function () {
      iframe.contentWindow.postMessage('message', 'http://127.0.0.1:3001')
    }

    // 接收信息
    window.onmessage = function (ev) {
      console.log(ev.data)
    }
  </script>

  // 3001/index.html

  <script>
    // 接收信息
    window.onmessage = function (ev) {
      // ev.source代表3000/index.html
      // 接收到后给3000发送信息
      ev.source.postMessage(ev.data + '@@@', ev.origin)
    }
  </script>

WebSocket

客户端

js
const socket = new WebSocket('ws://127.0.0.1:3000')

socket.addEventListener('open', () => {
  console.log('connected')
})

socket.addEventListener('message', e => {
  console.log(e.data)
})

sendMsg.addEventListener('click', () => {
  socket.send('')
})

服务器

js
const { WebSocketServer } = require('ws')

const server = new WebSocketServer({ port: 3000 })

server.on('connection', socket => {
  socket.on('message', message => {
    socket.send('foo')
  })
})

document.domain + iframe 只能实现同一个主域.不同子域之间的操作

html
// 父页面A http://www.demo.cn/A.html
<iframe src="http://school.demo.cn/B.html" frameborder="0"></iframe>

<script>
  document.domain = 'demo.cn'
  var user = 'admin'
</script>

// 子页面B
<script>
  document.domain = 'demo.cn'
  alert(window.parent.user) // window.parent就是父页面A
</script>

window.name + iframe

html
// 3000
<iframe id="iframe" src="http://127.0.0.1:3001" frameborder="0" style="display: none;"></iframe>

<script>
  // onload触发两次,第二次才得到信息
  let count = 0
  iframe.onload = function () {
    if (count === 0) {
      // 需要先把地址重新指向到同源中(window.name要求指向同源)
      // proxy.html中只要不写window.name拿到的就是3001的window.name
      iframe.src = 'http://127.0.0.1:3000/proxy.html' 
      count++
      return
    }
    console.log(iframe.contentWindow.name)
  }
</script>

// 3001
<script>
  // 3001需要返回给3000的信息都在window.name中存储着
  window.name = 'xxx'
</script>

localtion.hash + iframe

A和C同源 A和B不同源

html
// A.html
<iframe id="iframe" src="http://127.0.0.1:3001/B.html" style="display: none;" frameborder="0"></iframe>

<script>
  // 向B.html传hash值
  let count = 0
  iframe.onload = function () {
    if (count === 0) {
      iframe.src = 'http://127.0.0.1:3001/B.html#msg=message'
      count++
      return
    }
  }
  // 开放给同源C.html的回调方法
  function func (res) {
    console.log(res)
  }
</script>

// B.html
<iframe id="iframe" src="http://127.0.0.1:3000/C.html" style="display: none;" frameborder="0"></iframe>
<script>
  // 监听A传过来的hash改变,再传给C
  window.onhashchange = function () {
    iframe.src = 'http://127.0.0.1:3000/C.html' + location.hash
  }
</script>

// C.html
<script>
  // 监听B传过来的hash值
  window.onhashchange = function () {
    // 再通过操作同源A的js回调,将结果传回
    window.parent.parent.func(location.hash)
  }
</script>