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

显示模式

登录
ARCHIVE DOCUMENTNET

浏览器同源策略与 AJAX 跨域方法汇总

所属馆藏
NET Protocol
文件格式
Markdown
原始路径
NET Protocol/24-浏览器同源策略与ajax跨域方法汇总
本文目录9 个章节
  1. 什么是同源策略?
  2. 为什么实际开发中会有跨域 AJAX 请求?
  3. 跨域请求一定发不出去吗?
  4. 跨域方案示例
  5. 一、使用代理(Proxy)
  6. 二、CORS
  7. 三、JSONP
  8. 四、三种方案的选择
  9. 总结

浏览器同源策略与 AJAX 跨域方法汇总

Category(分类): NET Protocol Status: 优

什么是同源策略?

如果进行过前端开发,肯定或多或少听说过、接触过同源策略。那么什么是同源策略呢?

要了解同源策略,首先需要理解“源”(Origin)。源不是完整的 URL,而是由 URL 中的以下三部分组成:

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

例如:

http://www.jianshu.com/p/bc7b8d542dcd

可以拆解为:

http       :// www.jianshu.com    /p/bc7b8d542dcd
scheme        host                 pathname

对于一个更完整的 URL:

http://www.jianshu.com:80/p/bc7b8d542dcd#sample?query=text

各部分可以表示为:

protocolhostportpathnamehashquery string
httpwww.jianshu.com:8080/p/bc7b8d542dcd#sample?query=text
location.protocollocation.hostlocation.portlocation.pathnamelocation.hashlocation.search

需要注意:

  • location.host 可能包含端口,例如 www.example.com:8080
  • location.port 在使用默认端口时通常返回空字符串,而不是 80443
  • Hash 不会发送到服务器,也不属于 Origin;
  • 路径和查询参数不参与同源判断;
  • 默认端口会按协议进行规范化,例如 http://example.com 通常等价于 http://example.com:80

因此,同源是指两个页面的 scheme、host 和有效端口相同。

以下 URL 都是相对于:

http://www.jianshu.com/p/bc7b8d542dcd

进行判断:

URL是否同源非同源原因
http://www.jianshu.com/p/0b2acb50f321路径不同不影响 Origin
https://www.jianshu.com/p/0b2acb50f321协议不同
http://www.jianshu.com:8080/p/0b2acb50f321端口不同
http://www.jianshu2.com/p/0b2acb50f321主机不同

简单来说,同源策略就是浏览器出于安全考虑,限制不同源之间的脚本互相读取资源的一种安全机制。常见限制包括:

  • Fetch、XMLHttpRequest 读取跨源响应的权限;
  • 访问跨源页面的 DOM;
  • 访问 LocalStorage、IndexedDB 等按源隔离的存储;
  • 未经 CORS 授权时读取跨源图片的 Canvas 像素;
  • 跨源窗口对象的大多数属性。

Cookie 的规则与 LocalStorage、IndexedDB 不完全相同。Cookie 主要根据域名和路径匹配,不按端口隔离,同时还受到 SecureHttpOnlySameSite 和有效期等属性影响。

有些跨源资源加载行为本身是允许的,例如:

<img src="https://example.com/image.png" alt="">
<link rel="stylesheet" href="https://example.com/style.css">
<script src="https://example.com/script.js"></script>
<iframe src="https://example.com/page.html"></iframe>
<video src="https://example.com/video.mp4"></video>
<audio src="https://example.com/audio.mp3"></audio>

但“允许加载”不代表“脚本可以读取全部内容”:跨源 iframe 的 DOM 仍然不能直接访问,跨源图片的像素通常不能读取,跨源脚本则可能被执行。因此,不能把 <script> 当作安全的数据读取接口。

为什么实际开发中会有跨域 AJAX 请求?

由于浏览器同源策略的影响,页面脚本不能直接读取未经授权的跨源 AJAX 响应。但实际开发中,跨源接口非常常见。

1. 调用已有 API 或公开 API

假设当前新闻详情页是:

http://www.yournews.com/p/123

相关推荐接口已经在公司其他产品线实现:

http://www.mynews.com/recommend?query=123

