技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTNET

九种跨域方式实现原理(完整版)

所属馆藏
NET Protocol
文件格式
Markdown
原始路径
NET Protocol/14-九种跨域方式实现原理(完整版)
本文目录4 个章节
  1. 一、什么是跨域?
  2. 二、跨域解决方案
  3. 三、总结
  4. 参考资料

九种跨域方式实现原理(完整版)

Category(分类): NET Protocol Status: 优

前后端数据交互经常会遇到跨域问题。本文先介绍什么是跨域和同源策略,再梳理九种常见的跨域通信方式。

本文完整源代码可参考GitHub 博客。文中的部分方案属于历史兼容方案,新项目应优先使用 CORS、同源反向代理或 postMessage

一、什么是跨域?

1. 什么是同源策略?

同源策略(Same-Origin Policy,SOP)是浏览器的重要安全机制,用于限制一个源的脚本读取另一个源的资源。一个源由以下三部分共同决定:

协议(scheme) + 主机(host) + 有效端口(port)

例如,下面两个地址不是同源:

http://example.com:80
https://example.com:443

因为协议不同。下面两个地址也不是同源:

http://a.example.com
http://b.example.com

因为主机名不同。即使两个主机名最终解析到了同一个 IP 地址,浏览器仍然按照源进行判断,而不会因为 IP 相同就认为同源。

http://example.comhttp://example.com:80 使用默认端口时通常可以视为同源;端口是 Origin 的组成部分,但 Cookie 的域匹配规则不包含端口,因此 Cookie 与 LocalStorage 的隔离规则并不完全相同。

同源策略示意图

2. 同源策略限制了什么?

同源策略的限制对象不是所有网络行为,而主要是跨源脚本的读取和访问权限,常见包括:

  • LocalStorage、IndexedDB 等按源隔离的存储;
  • 跨源窗口或 iframe 的 DOM 访问;
  • Fetch、XMLHttpRequest 读取跨源响应的权限;
  • Canvas 读取未经 CORS 授权的跨源图片像素的权限。

Cookie 的规则比较特殊:Cookie 主要根据域名和路径匹配,不按端口隔离,并且还会受到 SecureHttpOnlySameSite 等属性影响。

浏览器允许很多元素加载跨源资源,例如:

<img src="https://example.com/a.png" alt="">
<link rel="stylesheet" href="https://example.com/a.css">
<script src="https://example.com/a.js"></script>

但“可以加载”不等于“脚本可以读取全部内容”:跨源图片不能直接被脚本读取像素,跨源 CSS 的 CSSOM 访问受限,跨源脚本虽然可以执行,但会带来严重的脚本注入风险,因此不能把 <script> 当作安全的数据读取接口。

同源策略并不能单独防止 XSS 或 CSRF:XSS 是恶意脚本在某个源内执行,CSRF 则利用浏览器自动携带凭据发起请求。实际应用需要配合输出编码、CSP、CSRF Token、SameSite Cookie 等安全措施。

3. 常见跨域场景

当两个 URL 的协议、主机或有效端口中任意一项不同时,通常就属于不同源。常见场景如下:

页面源请求或资源源是否同源原因
http://localhost:3000http://localhost:4000端口不同
http://a.example.comhttp://b.example.com主机名不同
http://example.comhttps://example.com协议不同
http://example.comhttp://example.com/api路径不同不影响源

常见跨域场景示意图

需要特别说明:

  1. 不能只根据域名对应的 IP 地址判断同源;
  2. “URL 的首部”不是规范术语,更准确的说法是比较 URL 的 scheme、host 和有效 port;
  3. 通过浏览器脚本不能自行放宽协议或端口限制,通常需要服务端 CORS、同源代理或跨窗口消息通信;
  4. 跨域请求不一定代表请求没有发出去。

4. 跨域请求到底发出了吗?

对于简单 CORS 请求,浏览器通常会把请求发送到服务器,但如果响应中没有允许当前 Origin 的 CORS 响应头,浏览器会阻止脚本读取响应。

对于需要预检的请求,浏览器会先发送 OPTIONS 预检请求。如果预检失败,真正的请求可能不会发送。表单提交、图片加载等行为也可能发出跨源请求,但页面脚本通常不能读取对方响应;因此仅依靠同源策略不能完全阻止 CSRF。

二、跨域解决方案

1. JSONP

1.1 JSONP 原理

JSONP(JSON with Padding)利用 <script> 元素可以加载跨源脚本的特性,让服务端返回一段 JavaScript,而不是纯 JSON:

show({ message: 'hello' })

