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

显示模式

登录
ARCHIVE DOCUMENTNET

跨域总结:从 CORS 到 Nginx

所属馆藏
NET Protocol
文件格式
Markdown
原始路径
NET Protocol/34-跨域总结 从CORS到Ngnix
本文目录13 个章节
  1. 前言
  2. 1. 什么是同源策略及其限制内容?
  3. 2. 常见的跨域场景
  4. 1. JSONP
  5. 2. CORS
  6. 3. postMessage
  7. 4. WebSocket
  8. 5. Node 中间件代理
  9. 1. 代理服务器
  10. 2. 为什么使用 Nginx 反向代理?
  11. 3. Windows 下 Nginx 的下载与安装
  12. 4. Nginx 的常见功能
  13. 参考资料

跨域总结:从 CORS 到 Nginx

Category(分类): NET Protocol, Other Status: 未知

前言

前后端数据交互经常会碰到请求跨域。什么是跨域?为什么会有跨域限制?有哪些常见的跨域方式?这些问题值得系统记录下来。

本文先介绍浏览器同源策略,再整理 JSONP、CORS、postMessage、WebSocket、Node 中间件代理和 Nginx 反向代理等方案。新项目通常优先使用 CORS、同源反向代理或 postMessagewindow.name、Hash 和 document.domain 主要用于理解历史方案或维护旧项目。

一、什么是跨域?

1. 什么是同源策略及其限制内容?

同源策略(Same-Origin Policy,SOP)是浏览器的一项安全策略。所谓同源,是指两个 URL 具有相同的协议(scheme)、主机(host)和有效端口(effective port)。

浏览器出于安全考虑,限制一个源的脚本读取或操作另一个源的资源。这里的限制主要针对跨源读取、DOM 访问和部分存储访问,并不是禁止所有跨源网络行为。

同源策略主要限制的内容包括:

  • Cookie、LocalStorage、IndexedDB 等存储的访问规则;
  • 不同源页面之间的 DOM 访问;
  • Fetch、XMLHttpRequest 读取跨源响应的权限;
  • 未经 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>

但“可以加载”不等于“脚本可以读取全部内容”:跨源图片的像素通常不能被脚本读取,跨源 CSS 的 CSSOM 访问受限,跨源脚本则可能被执行。因此,不能把 <script> 当成安全的数据读取接口。

同源策略示意图

2. 常见的跨域场景

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

页面地址目标地址是否同源原因
http://localhost:3000http://localhost:4000端口不同
http://a.example.comhttp://b.example.com主机不同
http://example.comhttps://example.com协议不同
http://example.comhttp://example.com/api路径不同不影响源

特别说明:

  • 如果是协议或端口造成的跨源,前端 JavaScript 不能自行修改当前页面的 Origin,但仍可以通过 CORS、反向代理或同源部署解决;
  • 同源判断依据的是 URL 的 scheme、host 和有效端口,不会因为两个域名解析到了同一个 IP 地址而变成同源;
  • “URL 的首部”不是准确的规范术语;
  • 跨源请求不一定发不出去,是否发送以及脚本是否能读取响应,要根据请求类型和 CORS 配置判断。

常见跨域场景示意图

请求跨域后,到底发出去没有?

不一定:

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

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

二、跨域解决方案

1. JSONP

1.1 JSONP 原理

JSONP(JSON with Padding)利用浏览器允许 <script> 元素加载跨源脚本的行为,让服务端返回一段 JavaScript,而不是纯 JSON:

show({ message: 'hello' })

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

1.2 JSONP 和 AJAX 对比

JSONP 和 AJAX 都可以用于异步获取服务端数据,但实现机制不同:

  • AJAX 通常指通过 XMLHttpRequest 或 Fetch 发送异步 HTTP 请求;
  • AJAX 默认受到同源策略限制,但可以通过服务端 CORS 获得跨源读取权限;
  • JSONP 利用 <script> 加载机制传递数据,服务端返回的是可执行 JavaScript;
  • JSONP 只能使用 GET,不能实现安全可靠的 POST、PUT、DELETE 等请求。