虽然两个地址可能属于同一家公司,但由于主机不同,页面脚本读取接口响应时仍然是跨源请求。

2. 前后端分离项目本地联调

本地前端页面可能运行在:

http://127.0.0.1:8085

后端接口运行在:

http://127.0.0.1:3000

虽然主机都是 127.0.0.1,但端口不同,所以属于不同源。开发时需要使用 CORS、开发服务器代理或后端反向代理完成联调。

跨域请求一定发不出去吗?

不一定:

  • 简单跨源请求通常会发送到服务器,但浏览器可能阻止脚本读取响应;
  • 需要预检的请求会先发送 OPTIONS,预检失败时真正请求可能不会发送;
  • 表单、图片等跨源请求可能发送,但响应内容通常不会暴露给原页面脚本;
  • 服务器之间的请求不受浏览器同源策略限制。

因此,跨域主要是浏览器脚本的访问和读取权限问题,不是简单的网络不可达问题。

跨域方案示例

下面假设有两个项目:

  • myweb:当前开发的前端站点,运行在 http://127.0.0.1:8085
  • thirdparty:需要调用的后端服务,运行在 http://127.0.0.1:3000

为了简化项目创建过程,可以使用 Express Generator:

npm install -g express-generator
express --view=pug myweb
express --view=pug thirdparty

也可以直接使用:

npx express-generator --view=pug myweb
npx express-generator --view=pug thirdparty

假设 thirdparty 提供以下接口:

http://127.0.0.1:3000/info/normal

接口代码:

const express = require('express')
const router = express.Router()

const data = {
  name: 'alienzhou',
  desc: 'a developer'
}

router.get('/normal', (req, res) => {
  res.json(data)
})

module.exports = router

myweb 页面在点击按钮时请求该接口:

// http://127.0.0.1:8085/index.js
document.getElementById('btn-1').addEventListener('click', () => {
  const xhr = new XMLHttpRequest()

  xhr.onreadystatechange = () => {
    if (xhr.readyState === XMLHttpRequest.DONE) {
      if (xhr.status >= 200 && xhr.status < 300) {
        alert(xhr.responseText)
      } else {
        console.error('请求失败:', xhr.status)
      }
    }
  }

  xhr.open('GET', 'http://127.0.0.1:3000/info/normal', true)
  xhr.send()
})

如果服务端没有返回允许当前源的 CORS 响应头,浏览器控制台可能出现类似错误:

Origin http://127.0.0.1:8085 is not allowed by Access-Control-Allow-Origin.
XMLHttpRequest cannot load http://127.0.0.1:3000/info/normal due to access control checks.

下面介绍几种常见解决方案。

一、使用代理(Proxy)

这种方法本质上是把请求转移到服务端处理。浏览器只请求与页面同源的 myweb 后端,myweb 后端再请求 thirdparty 服务。

同源策略是浏览器需要遵循的规则,服务器到服务器的请求不受浏览器同源策略限制。代理服务器通常需要完成以下步骤:

  1. 接收客户端请求;
  2. 将请求转发给目标服务器;
  3. 接收目标服务器响应;
  4. 将状态码、响应头和响应体转发给客户端。

1. 只代理 GET 请求的示例

下面的示例通过 Node.js 原生 HTTP 模块转发 /proxy/ 下的 GET 请求。访问:

http://127.0.0.1:8085/proxy/info/normal

代理会请求:

http://127.0.0.1:3000/info/normal
const http = require('http')
const express = require('express')
const router = express.Router()

router.get('/proxy/*', (req, res) => {
  // originalUrl 包含查询字符串,去掉 /proxy 前缀后转发
  const targetPath = req.originalUrl.replace(/^\/proxy/, '') || '/'

  const proxyRequest = http.request(
    {
      hostname: '127.0.0.1',
      port: 3000,
      path: targetPath,
      method: 'GET',
      headers: {
        accept: req.headers.accept || '*/*'
      },
      timeout: 5000
    },
    proxyResponse => {
      res.status(proxyResponse.statusCode || 502)

      for (const [key, value] of Object.entries(proxyResponse.headers)) {
        if (value !== undefined) {
          res.setHeader(key, value)
        }
      }

      proxyResponse.pipe(res)
    }
  )

  proxyRequest.on('timeout', () => {
    proxyRequest.destroy(new Error('代理请求超时'))
  })

  proxyRequest.on('error', error => {
    console.error(error)
    if (!res.headersSent) {
      res.status(502).send('Bad Gateway')
    }
  })

  proxyRequest.end()
})

