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

显示模式

登录
ARCHIVE DOCUMENTNET

跨域,不可不知的基础概念

所属馆藏
NET Protocol
文件格式
Markdown
原始路径
NET Protocol/35-跨域,不可不知的基础概念
本文目录7 个章节
  1. 为什么会存在跨域问题?
  2. 没有同源策略限制的危险场景
  3. 浏览器的同源策略
  4. 跨域的定义和常见场景
  5. 跨域解决方案
  6. 三、总结
  7. 参考资料

跨域,不可不知的基础概念

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

跨域是前端开发中经常遇到的问题。无论是本地开发、前后端分离项目,还是面试,都需要理解跨域的前因后果,而不是只记住几个配置项。

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

为什么会存在跨域问题?

因为浏览器存在同源策略,所以才会有跨域问题。同源策略的主要目的是限制一个网站的脚本读取另一个网站的敏感资源,避免用户登录某个网站后,恶意网站在后台读取其页面内容或利用其身份执行操作。

同源策略不是为了阻止所有跨源网络请求,而是限制跨源读取、DOM 访问和部分网络响应访问。它也不能单独解决 XSS、CSRF 等所有安全问题。

没有同源策略限制的危险场景

1. 跨源 DOM 读取

如果没有 DOM 同源策略,不同源 iframe 之间就可以相互读取 DOM。攻击者可能制作一个恶意页面,在其中嵌套银行网站:

  1. 恶意页面使用 iframe 嵌套银行页面;
  2. 通过视觉伪装让用户误以为自己仍在正常银行页面中;
  3. 如果攻击者能够读取跨源 iframe 的 DOM,就可能获取页面中的账户信息或其他敏感数据。

现实中的点击劫持还可能在同源策略存在时通过透明 iframe 诱导点击,因此网站通常还需要使用 Content-Security-Policy: frame-ancestorsX-Frame-Options 等响应头防护。

2. 跨源读取与 CSRF

假设用户已经登录银行网站,浏览器中保存了银行的 Cookie。此时用户访问恶意网站,恶意网站可能尝试向银行发起请求。

需要区分两种情况:

  • 跨源 Fetch/XHR 是否携带 Cookie,取决于请求的 credentials 设置和 Cookie 的 SameSite 等规则,不能简单说“一定自动携带”;
  • 表单、图片等请求可能产生跨源副作用,即使恶意页面不能读取响应,也可能导致转账、修改资料等操作。

因此,CSRF 的重点是防止攻击者借用用户身份执行有副作用的请求。服务端应使用 CSRF Token、SameSite Cookie、Origin/Referer 检查和权限校验等措施。同源策略可以限制读取,但不能代替 CSRF 防护。

浏览器的同源策略

同源策略(Same-Origin Policy,SOP)是浏览器的重要安全机制,用于限制一个源的文档或脚本与另一个源的资源进行交互。

一个源(origin)由以下三部分组成:

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

例如:

http://localhost:3000
https://localhost:3000
http://localhost:4000

这三个地址彼此都不同源,因为协议或端口不同。同源判断不会因为两个域名解析到了同一个 IP 地址而改变。

同源策略主要限制的内容

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

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

同源策略示意图

跨域的定义和常见场景

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

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

常见跨域场景示意图

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

不一定。

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

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

跨域解决方案

1. JSONP

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

show({ message: 'hello' })

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

JSONP 的实现步骤

  1. 创建一个回调函数;
  2. 创建 <script> 标签,将接口地址设置为 src
  3. 把回调函数名作为查询参数传给服务器;
  4. 服务器校验回调名后返回 callback(data)
  5. 浏览器执行返回的脚本并调用回调函数;
  6. 请求完成后删除 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: 'https://api.example.com/test',
  params: { keyword: 'hello' },
  callback: 'show'
}).then(data => {
  console.log(data)
})

服务器必须严格校验回调函数名,不能把任意用户输入直接拼接成脚本:

public function test(Request $request)
{
    $callback = $request->query('callback', 'callback');

    if (!preg_match('/^[A-Za-z_$][\w$]*$/', $callback)) {
        abort(400, 'Invalid callback');
    }

    $data = [
        'code' => 0,
        'data' => [1, 2, 3, 4, 5],
    ];

    $body = $callback . '(' . json_encode(
        $data,
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
    ) . ')';

    return response($body)
        ->header('Content-Type', 'application/javascript; charset=utf-8');
}

JSONP 的优缺点

优点:

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

缺点:

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

2. document.domain

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

a.example.com
b.example.com

两个页面都设置:

document.domain = 'example.com'

历史上可以访问部分 DOM,但该 API 已被现代 Web 平台废弃,不建议新项目使用。它不能用于任意不同域名,也不能跨协议使用,两个页面还必须同时进行兼容设置。

3. window.name

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

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

4. CORS

CORS(Cross-Origin Resource Sharing,跨源资源共享)需要浏览器和服务器共同支持。服务器通过响应头声明允许的 Origin、方法、请求头和凭据策略,浏览器据此决定是否允许页面脚本读取响应。

例如:

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

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

Access-Control-Allow-Credentials: true

同时使用。

简单请求

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

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

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

需要预检的请求

不满足简单请求条件的请求通常需要预检。浏览器会先发送 OPTIONS 请求,并携带 OriginAccess-Control-Request-MethodAccess-Control-Request-Headers

服务器需要返回类似:

Access-Control-Allow-Origin: http://localhost:3000
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://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)

客户端携带 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 不是简单响应头,需要 Access-Control-Expose-Headers
    console.log(xhr.getResponseHeader('name'))
  }
}
xhr.send()

CORS 不是 CSRF 防护机制,服务端仍然需要进行认证、授权和 CSRF 防护。动态反射 Origin 时必须使用白名单校验,不能无条件把请求中的 Origin 原样返回。

5. 代理(Node 中间件)

同源策略是浏览器需要遵循的规则,服务器到服务器的请求不受浏览器同源策略限制。代理服务器可以接收浏览器请求,再把请求转发给目标服务器。

严格来说,浏览器只请求代理服务器;代理服务器到目标服务器是服务端请求,不是第二次浏览器跨域。

代理通常需要:

  1. 接收客户端请求;
  2. 转发请求方法、路径、请求头和请求体;
  3. 接收目标服务器响应;
  4. 转发状态码、响应头和响应体;
  5. 限制目标地址,防止 SSRF。

Node 代理跨域示意图

客户端示例:

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))

代理服务器示例:

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)

目标服务器示例:

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)

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

6. Nginx 反向代理

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

如果页面是 http://www.domain1.com,但代理地址是 http://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 不包含端口,因此修改 Domain 不能解决所有跨域问题。

修改配置后可以使用:

nginx -t
nginx -s reload

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

7. window.name + iframe

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

这种方式依赖浏览器历史行为,容量没有统一标准,现代浏览器还可能对跨站 window.name 进行隔离或重置,不建议用于新项目。

<!-- 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) {
      first = false
      iframe.src = 'http://localhost:3000/b.html'
    } else {
      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 是浏览器跨源读取 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