1.3 JSONP 优缺点

JSONP 的优点是实现简单、兼容性较好,适合维护旧系统。

缺点包括:

  • 仅支持 GET;
  • 受 URL 长度和缓存策略限制;
  • 没有标准的跨域错误处理机制;
  • 目标服务器必须主动支持 JSONP;
  • 响应会被当作 JavaScript 执行,回调名或服务端响应不可信时可能造成 XSS。

GET 并不天然不安全,JSONP 的主要安全风险在于它返回的是可执行脚本,因此不能用 JSONP 传递密码、令牌等敏感信息。

1.4 JSONP 的实现流程

  • 声明一个回调函数,将函数名作为参数传给跨域服务器;
  • 创建 <script> 标签,把跨域 API 地址赋值给 src
  • 服务器接收到请求后,将回调函数名和数据拼接成 callback(data)
  • 浏览器执行返回的脚本,调用之前声明的回调函数;
  • 请求完成后删除 script 元素和全局回调,避免污染页面。

JSONP 跨域示意图

客户端示例:

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: 'I Love you' },
  callback: 'show'
}).then(data => {
  console.log(data)
})

服务器示例(Express):

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)
  const data = {
    username: 'zs',
    password: 123456
  }

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

app.listen(3000)

生产环境还应使用 HTTPS、限制可返回的数据、避免返回敏感信息,并对回调参数和接口访问权限进行严格控制。

2. CORS

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

服务器可以返回:

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

表示允许指定源读取响应。Access-Control-Allow-Origin: * 可以允许任意源进行非凭据跨源读取,但不能与:

Access-Control-Allow-Credentials: true

同时使用。

IE8、IE9 曾提供 XDomainRequest 等历史兼容机制,但现代项目不应再以它们作为主要方案。

2.1 简单请求

“简单请求”是 CORS 中常用的称呼。通常需要同时满足以下条件:

条件 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.2 需要预检的请求

不符合简单请求条件的请求通常需要预检。浏览器会在正式通信前发送一个 OPTIONS 请求,通过以下请求头说明真正请求的信息:

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

服务器需要通过 Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-Headers 等响应头明确允许该请求。

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

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

  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 请求携带 Cookie:

<script>
  const xhr = new XMLHttpRequest()
  document.cookie = 'name=hw; SameSite=Lax'
  xhr.withCredentials = true
  xhr.open('PUT', 'http://localhost:4000/getData', true)
  xhr.setRequestHeader('name', 'hw')

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

    if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
      console.log(JSON.parse(xhr.responseText))
      // name 不是简单响应头,需要 Access-Control-Expose-Headers
      console.log(xhr.getResponseHeader('name'))
    }
  }

  xhr.onerror = () => {
    console.error('请求失败')
  }

  xhr.send()
</script>

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

Access-Control-Allow-Credentials: true

Cookie 是否发送还会受到域名、路径、SameSiteSecure 和过期时间等规则影响。localhost:3000localhost:4000 虽然不同源,但 Cookie 的域匹配不包含端口。

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

3. postMessage

postMessage 属于 HTML Web Messaging API,不是 XMLHttpRequest Level 2 API。它允许不同源的窗口、iframe 或新窗口之间进行异步消息通信,可用于:

  • 页面与打开的新窗口之间的数据传递;
  • 多窗口之间的消息传递;
  • 页面与嵌套 iframe 之间的消息传递;
  • 上述场景中的跨源通信。

API 形式如下:

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(
      { message: 'hello' },
      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 是一种全双工通信协议,通常先通过 HTTP Upgrade 完成握手,握手成功后切换为 WebSocket 帧通信。它通常运行在 TCP 之上,生产环境一般使用运行在 TLS 之上的 wss://

WebSocket 不依赖普通 CORS 响应头,但服务端应检查握手中的 Origin,并进行身份认证和权限控制。

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

客户端示例:

<script>
  const data = {
    username: 'hw',
    password: 456789
  }

  const socket = new WebSocket('ws://localhost:4000')

  socket.onopen = () => {
    socket.send(JSON.stringify(data))
  }

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

服务端示例:

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

const data = {
  username: 'zs',
  password: 123456
}

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

  socket.on('message', message => {
    console.log(JSON.parse(message.toString()))
    socket.send(JSON.stringify(data))
  })
})

5. Node 中间件代理

实现原理是:浏览器只请求代理服务器,代理服务器再以服务端身份请求目标服务器。服务器到服务器的请求不受浏览器同源策略限制。

这不应简单称为“两次跨域”:如果页面和代理服务器同源,浏览器到代理服务器没有跨源;代理服务器到目标服务器则不是浏览器请求。

代理服务器通常需要完成以下步骤:

  • 接收客户端请求;
  • 将请求方法、路径、请求头和请求体转发给目标服务器;
  • 接收目标服务器响应;
  • 转发状态码、响应头和响应体;
  • 限制目标地址,避免 SSRF。

Node 代理跨域示意图

假设页面运行在 http://localhost:5500,代理服务器运行在 http://localhost:3000,目标服务器运行在 http://localhost:4000

客户端示例:

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

代理服务器示例:

const http = require('http')

const clientOrigin = 'http://localhost:5500'

function setCorsHeaders(response, origin) {
  if (origin !== clientOrigin) {
    return false
  }

  response.setHeader('Access-Control-Allow-Origin', clientOrigin)
  response.setHeader('Vary', 'Origin')
  response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
  response.setHeader('Access-Control-Allow-Headers', 'Content-Type')
  return true
}

const server = http.createServer((request, response) => {
  const allowed = setCorsHeaders(response, request.headers.origin)

  if (request.method === 'OPTIONS') {
    if (!allowed) {
      response.writeHead(403)
      response.end('Forbidden')
      return
    }

    response.writeHead(204)
    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 => {
      const headers = { ...proxyResponse.headers }

      if (allowed) {
        headers['access-control-allow-origin'] = clientOrigin
        headers.vary = 'Origin'
      }

      response.writeHead(proxyResponse.statusCode || 502, headers)
      proxyResponse.pipe(response)
    }
  )

  proxyRequest.on('error', error => {
    console.error(error)
    if (!response.headersSent) {
      response.writeHead(502)
    }
    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: 'zs',
  password: '123'
}

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, '127.0.0.1', () => {
  console.log('The target server is running at http://127.0.0.1:4000')
})

生产环境中还需要处理超时、二进制响应、Hop-by-Hop Headers、请求体大小、认证、日志和目标地址白名单。

三、Nginx 反向代理

Nginx 是高性能的 Web 服务器、反向代理服务器和负载均衡器。它与 Node.js 都可以使用事件驱动和非阻塞 I/O,但擅长的领域不同:

  • Nginx 更擅长静态资源处理、反向代理、负载均衡、TLS 终止和访问控制;
  • Node.js 更擅长具体业务逻辑和应用层服务;
  • 两者可以结合使用,由 Nginx 作为统一入口,将请求转发给 Node.js 或其他后端服务。

Nginx 反向代理概览图

1. 代理服务器

什么是反向代理?互联网应用通常基于 Client/Server 结构。代理服务器位于客户端与真正的服务器之间,为客户端或服务端提供转发、缓存、负载均衡、访问控制等能力。

正向代理

正向代理代表客户端访问目标服务器。客户端知道代理服务器的存在,并主动配置或使用它:

  1. 客户端向代理服务器发送请求;
  2. 代理服务器向目标服务器发起请求;
  3. 代理服务器获取目标内容;
  4. 代理服务器把内容返回给客户端。

正向代理流程图

反向代理

反向代理代表服务器接收客户端请求。客户端通常只看到统一的代理入口,并不需要知道后端具体是哪一台服务器:

  1. 客户端请求统一入口;
  2. Nginx 根据路径、域名或负载策略选择上游服务器;
  3. Nginx 将请求转发给上游;
  4. Nginx 将上游响应返回给客户端。

反向代理流程图

反向代理和上游服务器不一定位于同一个 LAN,也可以位于不同主机、不同网络或不同数据中心。客户端是否能感知代理,取决于响应头、重定向、应用配置等因素。

2. 为什么使用 Nginx 反向代理?

主要原因包括:

  • 安全与权限控制:后端服务可以不直接暴露给公网,由 Nginx 统一处理 TLS、访问控制、请求过滤和限流;
  • 负载均衡:可以将请求分配给多个后端实例,支持轮询、权重、IP Hash 等策略;
  • 静态资源处理:Nginx 适合处理静态文件、缓存和压缩;
  • 统一入口:可以通过域名和路径把多个服务组织到同一个站点下,从而减少浏览器跨源问题;
  • 连接管理:可以处理客户端连接、超时、缓存和部分协议转换。

Nginx 开源版主要提供被动故障处理;主动健康检查通常需要 Nginx Plus 或第三方模块,不能笼统地说所有 Nginx 都内置主动健康检查。

3. Windows 下 Nginx 的下载与安装

Nginx 下载

Windows 下 Nginx 配置参考

Windows 环境可以下载官方压缩包,解压后通过命令启动。修改配置前建议先执行配置检查:

nginx -t

重新加载配置:

nginx -s reload

4. Nginx 的常见功能

4.1 简单的访问控制

Nginx 的 allowdeny 指令属于 ngx_http_access_module。规则通常按配置顺序匹配,匹配到第一条适用规则后停止继续匹配。

location / {
    deny 192.168.1.100; # 禁止单个 IP
    allow 192.168.1.0/24; # 允许 192.168.1.x 网段的其他地址
    allow 10.110.50.16; # 允许单个 IP
    deny all; # 其余全部禁止
}

由于 192.168.1.100 的拒绝规则在网段允许规则之前,因此该地址仍然会被拒绝。Nginx 配置使用 # 注释,不能使用 JavaScript 风格的 // 注释;192.168.1.10/200 也不是合法的 CIDR 表达式。

4.2 使用 Nginx 处理跨源请求

如果前端和 Nginx 使用相同的协议、主机和端口,只需要让 Nginx 将 /api/ 转发给后端,浏览器看到的就是同源请求,通常不需要 CORS。

如果页面和 Nginx 本身不同源,则需要由 Nginx 返回 CORS 响应头。不能无条件反射任意 $http_origin,而应使用白名单。

下面的配置假设 map 位于 Nginx 的 http 配置块中,允许的前端源是 http://localhost:3000

# http {} 配置块中
map $http_origin $cors_origin {
    default "";
    "http://localhost:3000" $http_origin;
}

server {
    listen 3002;
    server_name localhost;

    location /api/ {
        proxy_pass http://localhost:4000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Vary Origin always;
        add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header Access-Control-Allow-Headers 'Content-Type, Authorization' always;
        add_header Access-Control-Allow-Credentials 'true' always;

        if ($request_method = OPTIONS) {
            return 204;
        }
    }
}

说明:

  • Access-Control-Allow-Origin 不能在带凭据的请求中使用 *
  • Vary: Origin 用于避免缓存把某个 Origin 的响应错误地复用于另一个 Origin;
  • Access-Control-Allow-Headers 应根据实际需要维护白名单,不能盲目反射客户端请求头;
  • add_header ... always 可以让响应头也出现在部分非成功状态响应中;
  • 生产环境还需要根据实际域名、Cookie、SameSite 和认证方式调整配置。

四、总结

  • CORS 是浏览器跨源读取 HTTP 响应的标准机制,但请求方法、请求头、凭据和预检行为需要服务端分别配置;
  • JSONP 只支持 GET,并且必须由目标服务器主动支持,不能向任意不支持 JSONP 的网站请求数据;
  • Node 中间件代理和 Nginx 反向代理利用的是服务器到服务器的请求不受浏览器同源策略限制这一特点;
  • postMessage 适合窗口和 iframe 通信,但必须校验 event.originevent.source
  • WebSocket 不依赖普通 CORS,但服务端应校验 Origin 并进行身份认证;
  • 日常项目中常用的方案通常是 CORS、同源反向代理和开发服务器代理,历史方案应谨慎使用。

参考资料

作者:千叶风行

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

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

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS