菜单

Q
发布于 2025-08-15 / 3 阅读
0
0

浏览器同源策略 (SOP) 与跨域解决方案

一、 万恶之源?安全之盾?—— 什么是同源策略

在深入探讨解决方案之前,我们必须先理解问题本身。

同源策略 (SOP) 是浏览器提供的一个至关重要的安全功能。它规定,一个源(Origin)的文档或脚本,不能与另一个源的资源进行交互。换句话说,浏览器限制了来自不同源的“读”操作。

1. "源" (Origin) 的定义

什么才算“同源”?URL 由多个部分组成,但决定是否同源的只有三个:

  1. 协议 (Protocol):例如 httphttps

  2. 域名 (Domain):例如 www.google.comapi.google.com

  3. 端口 (Port):例如 80443

只有当这三者完全相同时,两个 URL 才被认为是同源的。

我们来看一个表格,假设当前页面的源是 http://www.example.com:8080/index.html

目标 URL

是否同源

原因

http://www.example.com:8080/path/to/file

协议、域名、端口全部相同

http://www.example.com/other.html

端口不同(默认 80 vs 8080)

https://www.example.com:8080/other.html

协议不同(http vs https)

http://api.example.com:8080/other.html

域名不同(www vs api)

http://www.example.com:80/other.html