浏览器加载并执行这段脚本后,就会调用页面中预先定义的 show 函数。JSONP 必须得到目标服务器的配合,不能访问任意不支持 JSONP 的网站。

1.2 JSONP 与 AJAX 的区别

AJAX 通常指通过 XMLHttpRequest 或 Fetch 异步请求数据。它本身遵循浏览器的同源策略,但可以通过服务端 CORS 获得跨源读取权限。

JSONP 不是 AJAX 的一种跨域版本,而是利用脚本加载机制完成跨源数据传递。JSONP 只能发起 GET 请求,不能安全地实现 POST、PUT、DELETE 等方法,也不适合传输敏感数据。

1.3 JSONP 的优缺点

优点:

  • 实现简单;
  • 对较老的浏览器兼容性较好;
  • 不需要 XMLHttpRequest 的 CORS 支持。

缺点:

  • 只能使用 GET;
  • 响应是可执行 JavaScript,服务端或回调参数被利用时可能造成 XSS;
  • 没有标准的跨域错误处理机制;
  • 受 URL 长度和缓存策略影响;
  • 目标服务必须专门支持 JSONP。

1.4 JSONP 的实现流程

  1. 页面声明回调函数;
  2. 创建 <script> 元素,并将接口地址设置为 src
  3. 通过查询参数把回调函数名传给服务器;
  4. 服务器校验回调名后返回 callback(data)
  5. 浏览器执行脚本并调用回调函数;
  6. 请求完成后删除脚本和全局回调,避免污染页面。

示例客户端代码:

function jsonp({ url, params = {}, callback = 'jsonpCallback', timeout = 5000 }) {
  return new Promise((resolve, reject) => {
    if (!/^[A-Za-z_$][\w$]*$/.test(callback)) {
      reject(new Error('非法的 JSONP 回调名'))
      return
    }

    const script = document.createElement('script')
    const query = new URLSearchParams({ ...params, callback })
    let timer

    function cleanup() {
      clearTimeout(timer)
      delete window[callback]
      script.remove()
    }

    window[callback] = data => {
      cleanup()
      resolve(data)
    }

    script.onerror = () => {
      cleanup()
      reject(new Error('JSONP 请求失败'))
    }

    script.src = `${url}${url.includes('?') ? '&' : '?'}${query}`
    document.head.appendChild(script)

    timer = setTimeout(() => {
      cleanup()
      reject(new Error('JSONP 请求超时'))
    }, timeout)
  })
}

jsonp({
  url: 'http://localhost:3000/say',
  params: { wd: 'Iloveyou' },
  callback: 'show'
}).then(data => {
  console.log(data)
})

上面的代码会请求:

http://localhost:3000/say?wd=Iloveyou&callback=show

服务端示例:

const express = require('express')
const app = express()

app.get('/say', (req, res) => {
  const { wd, callback } = req.query

  // 生产环境必须严格校验回调函数名,不能直接拼接任意用户输入
  if (!/^[A-Za-z_$][\w$]*$/.test(callback || '')) {
    res.status(400).send('invalid callback')
    return
  }

  console.log(wd)
  res.type('js').send(`${callback}(${JSON.stringify({ message: '我不爱你' })})`)
})

app.listen(3000)

1.5 jQuery 的 JSONP

$.ajax({
  url: 'http://crossdomain.com/jsonServerResponse',
  dataType: 'jsonp',
  type: 'GET',
  jsonpCallback: 'show',
  jsonp: 'callback',
  success(data) {
    console.log(data)
  },
  error() {
    console.error('JSONP 请求失败')
  }
})

JSONP 通过动态脚本加载实现,通常是异步 GET 请求。所谓“清除缓存”通常是追加时间戳查询参数进行缓存绕过,并不是清空浏览器缓存。新项目优先使用 CORS 或同源代理。

2. CORS

CORS(Cross-Origin Resource Sharing,跨源资源共享)需要浏览器和服务器共同支持。浏览器负责按照 CORS 规则发送预检和检查响应,服务器负责返回允许的 Origin、方法、请求头和凭据策略。

服务端可以返回:

Access-Control-Allow-Origin: https://app.example.com

表示允许指定源读取响应。* 可以允许任意源进行非凭据跨源读取,但不能和 Access-Control-Allow-Credentials: true 一起使用。

CORS 不是“服务器允许所有请求”的开关,而是浏览器是否允许当前页面脚本读取跨源响应的机制。服务器仍然需要进行身份认证和权限校验。

2.1 简单请求

“简单请求”是历史上常用的称呼。通常需要同时满足:

  • 方法是 GETHEADPOST
  • Content-Type 是以下之一:
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain
  • 手动设置的请求头满足 CORS safelisted request-header 的限制;
  • XMLHttpRequest 的上传对象没有注册会触发预检的事件监听器。

简单请求通常可以直接发送,但服务端仍必须返回正确的 Access-Control-Allow-Origin,浏览器才会允许脚本读取响应。

2.2 需要预检的请求

不满足简单请求条件的请求通常称为需要预检的请求。浏览器会先发送 OPTIONS 请求,并携带:

  • Origin
  • Access-Control-Request-Method
  • Access-Control-Request-Headers

服务器需要通过 Access-Control-Allow-MethodsAccess-Control-Allow-HeadersAccess-Control-Allow-Origin 等响应头明确允许该请求。预检通过后,浏览器才会发送真正的请求。

例如,使用 PUT 方法并携带自定义 name 请求头时,服务端可以这样配置:

const express = require('express')
const app = express()

const allowList = new Set(['http://localhost:3000'])

app.use((req, res, next) => {
  const origin = req.headers.origin

  if (origin && allowList.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin)
    res.setHeader('Vary', 'Origin')
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, name')
    res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS')
    res.setHeader('Access-Control-Allow-Credentials', 'true')
    res.setHeader('Access-Control-Expose-Headers', 'name')
    res.setHeader('Access-Control-Max-Age', '6')
  }

  if (req.method === 'OPTIONS') {
    res.sendStatus(204)
    return
  }

  next()
})

app.put('/getData', (req, res) => {
  res.setHeader('name', 'jw')
  res.send('我不爱你')
})

app.listen(4000)

2.3 带凭据的 CORS 请求

客户端可以使用 withCredentials 或 Fetch 的 credentials: 'include' 请求携带 Cookie:

const xhr = new XMLHttpRequest()
xhr.withCredentials = true
xhr.open('PUT', 'http://localhost:4000/getData', true)
xhr.setRequestHeader('name', 'xiamen')
xhr.onreadystatechange = () => {
  if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 300) {
    console.log(xhr.responseText)
    // name 不是 CORS safelisted response header,服务端需要设置 Expose-Headers
    console.log(xhr.getResponseHeader('name'))
  }
}
xhr.send()

服务端必须返回具体的 Access-Control-Allow-Origin,不能返回 *,并且需要返回字符串形式的:

Access-Control-Allow-Credentials: true

Cookie 是否发送还要受到域名、路径、SameSiteSecure 和过期时间等规则影响。localhost:3000localhost:4000 虽然不同源,但 Cookie 的域匹配不包含端口,因此不能简单写成“Cookie 不能跨域”。

3. postMessage

postMessage 是 Window 对象提供的跨文档消息通信 API,不是 XMLHttpRequest Level 2 的 API。它可以用于:

  • 页面与新窗口之间通信;
  • 多窗口之间通信;
  • 页面与 iframe 之间通信;
  • 以上场景中的跨源通信。

基本形式:

otherWindow.postMessage(message, targetOrigin, transfer)
  • message:通过结构化克隆算法传递的数据;
  • targetOrigin:允许接收消息的目标源,应尽量使用明确的 Origin,不要随意使用 *
  • transfer:可选的 Transferable 对象数组,转移所有权后发送方可能不再持有这些对象。

发送方示例:

<iframe src="http://localhost:4000/b.html" id="frame"></iframe>
<script>
  const frame = document.getElementById('frame')
  const targetOrigin = 'http://localhost:4000'

  window.addEventListener('message', event => {
    if (event.origin !== targetOrigin || event.source !== frame.contentWindow) {
      return
    }
    console.log(event.data)
  })

  frame.addEventListener('load', () => {
    frame.contentWindow.postMessage('我爱你', targetOrigin)
  })
</script>

接收方示例:

const allowedOrigin = 'http://localhost:3000'

window.addEventListener('message', event => {
  if (event.origin !== allowedOrigin || !event.source) {
    return
  }

  console.log(event.data)
  event.source.postMessage('我不爱你', allowedOrigin)
})

接收消息时必须验证 event.origin,必要时还要验证 event.source,否则任意窗口都可能发送伪造消息。

4. WebSocket

WebSocket 是一种全双工通信协议和浏览器 API。它通常先通过 HTTP Upgrade 完成握手,握手成功后切换为 WebSocket 帧通信,不再使用普通 HTTP 请求-响应模式。

WebSocket 跨源连接不依赖普通 CORS 响应头,但服务端应检查握手中的 Origin,并进行身份认证和权限控制。生产环境通常使用 wss://,即运行在 TLS 之上的 WebSocket。

客户端示例:

<script>
  const socket = new WebSocket('ws://localhost:3000')

  socket.onopen = () => {
    socket.send('我爱你')
  }

  socket.onmessage = event => {
    console.log(event.data)
  }
</script>

服务端示例:

const WebSocket = require('ws')
const wss = new WebSocket.Server({ port: 3000 })
const allowedOrigin = 'http://localhost:5500'

wss.on('connection', (socket, request) => {
  if (request.headers.origin !== allowedOrigin) {
    socket.close(1008, 'origin not allowed')
    return
  }

  socket.on('message', data => {
    console.log(data.toString())
    socket.send('我不爱你')
  })
})

Socket.IO 不只是原生 WebSocket 的简单封装,它还包含连接管理、心跳、重连以及可能的长轮询降级机制,客户端和服务端需要使用兼容版本。

5. Node 中间件代理

实现原理是:浏览器只请求自己的同源代理服务器,代理服务器再以服务端身份请求目标服务器。服务器到服务器的请求不受浏览器同源策略限制,因此这不是严格意义上的“两次浏览器跨域”。

代理服务器通常需要:

  1. 接收客户端请求;
  2. 将请求方法、路径和请求体转发给目标服务器;
  3. 接收目标服务器响应;
  4. 转发状态码、响应头和响应体;
  5. 根据需要处理 CORS,并限制可代理的目标,避免 SSRF。

Node 代理跨域示意图

客户端示例:

<script>
  fetch('http://localhost:3000/', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ name: 'xiamen', password: '123456' })
  })
    .then(response => response.json())
    .then(result => console.log(result))
</script>

代理服务器示例:

const http = require('http')

const server = http.createServer((request, response) => {
  const corsHeaders = {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type'
  }

  if (request.method === 'OPTIONS') {
    response.writeHead(204, corsHeaders)
    response.end()
    return
  }

  const proxyRequest = http.request(
    {
      hostname: '127.0.0.1',
      port: 4000,
      path: request.url,
      method: request.method,
      headers: {
        ...request.headers,
        host: '127.0.0.1:4000'
      }
    },
    proxyResponse => {
      response.writeHead(proxyResponse.statusCode || 502, {
        ...proxyResponse.headers,
        ...corsHeaders
      })
      proxyResponse.pipe(response)
    }
  )

  proxyRequest.on('error', error => {
    console.error(error)
    if (!response.headersSent) {
      response.writeHead(502, corsHeaders)
    }
    response.end('Bad Gateway')
  })

  // 转发客户端请求体,不能直接调用 proxyRequest.end() 丢弃 POST 数据
  request.pipe(proxyRequest)
})

server.listen(3000, () => {
  console.log('The proxy server is running at http://localhost:3000')
})

目标服务器示例:

const http = require('http')

const data = { title: 'frontend', password: '123456' }

const server = http.createServer((request, response) => {
  if (request.url === '/') {
    response.setHeader('Content-Type', 'application/json; charset=utf-8')
    response.end(JSON.stringify(data))
    return
  }

  response.statusCode = 404
  response.end('Not Found')
})

server.listen(4000, () => {
  console.log('The target server is running at http://localhost:4000')
})

生产环境中还需要正确处理响应状态码、二进制数据、超时、连接复用、Hop-by-Hop Headers、请求体大小以及目标地址白名单。

6. Nginx 反向代理

Nginx 反向代理与 Node 代理的原理类似。最理想的情况是让前端和 Nginx 使用相同的协议、主机和端口,再通过路径转发到后端,这样浏览器看到的是同源请求,不需要额外 CORS。

如果前端访问 www.domain1.com,代理却使用 www.domain1.com:81,由于端口不同,仍然是跨源请求,需要正确配置 CORS。反向代理会增加一跳转发,并不能保证“完全没有性能影响”。

示例配置:

server {
    listen 81;
    server_name www.domain1.com;

    location /api/ {
        proxy_pass http://www.domain2.com:8080;
        proxy_set_header Host www.domain2.com;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # 将后端 Cookie 的 Domain 改写为前端域名
        proxy_cookie_domain www.domain2.com www.domain1.com;

        # 只有在确实需要跨源时才配置 CORS
        add_header Access-Control-Allow-Origin "http://www.domain1.com" always;
        add_header Access-Control-Allow-Credentials "true" always;
        add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
    }
}

proxy_cookie_domain 只修改 Cookie 的 Domain,不会自动解决 SameSiteSecure、路径和认证策略问题。Cookie 不包含端口,因此不能通过修改 Cookie Domain 来解决所有跨源问题。

修改配置后可以使用:

nginx -t
nginx -s reload

其中 nginx -s reload 用于重新加载已经运行的 Nginx;首次启动需要先启动 Nginx 进程。

7. window.name + iframe

历史上,window.name 在同一个浏览上下文导航到其他页面后仍可能保留,因此有人让外域页面先把数据写入 window.name,再导航到同源中间页,最后由父页面读取。

这种方式依赖浏览器历史行为,容量也没有统一的 2 MB 标准,现代浏览器还可能对跨站 window.name 进行隔离或重置,不建议用于新项目。跨窗口通信应优先使用 postMessage

示意代码:

<!-- a.html,http://localhost:3000/a.html -->
<iframe
  src="http://localhost:4000/c.html"
  id="iframe"
  onload="load()"
></iframe>

<script>
  let first = true
  const iframe = document.getElementById('iframe')

  function load() {
    if (first) {
      // 第一次加载外域页面,外域页面把数据写入 window.name
      first = false
      iframe.src = 'http://localhost:3000/b.html'
    } else {
      // 第二次加载同源页面后,父页面才能读取 iframe 的 window.name
      console.log(iframe.contentWindow.name)
    }
  }
</script>
<!-- c.html,http://localhost:4000/c.html -->
<script>
  window.name = '我不爱你'
</script>

8. location.hash + iframe

location.hash 是 URL 片段标识符。跨源页面不能直接读取彼此的 DOM,但可以在一定条件下设置其他窗口的位置;通过中间 iframe,可以把少量数据放入 hash,再由同源页面读取。

这种方式存在 URL 长度限制,数据会出现在地址栏和浏览历史中,不适合传递密码、令牌等敏感信息。实际使用时还应进行 URL 编码,并防止把 hash 直接拼接进 HTML。

示意代码:

<!-- a.html,http://localhost:3000/a.html -->
<iframe src="http://localhost:4000/c.html#iloveyou"></iframe>

<script>
  window.addEventListener('hashchange', () => {
    console.log(decodeURIComponent(location.hash.slice(1)))
  })
</script>
<!-- c.html,http://localhost:4000/c.html -->
<script>
  const iframe = document.createElement('iframe')
  iframe.src = 'http://localhost:3000/b.html#idontloveyou'
  document.body.appendChild(iframe)
</script>
<!-- b.html,与 a.html 同源 -->
<script>
  // b.html 可以把结果写入顶层页面的 hash,但不能读取跨源页面的 DOM
  window.parent.parent.location.hash = location.hash
</script>

现代项目更推荐使用 postMessage,因为它可以明确指定目标 Origin,并且不需要把数据放进 URL。

9. document.domain + iframe

document.domain 曾用于放宽同一注册域名下页面的同源限制,例如:

a.example.com
b.example.com

两个页面都设置相同的 document.domain 后,历史上可以访问部分 DOM:

document.domain = 'example.com'

document.domain 已被现代 Web 平台废弃,不建议用于新项目。它只能用于满足特定条件的相关域名,不能跨协议访问任意域名,也要求两个页面都进行兼容设置。设置后还可能改变端口相关的 Origin 行为,带来额外的安全风险。

历史示例:

<!-- a.html,http://a.zf1.cn:3000/a.html -->
<body>
  helloa
  <iframe
    src="http://b.zf1.cn:3000/b.html"
    id="frame"
    onload="load()"
  ></iframe>

  <script>
    document.domain = 'zf1.cn'
    const frame = document.getElementById('frame')

    function load() {
      console.log(frame.contentWindow.a)
    }
  </script>
</body>
<!-- b.html,http://b.zf1.cn:3000/b.html -->
<body>
  hellob
  <script>
    document.domain = 'zf1.cn'
    window.a = 100
  </script>
</body>

新项目应优先使用 CORS、同源反向代理或 postMessage

三、总结

  • CORS 是浏览器跨源读取 HTTP 响应的标准方案,但需要服务端明确配置 Origin、方法、请求头和凭据,不能简单说“支持所有类型请求”。
  • JSONP 只能使用 GET,并且必须由目标服务器主动支持;它返回可执行脚本,存在 XSS 风险。
  • Node 中间件代理和 Nginx 反向代理利用的是服务器端不受浏览器同源策略限制的特点,生产环境还需要处理认证、Cookie、超时、状态码和安全白名单。
  • postMessage 适合跨窗口和 iframe 通信,但接收方必须校验 event.originevent.source
  • WebSocket 不依赖普通 CORS,但服务端应校验 Origin 并进行身份认证。
  • window.name、Hash iframe 和 document.domain 属于历史兼容方案,新项目一般不建议使用。

作者:浪里行舟

原文链接:https://juejin.cn/post/6844903767226351623

来源:稀土掘金。著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS