探讨浏览器同源策略 (Same-Origin Policy) 和 Cross-Origin Resource Sharing (CORS) 机制,以及 CORS 预检请求 (Preflight Request) 的作用。

大家好,我是你们今天的浏览器安全小讲师,咱们今天聊聊浏览器里一对相爱相杀的好基友:同源策略 (Same-Origin Policy) 和跨域资源共享 (CORS)。以及那个让开发者们头疼又不得不面对的 CORS 预检请求 (Preflight Request)。

第一章:什么是同源?为什么要有同源策略?

想象一下,你在网上冲浪,同时打开了两个标签页:一个是你的银行网站 https://mybank.com,另一个是一个看起来很可爱的猫猫网站 https://cutecats.com。 如果没有同源策略,cutecats.com 里的 JavaScript 代码就能轻轻松松地读取 mybank.com 里的 Cookie,获取你的银行账户信息,然后…你就破产了!

所以,同源策略就是浏览器为了保护用户数据而设立的一道安全屏障。它规定,只有协议、域名和端口都相同的页面,才被认为是同源的

  • 协议 (Protocol): httphttps
  • 域名 (Domain): mybank.com
  • 端口 (Port): 80 (http默认) 或 443 (https默认),或者其他显式指定的端口

如果这三者中有任何一个不同,浏览器就会认为这两个页面是不同源的,并阻止其中一个页面上的 JavaScript 代码去访问另一个页面的资源。

举几个例子:

URL 1 URL 2 同源吗? 解释
https://mybank.com/page1 https://mybank.com/page2 协议、域名和端口都相同。
http://mybank.com/page1 https://mybank.com/page1 协议不同 (http vs https)。
https://mybank.com/page1 https://api.mybank.com/ 域名不同 (mybank.com vs api.mybank.com)。注意, api.mybank.com 被认为是 mybank.com 的一个子域名,但在同源策略中,它们仍然被视为不同的源。
https://mybank.com:8080 https://mybank.com 端口不同 (8080 vs 443, 因为https默认是443端口)。
https://mybank.com https://evil.com 域名不同。

同源策略限制了哪些行为?

主要限制了以下几种行为:

  • Cookie、LocalStorage 和 IndexDB: 不同源的页面无法互相访问这些存储在浏览器中的数据。
  • DOM 访问: 一个页面上的 JavaScript 代码无法访问另一个不同源页面的 DOM 结构。
  • 网络请求 (XMLHttpRequest/Fetch): 如果发起跨域请求,浏览器会阻止 JavaScript 代码获取响应数据,即使服务器返回了数据。

第二章:CORS – 同源策略的“白名单”机制

同源策略虽然保护了用户安全,但也给 Web 开发带来了很多麻烦。 想象一下,你的前端应用部署在 https://myapp.com,需要从 https://api.myapp.com 获取数据,结果因为同源策略的限制,请求被浏览器拦截了,是不是很崩溃?

CORS (Cross-Origin Resource Sharing) 就是为了解决这个问题而生的。 它是一种机制,允许服务器声明哪些来源 (origin) 可以访问自己的资源。 你可以把 CORS 看作是同源策略的一个“白名单”机制。

CORS 的工作原理:

  1. 浏览器发起跨域请求: 当一个页面上的 JavaScript 代码尝试向不同源的服务器发起请求时,浏览器会在请求头中添加一个 Origin 字段,表明请求的来源 (协议 + 域名 + 端口)。例如:

    Origin: https://myapp.com
  2. 服务器检查 Origin 字段: 服务器收到请求后,会检查 Origin 字段的值,判断是否允许该来源访问自己的资源。

  3. 服务器设置响应头: 如果服务器允许该来源访问,它会在响应头中添加 Access-Control-Allow-Origin 字段,指定允许访问的来源。例如:

    Access-Control-Allow-Origin: https://myapp.com
    • Access-Control-Allow-Origin: * 表示允许所有来源访问,但这通常不推荐,因为它会降低安全性。
    • Access-Control-Allow-Origin: https://myapp.com 表示只允许 https://myapp.com 访问。
    • Access-Control-Allow-Origin: null 表示允许来自本地文件 (file://) 的请求。
  4. 浏览器检查响应头: 浏览器收到服务器的响应后,会检查响应头中 Access-Control-Allow-Origin 字段的值。 如果 Access-Control-Allow-Origin 的值与请求的 Origin 匹配,或者 Access-Control-Allow-Origin 的值为 *,浏览器才会允许 JavaScript 代码访问响应数据。 否则,浏览器会阻止 JavaScript 代码获取响应数据,并抛出一个 CORS 错误。

简单的 CORS 例子 (Node.js + Express):

const express = require('express');
const cors = require('cors'); // 引入 cors 中间件
const app = express();
const port = 3000;

// 使用 cors 中间件,允许所有来源访问
// app.use(cors());

// 使用 cors 中间件,只允许特定的来源访问
const corsOptions = {
  origin: 'https://myapp.com', // 允许的来源
  optionsSuccessStatus: 200 // some legacy browsers (IE11, various SmartTVs) choke on 204
}
app.use(cors(corsOptions));

app.get('/data', (req, res) => {
  res.json({ message: 'Hello from the API!' });
});

app.listen(port, () => {
  console.log(`API listening at http://localhost:${port}`);
});

在这个例子中,cors 中间件会帮我们自动设置 Access-Control-Allow-Origin 响应头。 如果取消注释 app.use(cors());, 则允许所有来源访问。 如果使用 cors(corsOptions),则只允许 https://myapp.com 访问。

第三章:CORS 预检请求 (Preflight Request) – 安全性再升级

对于一些“简单请求 (Simple Request)”,浏览器会直接发起跨域请求,并在请求头中携带 Origin 字段,如上面所述。 但对于一些“复杂请求 (Complex Request)”,浏览器会先发起一个“预检请求 (Preflight Request)”,用于确认服务器是否允许真正的跨域请求。

什么是简单请求 (Simple Request)?

简单请求需要同时满足以下所有条件:

  • 请求方法 (Method): GET, HEAD, POST 之一
  • 请求头 (Headers): 只包含以下字段 (大小写不敏感):
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type (但只允许以下三个值:application/x-www-form-urlencoded, multipart/form-data, text/plain)
    • DPR
    • Downlink
    • ECT
    • RRT
    • Save-Data
    • Viewport-Width
    • Width

什么又是复杂请求 (Complex Request)?

只要不满足简单请求的所有条件,那就是复杂请求。 比如,使用了 PUT, DELETE 等请求方法,或者设置了自定义的请求头,都属于复杂请求。

预检请求 (Preflight Request) 的作用:

预检请求是一个 OPTIONS 请求,它会先发送到服务器,询问服务器是否允许真正的跨域请求。 预检请求的请求头中包含以下字段:

  • Origin: 请求的来源
  • Access-Control-Request-Method: 真正的请求将使用的 HTTP 方法 (如 PUT, DELETE)
  • Access-Control-Request-Headers: 真正的请求将包含的自定义请求头

例如:

OPTIONS /resource HTTP/1.1
Origin: https://myapp.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header, Content-Type

服务器收到预检请求后,需要返回一个响应,其中包含以下响应头:

  • Access-Control-Allow-Origin: 允许的来源 (必须包含请求的 Origin 值,或者 *)
  • Access-Control-Allow-Methods: 允许的 HTTP 方法 (必须包含 Access-Control-Request-Method 中指定的方法)
  • Access-Control-Allow-Headers: 允许的自定义请求头 (必须包含 Access-Control-Request-Headers 中指定的头部)
  • Access-Control-Max-Age: 预检请求的缓存时间 (秒)。 在这个时间内,浏览器不会再次发送预检请求。

例如:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: PUT, DELETE
Access-Control-Allow-Headers: X-Custom-Header, Content-Type
Access-Control-Max-Age: 86400

如果服务器返回的响应头表明允许真正的跨域请求,浏览器才会发送真正的请求。 否则,浏览器会阻止请求,并抛出一个 CORS 错误。

预检请求的示例 (Node.js + Express):

const express = require('express');
const cors = require('cors');
const app = express();
const port = 3000;

const corsOptions = {
  origin: 'https://myapp.com',
  optionsSuccessStatus: 204, // 必须设置为 204 (No Content) 或 200 (OK),否则一些浏览器可能出现问题
  allowedHeaders: ['X-Custom-Header', 'Content-Type'],
  methods: ['PUT', 'DELETE']
}

app.use(cors(corsOptions)); // 应用 CORS 中间件

app.use(express.json()); // 解析 JSON 格式的请求体

app.put('/data', (req, res) => {
  console.log("PUT request received");
  console.log("Request body:", req.body);
  res.json({ message: 'Data updated successfully!' });
});

app.delete('/data', (req, res) => {
  res.json({ message: 'Data deleted successfully!' });
});

app.listen(port, () => {
  console.log(`API listening at http://localhost:${port}`);
});

在这个例子中,我们使用了 cors 中间件,并配置了 allowedHeadersmethods 选项,以允许 PUTDELETE 请求,以及 X-Custom-HeaderContent-Type 请求头。 optionsSuccessStatus 必须设置为 204 或 200,否则一些浏览器可能会出现问题。

前端发起复杂请求的例子 (JavaScript):

fetch('https://api.myapp.com/data', {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'X-Custom-Header': 'foobar'
  },
  body: JSON.stringify({ key: 'value' })
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));

在这个例子中,我们使用了 PUT 方法,并设置了 Content-TypeX-Custom-Header 请求头,因此这是一个复杂请求。 浏览器会自动发送预检请求,如果服务器允许,才会发送真正的 PUT 请求。

CORS 常见问题和解决方案:

  • CORS 错误:No 'Access-Control-Allow-Origin' header is present on the requested resource.

    • 原因: 服务器没有设置 Access-Control-Allow-Origin 响应头,或者设置的值与请求的 Origin 不匹配。
    • 解决方案: 确保服务器正确设置了 Access-Control-Allow-Origin 响应头,并且值与请求的 Origin 匹配,或者设置为 *。 注意,在生产环境中,不建议将 Access-Control-Allow-Origin 设置为 *,因为它会降低安全性。
  • CORS 错误:Request header field X-Custom-Header is not allowed by Access-Control-Allow-Headers in preflight response.

    • 原因: 服务器没有在 Access-Control-Allow-Headers 响应头中包含请求头 X-Custom-Header
    • 解决方案: 确保服务器在 Access-Control-Allow-Headers 响应头中包含了所有自定义请求头。
  • CORS 错误:Method PUT is not allowed by Access-Control-Allow-Methods in preflight response.

    • 原因: 服务器没有在 Access-Control-Allow-Methods 响应头中包含请求方法 PUT
    • 解决方案: 确保服务器在 Access-Control-Allow-Methods 响应头中包含了所有允许的 HTTP 方法。
  • 预检请求失败 (OPTIONS 请求返回 404 或 500 等错误):

    • 原因: 服务器没有正确处理 OPTIONS 请求。
    • 解决方案: 确保服务器能够正确处理 OPTIONS 请求,并返回包含 Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-Headers 响应头的响应。 可以使用 CORS 中间件来简化处理。

第四章:CORS 的替代方案

除了 CORS,还有一些其他的跨域解决方案,但它们各有优缺点:

  • JSONP (JSON with Padding):

    • 原理: 利用 <script> 标签的 src 属性可以跨域请求资源的特性。 服务器返回一段 JavaScript 代码,这段代码会调用一个预先定义好的回调函数,并将数据作为参数传递给该函数。
    • 优点: 兼容性好,可以支持老版本的浏览器。
    • 缺点: 只支持 GET 请求,安全性较低,容易受到 XSS 攻击。
    <!DOCTYPE html>
    <html>
    <head>
    <title>JSONP Example</title>
    </head>
    <body>
    <script>
    function handleResponse(data) {
    console.log("Received data:", data);
    // 处理接收到的数据
    }
    </script>
    <script src="https://api.example.com/data?callback=handleResponse"></script>
    </body>
    </html>

    服务器端(例如,Node.js):

    const express = require('express');
    const app = express();
    const port = 3000;
    
    app.get('/data', (req, res) => {
    const data = { message: 'Hello from the API!' };
    const callback = req.query.callback;
    
    if (callback) {
    // 返回 JSONP 格式的响应
    res.send(`${callback}(${JSON.stringify(data)})`);
    } else {
    // 如果没有 callback 参数,返回普通的 JSON 响应
    res.json(data);
    }
    });
    
    app.listen(port, () => {
    console.log(`JSONP API listening at http://localhost:${port}`);
    });
  • 代理服务器 (Proxy Server):

    • 原理: 前端应用向同源的代理服务器发起请求,代理服务器再向目标服务器发起跨域请求,并将响应数据返回给前端应用。 相当于把跨域请求的责任转移到了服务器端。
    • 优点: 可以绕过浏览器的同源策略,支持各种 HTTP 方法。
    • 缺点: 需要搭建和维护代理服务器,增加了服务器的负担。

    例如,Nginx配置:

    server {
        listen 80;
        server_name myapp.com;
    
        location /api/ {
            proxy_pass https://api.example.com/; # 目标API服务器
            proxy_set_header Host api.example.com;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
    
            add_header Access-Control-Allow-Origin *;  # 允许所有来源,生产环境慎用
            add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
            add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept";
        }
    
        location / {
            root /usr/share/nginx/html;
            index index.html index.htm;
            try_files $uri $uri/ /index.html; # 如果使用SPA
        }
    }
  • PostMessage:

    • 原理: 允许不同源的窗口之间进行通信。 通过 window.postMessage() 方法,一个窗口可以向另一个窗口发送消息,而不管它们的源是否相同。
    • 优点: 可以进行双向通信,可以传输复杂的数据结构。
    • 缺点: 需要手动处理消息的发送和接收,安全性需要特别注意。

    例如,一个页面 (https://myapp.com/page1.html) 发送消息到另一个页面 (https://api.myapp.com/page2.html):

    // https://myapp.com/page1.html
    const targetWindow = window.open('https://api.myapp.com/page2.html', '_blank');
    
    targetWindow.onload = () => {
        targetWindow.postMessage({ message: 'Hello from page1!' }, 'https://api.myapp.com');
    };
    
    window.addEventListener('message', (event) => {
        if (event.origin === 'https://api.myapp.com') {
            console.log('Received message from page2:', event.data);
        }
    });

    接收消息的页面 (https://api.myapp.com/page2.html):

    // https://api.myapp.com/page2.html
    window.addEventListener('message', (event) => {
        if (event.origin === 'https://myapp.com') {
            console.log('Received message from page1:', event.data);
            event.source.postMessage({ message: 'Hello from page2!' }, event.origin);
        }
    });

总结:

同源策略和 CORS 是 Web 安全的重要组成部分。 理解它们的工作原理,以及如何正确配置 CORS,对于开发安全的 Web 应用至关重要。 希望今天的讲解能够帮助大家更好地理解这两个概念,并在实际开发中灵活运用。 记住,安全无小事,多一份了解,少一份风险!

好了,今天的课程就到这里, 祝大家编码愉快,永不遇到 CORS 错误!(除非是故意的,手动滑稽)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注