端口不同(8080 vs 80

2. 同源策略为何存在?

想象一个场景:你正在浏览器的一个标签页中登录着你的网上银行。同时,你在另一个标签页中打开了一个恶意网站。如果没有同源策略,这个恶意网站的脚本就可以向你的网上银行API发送请求(例如 fetch('https://mybank.com/api/transfer?to=hacker&amount=10000')),因为你的浏览器中存有银行的登录凭证(Cookies),这个请求会被认为是合法的,你的资金就会被盗走。

同源策略正是为了防止这种跨站请求伪造 (CSRF) 等安全风险而生的。它保证了你的浏览器在不同站点间的隔离性,构建了 Web 安全的基石。

3. 同源策略的限制范围

同源策略主要限制了以下三种行为:

  1. 无法读取非同源网页的 Cookie、LocalStorage 和 IndexedDB

  2. 无法接触非同源网页的 DOM(例如,在一个页面中通过 <iframe> 嵌入了非同源页面,父页面无法操作子页面的 DOM)。

  3. 无法发送 AJAX 请求到非同源的服务器(这是我们最常遇到的问题)。

但值得注意的是,跨域请求并非完全无法发出。浏览器实际上已经将请求发送到了服务器,服务器也可能处理了请求并返回了数据。但是,浏览器在接收到响应时,会检查其来源,如果发现是非同源的,并且没有相应的跨域许可(我们稍后会讲到),就会拦截这个响应,不让你的 JavaScript 代码读到它,并在控制台抛出我们熟悉的那个错误。

而有些标签则不受同源策略限制,比如:

  • <script src="..."></script>

  • <img src="...">

  • <link href="...">

  • <iframe src="...">

这些标签加载的资源可以来自任何地方,这也是 CDN (内容分发网络) 能够工作的基本原理。

二、 八仙过海,各显神通 —— 主流跨域解决方案

理解了同源策略后,我们来看看如何“合法”地绕过这堵墙。

方案一:CORS (Cross-Origin Resource Sharing) - 官方标准,现代首选

CORS 是 W3C 的官方标准,是目前解决跨域问题最主流、最强大、最正规的方案。它的核心思想是:让服务器来声明,哪些源的请求是可以被接受的。

CORS 将请求分为两类:简单请求 (Simple Requests)非简单请求 (Preflighted Requests)

1. 简单请求

满足以下所有条件的即为简单请求:

  • 请求方法是 GETPOSTHEAD 之一。

  • HTTP 头信息不超出以下几种字段:Accept, Accept-Language, Content-Language, Content-Type(且 Content-Type 的值仅限于 application/x-www-form-urlencoded, multipart/form-data, text/plain)。

对于简单请求,浏览器会直接发送请求,并在请求头中加入一个 Origin 字段,表明请求来自哪个源。

JavaScript

// 前端代码 (Fetch API)
fetch('http://api.example.com/data')
  .then(response => response.json())
  .then(data => console.log(data));

服务器收到请求后,如果 Origin 字段的值在许可范围内,就在响应头中加入一个关键字段:

  • Access-Control-Allow-Origin: http://www.example.com

或者,允许任何源:

  • Access-Control-Allow-Origin: *

浏览器看到这个响应头,就知道这个跨域请求是被允许的,从而将数据交给你的 JavaScript。

后端 Node.js (Express) 示例:

JavaScript

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

app.get('/data', (req, res) => {
  // 设置允许来自 http://www.example.com 的请求
  res.setHeader('Access-Control-Allow-Origin', 'http://www.example.com');
  res.json({ message: 'Hello from CORS-enabled server!' });
});

app.listen(3000, () => console.log('API server listening on port 3000'));

2. 非简单请求(预检请求)

不满足简单请求条件的都是非简单请求,例如请求方法是 PUT, DELETE,或者 Content-Typeapplication/json,或者带有自定义的请求头。

对于非简单请求,浏览器会先发送一个“预检”请求 (Preflight Request)。这是一个 OPTIONS 方法的请求,用于向服务器“咨询”后续的实际请求是否安全。

预检请求头会包含以下关键信息:

  • Origin: 请求源

  • Access-Control-Request-Method: 实际请求将使用的方法 (如 PUT)

  • Access-Control-Request-Headers: 实际请求将携带的自定义头 (如 X-Custom-Header)

服务器必须正确响应这个 OPTIONS 请求,返回以下响应头,才能让浏览器继续发送实际的请求:

  • Access-Control-Allow-Origin: 允许的源

  • Access-Control-Allow-Methods: 允许的请求方法 (如 GET, POST, PUT, DELETE)

  • Access-Control-Allow-Headers: 允许的请求头

  • Access-Control-Max-Age: 预检请求的有效期(秒),在此期间内无需再发预检请求。

后端 Node.js (Express) 示例(更完整的 CORS 配置):

JavaScript

const express = require('express');
const app = express();
const cors = require('cors'); // 使用流行的 cors 中间件更方便

// 简单的用法
// app.use(cors());

// 精细化配置
app.use(cors({
  origin: 'http://www.example.com', // 允许的源
  methods: ['GET', 'POST', 'PUT', 'DELETE'], // 允许的方法
  allowedHeaders: ['Content-Type', 'Authorization'], // 允许的头
  credentials: true // 如果需要携带 cookie
}));


app.put('/update', (req, res) => {
  res.json({ message: 'Data updated successfully!' });
});

app.listen(3000, () => console.log('API server listening on port 3000'));
  • 优点:W3C 标准,功能强大,支持所有 HTTP 方法,是现代 Web 开发的基石。

  • 缺点:需要后端配合进行配置,对于不了解的开发者来说,预检请求可能会带来一些困惑。

方案二:JSONP (JSON with Padding) - 古老但巧妙的“黑客”

在 CORS 出现之前,JSONP 是跨域的流行解决方案。它利用了 <script> 标签不受同源策略限制的“漏洞”。

原理

  1. 前端定义一个全局的回调函数(例如 handleResponse)。

  2. 通过动态创建一个 <script> 标签,其 src 指向后端的 API 地址,并通过 URL 参数将回调函数的名字传给后端(例如 ?callback=handleResponse)。

  3. 后端收到请求后,不再返回纯粹的 JSON 数据,而是返回一段调用这个回调函数的 JavaScript 代码,并将 JSON 数据作为参数传入。例如 handleResponse({"name": "Alice", "age": 30})

  4. <script> 标签加载并执行这段代码时,前端定义好的回调函数就被调用,从而拿到了数据。

前端实现

JavaScript

// 1. 定义回调函数
function handleResponse(data) {
  console.log('Received data:', data);
}

// 2. 创建并插入 script 标签
const script = document.createElement('script');
script.src = 'http://api.example.com/jsonp-data?callback=handleResponse';
document.body.appendChild(script);

// 3. 用完后清理
script.onload = () => {
  document.body.removeChild(script);
};

后端 Node.js (Express) 示例

JavaScript

app.get('/jsonp-data', (req, res) => {
  const callbackName = req.query.callback;
  const data = { name: 'Alice', age: 30 };
  
  // 返回一段 JS 代码
  res.send(`${callbackName}(${JSON.stringify(data)})`);
});
  • 优点:兼容性极好,能支持非常古老的浏览器。

  • 缺点

    • 只支持 GET 请求,因为 <script> 标签只能发送 GET 请求。

    • 安全性差,如果提供 JSONP 服务的网站存在恶意代码,会直接在你的页面执行。

    • 错误处理机制不完善。

    • 现在基本已被 CORS 取代。

方案三:代理 (Proxy) - 釜底抽薪,瞒天过海

代理是解决跨域问题最可靠、最灵活的方案之一,尤其是在生产环境中。其核心思想是:让同源的服务器去请求非同源的服务器,然后将结果返回给前端。

由于服务器之间的通信不受浏览器同源策略的限制,这个方法完美地绕过了问题。

1. Nginx 反向代理(生产环境首选)

在生产环境中,通常使用 Nginx 作为 Web 服务器。我们可以配置 Nginx,让它将所有发往特定路径(如 /api)的请求,转发到真正的后端 API 服务器上。

对于前端来说,它请求的是 http://www.example.com/api/users,这是一个同源请求。Nginx 接收到这个请求后,内部将其转发到 http://api.internal.com/users,拿到数据后再返回给浏览器。

Nginx 配置示例 (nginx.conf):

Nginx

server {
    listen       80;
    server_name  www.example.com;

    # 静态资源
    location / {
        root   /path/to/your/frontend/dist;
        index  index.html;
    }

    # API 代理
    location /api/ {
        # proxy_pass 指向你的后端 API 地址
        proxy_pass http://api.example.com/; 

        # 可选:重写路径,去掉 /api
        # rewrite ^/api/(.*)$ /$1 break;
        
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
  • 优点:无需修改任何前端或后端代码,对开发者透明;安全、稳定、高性能,是生产环境的最佳实践。

  • 缺点:需要额外的服务器配置,依赖运维知识。

2. Node.js 中间件代理(开发环境利器)

在现代前端开发中,我们通常会使用 Webpack、Vite 等构建工具,它们自带的开发服务器(devServer)都内置了代理功能。

这使得我们在开发时可以轻松地模拟生产环境的 Nginx 代理。

Vite 配置示例 (vite.config.js):

JavaScript

import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    proxy: {
      // 字符串简写写法
      '/foo': 'http://localhost:4567',
      // 选项写法
      '/api': {
        target: 'http://jsonplaceholder.typicode.com', // 目标 API 地址
        changeOrigin: true, // 必须设置为 true,否则会失败
        rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 /api
      },
    }
  }
});

配置好后,在你的前端代码里,直接请求 /api/todos/1 即可,Vite 的开发服务器会自动帮你转发到 http://jsonplaceholder.typicode.com/todos/1

  • 优点:配置简单,与现代前端工作流无缝集成,是开发环境解决跨域问题的最佳方式。

  • 缺点:主要用于开发环境。

其他方案

除了上述三种主流方案,还有一些适用于特定场景的技术:

  • postMessage API:用于 window 对象之间(如页面与 iframe、页面与新打开的窗口)的安全通信,可以跨源。

  • WebSocket:WebSocket 协议本身不受同源策略限制,但它在建立连接时需要一个 HTTP 握手,这个握手过程依然受同源策略影响。通常服务器端会做来源验证。

三、 如何选择?场景化总结

解决方案

优点

缺点

推荐场景

CORS

标准、强大、灵活

需要后端配置,预检请求稍复杂

首选方案。当你能完全控制后端时,这是最正规、最推荐的方式。

Nginx 反向代理

对代码无侵入、安全、稳定、高性能

需要运维知识和服务器权限

生产环境最佳实践。特别是当后端 API 无法修改或由第三方提供时。

Node.js 代理

配置简单、集成度高

主要用于开发阶段

前端本地开发。与 Webpack、Vite 等构建工具结合使用,体验极佳。

JSONP

兼容性极好

只支持GET、不安全、已过时

历史遗留项目或需要兼容极古老浏览器的特殊情况,不推荐在新项目中使用。


评论