module.exports = router

前端改为请求同源地址:

document.getElementById('btn-1').addEventListener('click', () => {
  const xhr = new XMLHttpRequest()

  xhr.onreadystatechange = () => {
    if (xhr.readyState === XMLHttpRequest.DONE) {
      if (xhr.status >= 200 && xhr.status < 300) {
        alert(xhr.responseText)
      } else {
        console.error('请求失败:', xhr.status)
      }
    }
  }

  xhr.open('GET', '/proxy/info/normal', true)
  xhr.send()
})

该方法的优点是目标服务 http://127.0.0.1:3000/info/normal 不需要增加 CORS 配置。

缺点包括:

  • 需要有自己的后端服务能够接收并转发请求;
  • 纯静态页面需要额外启动开发服务器或使用托管平台的代理能力;
  • 如果请求包含 Cookie、Authorization、特殊请求头或请求体,需要在代理中明确处理;
  • 生产环境需要限制目标地址,防止 SSRF;
  • 还需要处理超时、错误、状态码、缓存、二进制数据和 Hop-by-Hop Headers。

上面的代码只演示 GET。若要代理 POST、PUT 等方法,还需要转发请求方法、请求体和允许的请求头,不能简单地调用 request.get()

二、CORS

CORS(Cross-Origin Resource Sharing,跨源资源共享)是浏览器跨源读取 HTTP 响应的标准机制,需要浏览器和服务器共同支持。

浏览器通常会在跨源请求中携带 Origin 请求头,例如:

Origin: http://127.0.0.1:8085

服务器通过响应头声明允许的源:

Access-Control-Allow-Origin: http://127.0.0.1:8085

浏览器检查响应中的 CORS 头,如果当前源被允许,页面脚本才可以读取响应。

Access-Control-Allow-Origin: * 表示允许任意源进行非凭据跨源读取,但不能与:

Access-Control-Allow-Credentials: true

同时使用。

1. 简单请求

通常需要同时满足以下条件:

条件 1:请求方法是以下之一:

  • GET
  • HEAD
  • POST

条件 2:Content-Type 是以下之一:

  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded

此外,手动设置的请求头必须满足 CORS safelisted request-header 的限制,XMLHttpRequest 上传对象也不能注册会触发预检的事件监听器。

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

2. 需要预检的请求

不符合简单请求条件的请求通常需要预检。浏览器会在正式请求前发送 OPTIONS 请求,并通过以下请求头描述真正请求:

Origin: http://127.0.0.1:8085
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: name

服务器需要返回类似:

Access-Control-Allow-Origin: http://127.0.0.1:8085
Access-Control-Allow-Methods: GET, PUT, OPTIONS
Access-Control-Allow-Headers: Content-Type, name
Access-Control-Max-Age: 600

Express 示例:

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

const allowList = new Set(['http://127.0.0.1:8085'])

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', '600')
  }

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

  next()
})

app.put('/info/cors', (req, res) => {
  res.setHeader('name', 'jw')
  res.json({
    name: 'alienzhou',
    desc: 'a developer'
  })
})

app.listen(3000)

3. 带凭据的 CORS 请求

默认情况下,跨源 XHR/Fetch 不会携带 Cookie。客户端需要显式设置 withCredentials

document.getElementById('btn-1').addEventListener('click', () => {
  const xhr = new XMLHttpRequest()
  xhr.withCredentials = true

  xhr.onreadystatechange = () => {
    if (xhr.readyState !== XMLHttpRequest.DONE) {
      return
    }

    if (xhr.status >= 200 && xhr.status < 300) {
      alert(xhr.responseText)
      // name 不是简单响应头,需要服务端设置 Expose-Headers
      console.log(xhr.getResponseHeader('name'))
    }
  }

  xhr.open('PUT', 'http://127.0.0.1:3000/info/cors', true)
  xhr.setRequestHeader('name', 'hw')
  xhr.send()
})

服务端需要返回:

Access-Control-Allow-Origin: http://127.0.0.1:8085
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: name

不能返回:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Cookie 是否能发送还取决于域名、路径、SameSiteSecure 和有效期等规则。127.0.0.1:8085127.0.0.1:3000 虽然不同源,但 Cookie 的域匹配不包含端口。

CORS 不是 CSRF 防护机制。服务端仍应使用 CSRF Token、SameSite Cookie、Origin/Referer 检查和权限校验。

CORS 的优点:

  • 浏览器原生支持;
  • 配置清晰,能够支持多种 HTTP 方法;
  • 可以通过预检机制控制请求方法和请求头;
  • 支持明确的凭据和响应头暴露策略。

CORS 的缺点:

  • 需要目标服务配合修改响应头;
  • 复杂请求可能增加一次预检请求;
  • 旧浏览器兼容性较差;
  • 错误配置可能导致敏感数据被不可信源读取。

三、JSONP

JSONP 是跨域领域中历史较早的一种方案。由于浏览器允许 <script> 标签加载跨源脚本,服务端可以把客户端需要的数据包装成 JavaScript 返回:

callback({ name: 'alienzhou' })

具体流程如下:

  1. 在 myweb 页面预先定义一个回调函数,例如 myCallback
  2. 动态创建 <script> 标签,将跨域接口地址设置给 src
  3. 把回调函数名作为查询参数传给 thirdparty;
  4. thirdparty 返回以该回调函数包裹的数据;
  5. 浏览器执行返回脚本,调用页面中预先定义的函数。

1. 原生 JavaScript 示例

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 请求失败'))
    }

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

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

jsonp({
  url: 'http://127.0.0.1:3000/info/jsonp',
  params: {},
  callback: 'myCallback'
}).then(data => {
  alert(JSON.stringify(data, null, 2))
})

2. thirdparty 服务端示例

const express = require('express')
const router = express.Router()

const data = {
  name: 'alienzhou',
  desc: 'a developer'
}

router.get('/jsonp', (req, res) => {
  const callback = req.query.callback

  if (!/^[A-Za-z_$][\w$]*$/.test(callback || '')) {
    res.status(400).send('Invalid callback')
    return
  }

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

module.exports = router

服务端不能把任意用户输入直接拼接到脚本中,否则可能造成 XSS。JSONP 只能使用 GET,不能用于 POST、PUT、DELETE 等方法;它也不能访问任意不支持 JSONP 的网站。

3. jQuery JSONP 示例

$.ajax({
  url: 'http://127.0.0.1:3000/info/jsonp',
  dataType: 'jsonp',
  jsonp: 'callback',
  jsonpCallback: 'myCallback'
}).done(res => {
  alert(JSON.stringify(res, null, 2))
})

JSONP 的优点是兼容性较好,适合维护旧系统。缺点是只能 GET、没有标准的响应错误读取机制,并且响应会作为 JavaScript 执行,安全性不如 CORS。

四、三种方案的选择

  • 代理:目标服务不能修改时,或者希望前端始终请求同源地址时使用;
  • CORS:能够修改目标服务时,优先使用标准方案;
  • JSONP:仅用于旧浏览器或旧接口兼容,目标服务必须明确支持,并且不能传递敏感数据。

上面示例的完整代码可以参考:cross-domain-demo

git clone https://github.com/alienzhou/cross-domain-demo.git

总结

同源策略是浏览器的重要安全机制,它在保护用户数据的同时,也限制了页面脚本直接读取跨源响应的能力。跨域请求并不一定不会发送,关键是浏览器是否允许页面脚本读取响应,以及请求是否需要预检。

常见解决方案包括:

  • 代理:通过同源后端转发请求,浏览器不直接读取跨源接口;
  • CORS:通过服务端响应头授权浏览器读取跨源 HTTP 响应;
  • JSONP:利用 <script> 加载跨源 JavaScript,仅支持 GET,属于历史兼容方案。

在实际项目中,应优先考虑 CORS、同源反向代理和开发服务器代理,并同时做好认证、授权、CSRF 防护和输入校验。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS