用可复现实验直观理解 CORS 与 CSRF 的区别与联系

用可复现实验直观理解 CORS 与 CSRF 的区别与联系

前言:它们到底有啥区别?

一句话总结:CORS 决定了你的脚本能不能"看"到响应,而 CSRF 利用了浏览器自动"带"凭证的特性去"做"坏事。

  • CORS (Cross-Origin Resource Sharing):跨域资源共享。
    • 本质上是浏览器为了安全,限制"脚本"(如 JavaScript)读取"另一个来源"的资源。
    • 它关心的是读权限和可见性。一个请求发出去了,服务器也返回了,但浏览器觉得不安全,就不让你在代码里拿到返回结果。
  • CSRF (Cross-Site Request Forgery):跨站请求伪造。
    • 本质上是攻击者"借用"了你的身份(浏览器自动携带的 Cookie),去执行一些非你本意的操作。
    • 它关心的是跨站执行写操作,比如转账、修改个人资料。

基础知识

一、核心概念

子项 具体内容 取值/要求 实验关联/作用
同源策略(SOP) 浏览器核心安全规则:仅「协议+域名+端口」完全相同的页面,脚本才能读写对方资源(Cookie、DOM、响应数据) - CORS是同源策略的"例外机制",CSRF是同源策略的"绕过漏洞"
跨域请求分类 简单请求:GET/POST(Content-Type为application/x-www-form-urlencoded/text/plain等),无自定义头; 预检请求(Preflight):PUT/DELETE、自定义头、Content-Type为application/json,浏览器先发OPTIONS请求校验 - 实验二专门验证预检请求,需理解OPTIONS的作用
凭证(Credentials) 浏览器中标识用户身份的信息:Cookie(如session_id)、HTTP认证、TLS客户端证书 - CORS的Allow-Credentials和CSRF的核心都是"凭证自动携带"
SameSite Cookie 控制Cookie是否跨域携带: - Strict:仅同站请求携带; - Lax:仅导航类跨域请求(链接/表单)携带; - None:所有跨域请求携带(需配合Secure) Strict/Lax/None 影响CSRF是否生效、CORS跨域带Cookie是否成功

二、CORS核心响应头

子项 具体内容 取值/要求 实验关联/作用
Access-Control-Allow-Origin 告诉浏览器:允许该来源的脚本读取响应 ① 具体域名(如http://localhost:4000); ② *(通配符,不可与Allow-Credentials: true混用) 实验一(基础CORS)、实验三(带凭证CORS)
Access-Control-Allow-Credentials 告诉浏览器:是否允许跨域请求携带Cookie等凭证 true/false(仅true允许跨域带凭证) 实验三(核心),缺失会报"Credentials模式为include但该头不是true"
Access-Control-Allow-Methods 预检请求时,告诉浏览器:允许的请求方法 GET/POST/PUT/DELETE/OPTIONS等 实验二(预检请求)
Access-Control-Allow-Headers 预检请求时,告诉浏览器:允许的自定义请求头 Content-Type、X-Custom-Header等 实验二(核心)
Access-Control-Max-Age 缓存预检请求结果,避免重复发OPTIONS 秒数(如3600) 实验二(优化,非必需)

三、Cookie相关响应头

子项 具体内容 取值/要求 实验关联/作用
Set-Cookie 登录时设置用户凭证Cookie 示例:session_id=123; HttpOnly; SameSite=None; Secure; Max-Age=86400 所有实验的基础:无Cookie则CORS/CSRF都无意义 - HttpOnly:防止JS读取Cookie(防XSS,不影响CSRF); - SameSite:控制跨域携带规则; - Secure:仅HTTPS传输(SameSite=None时强制要求)

四、CSRF防护相关

子项 具体内容 取值/要求 实验关联/作用
X-CSRF-Token(响应头) 返回CSRF Token,要求前端请求时携带 随机字符串(如token_123456 对比实验:开启/关闭Token校验,看CSRF是否生效
SameSite=Lax/Strict(Cookie属性) 天然防护CSRF:跨域脚本请求无法带Cookie Strict/Lax 实验四:修改SameSite值,看CSRF表单是否能触发转账

五、前端请求配置

分类 子项 具体内容 取值/要求 实验关联/作用
前端请求配置 fetch(url, { credentials: 'include' }) 告诉浏览器:跨域请求时携带目标域名的Cookie include/same-origin/omit 对应服务端Access-Control-Allow-Credentials: true + Access-Control-Allow-Origin: 具体域名
前端请求配置 自定义请求头(如X-Custom-Header) 触发预检请求(OPTIONS) 任意自定义头名称 对应服务端Access-Control-Allow-Headers
前端请求配置 POST请求Content-Type: application/json 触发预检请求(OPTIONS) application/json 对应服务端Access-Control-Allow-Methods + Access-Control-Allow-Headers

实验环境准备

源的定义

我们需要模拟两个不同来源的站点:

Victim (受害者) 站 Attacker (攻击者) 站
http://localhost:3000 http://localhost:4000

这里使用 Node.js 和 Express 来快速搭建这两个服务。

操作步骤:

  1. 创建一个新的项目文件夹,例如 cors_csrf_lab

  2. 在文件夹中创建两个子文件夹:victim 和 attacker。

  3. 分别进入这两个文件夹,执行 npm init -y 初始化项目,并安装 express 和 cookie-parser(仅 Victim 需要)。

    在 victim 文件夹下执行:

    shell 复制代码
    npm init -y
    npm install express cookie-parser

    在 attacker 文件夹下执行:

    shell 复制代码
    npm init -y
    npm install express

1. Victim 服务端代码 (victim/server.js)

启动 Victim 服务:在 victim 文件夹下打开终端,运行 node server.js。

这是模拟受害者的"受保护"服务,提供了登录、查询数据和转账的接口。

js 复制代码
// victim/server.js
const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();

app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

// 模拟 CSRF Token 存储
let csrfTokenStore = '';

// CORS 中间件配置
app.use((req, res, next) => {
    // 实验一:简单请求
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Access-Control-Allow-Origin', 'http://localhost:4000');
    // 实验二:预检请求
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Custom-Header');

    if (req.method === 'OPTIONS') {
        res.sendStatus(204);
        return;
    }
    next();
});
// 1. 登录接口:设置 Cookie
app.post('/login', (req, res) => {
    const { samesite, username, password } = req.body; // 新增用户名密码参数
    // 简单校验(模拟)
    if (!username || !password) {
        return res.status(400).json({ error: '请输入用户名和密码' });
    }

    let cookieOptions = {
        httpOnly: true,
        maxAge: 1000 * 60 * 60 * 24 // 1天有效期
    };

    if (samesite === 'None') {
        cookieOptions.sameSite = 'none';
        cookieOptions.secure = true;
        console.log('设置 Cookie: SameSite=None; Secure');
    } else {
        cookieOptions.sameSite = samesite || 'Lax';
        console.log(`设置 Cookie: SameSite=${cookieOptions.sameSite}`);
    }

    // 设置登录态 Cookie
    res.cookie('session_id', `user_${username}_${Date.now()}`, cookieOptions);
    res.cookie('username', username, { maxAge: 1000 * 60 * 60 * 24 }); // 非httpOnly,便于页面查看
    res.json({ 
        message: `登录成功!Cookie已设置 (SameSite=${cookieOptions.sameSite})`,
        username: username
    });
});

// 2. 退出登录接口:清除 Cookie
app.post('/logout', (req, res) => {
    res.clearCookie('session_id');
    res.clearCookie('username');
    res.json({ message: '退出登录成功,Cookie已清除' });
});

// 3. 获取用户信息接口
app.get('/user-data', (req, res) => {
    if (req.cookies.session_id) {
        res.json({ 
            username: req.cookies.username || 'Alice', 
            balance: 1000,
            session_id: req.cookies.session_id,
            loginStatus: '已登录'
        });
    } else {
        res.status(401).json({ 
            error: '未授权,请先登录',
            loginStatus: '未登录'
        });
    }
});

// 4. 转账接口
app.post('/transfer', (req, res) => {
    const { to, amount, csrfToken } = req.body;
    console.log('转账请求:', req.body);
    console.log('接收的Cookie:', req.cookies);

    // 思考题:从请求头 X-CSRF-Token 读取 Token
    const receivedToken = req.headers['x-csrf-token']; 

    if (!req.cookies.session_id) {
        return res.status(401).json({ error: '未授权,请先登录' });
    }

    // 基础校验:CSRF Token 校验(可注释/开启测试)
    // if (csrfToken !== csrfTokenStore) {
    //     return res.status(403).json({ error: 'CSRF Token 不匹配!' });
    // }

    // CSRF Token 校验:对比请求头的 Token 和服务端存储的 Token
    // if (receivedToken !== csrfTokenStore) {
    //     console.log(`CSRF Token 不匹配!预期: ${csrfTokenStore}, 实际: ${receivedToken}`);
    //     return res.status(403).json({ error: 'CSRF Token 不匹配!' });
    // }

    res.json({ message: `成功转账 ${amount} 元给 ${to}!` });
});

// 5. 主页:带UI交互的页面
app.get('/', (req, res) => {
    // 生成CSRF Token
    csrfTokenStore = `token_${Date.now()}`;
    console.log('生成CSRF Token:', csrfTokenStore);

    // 渲染带按钮的HTML页面
    res.send(`
        <!DOCTYPE html>
        <html lang="zh-CN">
        <head>
            <meta charset="UTF-8">
            <title>受害者银行 - 模拟登录测试</title>
            <style>
                body { font-family: Arial, sans-serif; max-width: 800px; margin: 20px auto; padding: 0 20px; }
                .container { margin: 20px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px; }
                button { padding: 8px 16px; margin: 5px; cursor: pointer; background: #007bff; color: white; border: none; border-radius: 4px; }
                button:hover { background: #0056b3; }
                .logout-btn { background: #dc3545; }
                .logout-btn:hover { background: #c82333; }
                #status { margin: 10px 0; padding: 10px; border-radius: 4px; }
                .success { background: #d4edda; color: #155724; }
                .error { background: #f8d7da; color: #721c24; }
                .info { background: #d1ecf1; color: #0c5460; }
                input { margin: 5px 0; padding: 8px; width: 200px; }
                pre { background: #f8f9fa; padding: 10px; border-radius: 4px; overflow-x: auto; }
            </style>
        </head>
        <body>
            <h1>受害者银行系统</h1>
            
            <!-- 登录区域 -->
            <div class="container">
                <h2>🔑 用户登录</h2>
                <div>
                    <input type="text" id="username" placeholder="输入用户名" value="testuser"><br>
                    <input type="password" id="password" placeholder="输入密码" value="123456"><br>
                    <label>SameSite 模式:</label>
                    <select id="samesite-mode">
                        <option value="Strict">Strict</option>
                        <option value="Lax" selected>Lax</option>
                        <option value="None">None</option>
                    </select><br>
                    <button onclick="login()">登录</button>
                    <button class="logout-btn" onclick="logout()">退出登录</button>
                </div>
            </div>

            <!-- 登录状态 & Cookie 信息 -->
            <div class="container">
                <h2>📌 登录状态 & Cookie 信息</h2>
                <div id="status" class="info">未登录,点击登录按钮尝试登录</div>
                <div>
                    <button onclick="checkLoginStatus()">刷新登录状态</button>
                    <button onclick="showCookies()">查看浏览器Cookie</button>
                </div>
                <pre id="cookie-info"></pre>
            </div>

            <!-- 用户信息区域 -->
            <div class="container">
                <h2>👤 用户信息</h2>
                <button onclick="getUserData()">获取用户信息</button>
                <pre id="user-data"></pre>
            </div>

            <!-- 转账操作区域 -->
            <div class="container">
                <h2>💸 转账操作</h2>
                <div>
                    <input type="text" id="transfer-to" placeholder="收款方账号" value="zhangsan"><br>
                    <input type="number" id="transfer-amount" placeholder="转账金额" value="100"><br>
                    <input type="hidden" id="csrf-token" value="${csrfTokenStore}">
                    <p>当前CSRF Token:<span id="csrf-token-display">${csrfTokenStore}</span></p>
                    <button onclick="transfer()">提交转账</button>
                </div>
                <pre id="transfer-result"></pre>
            </div>

            <script>
                // 工具函数:更新状态提示
                function updateStatus(message, type = 'info') {
                    const statusEl = document.getElementById('status');
                    statusEl.textContent = message;
                    statusEl.className = type;
                }

                // 1. 登录功能
                async function login() {
                    const username = document.getElementById('username').value;
                    const password = document.getElementById('password').value;
                    const samesite = document.getElementById('samesite-mode').value;

                    if (!username || !password) {
                        updateStatus('请输入用户名和密码!', 'error');
                        return;
                    }

                    try {
                        const response = await fetch('/login', {
                            method: 'POST',
                            headers: {
                                'Content-Type': 'application/json'
                            },
                            body: JSON.stringify({ username, password, samesite })
                        });

                        const data = await response.json();
                        if (response.ok) {
                            updateStatus(data.message, 'success');
                            showCookies(); // 自动刷新Cookie信息
                        } else {
                            updateStatus(data.error, 'error');
                        }
                    } catch (error) {
                        updateStatus('登录请求失败:' + error.message, 'error');
                    }
                }

                // 2. 退出登录功能
                async function logout() {
                    try {
                        const response = await fetch('/logout', {
                            method: 'POST'
                        });
                        const data = await response.json();
                        updateStatus(data.message, 'success');
                        document.getElementById('cookie-info').textContent = '';
                        document.getElementById('user-data').textContent = '';
                        document.getElementById('transfer-result').textContent = '';
                    } catch (error) {
                        updateStatus('退出登录失败:' + error.message, 'error');
                    }
                }

                // 3. 检查登录状态
                async function checkLoginStatus() {
                    try {
                        const response = await fetch('/user-data');
                        const data = await response.json();
                        if (response.ok) {
                            updateStatus('当前状态:已登录', 'success');
                        } else {
                            updateStatus('当前状态:未登录 - ' + data.error, 'error');
                        }
                    } catch (error) {
                        updateStatus('检查状态失败:' + error.message, 'error');
                    }
                }

                // 4. 查看浏览器Cookie
                function showCookies() {
                    const cookieEl = document.getElementById('cookie-info');
                    // 获取当前页面所有Cookie
                    const cookies = document.cookie.split('; ').reduce((obj, cookie) => {
                        const [key, value] = cookie.split('=');
                        obj[key] = value;
                        return obj;
                    }, {});
                    cookieEl.textContent = JSON.stringify(cookies, null, 2);
                }

                // 5. 获取用户信息
                async function getUserData() {
                    try {
                        const response = await fetch('/user-data');
                        const data = await response.json();
                        const userDataEl = document.getElementById('user-data');
                        userDataEl.textContent = JSON.stringify(data, null, 2);
                        if (!response.ok) {
                            updateStatus('获取用户信息失败:' + data.error, 'error');
                        }
                    } catch (error) {
                        updateStatus('获取用户信息请求失败:' + error.message, 'error');
                    }
                }

                // 6. 转账功能
                async function transfer() {
                    const to = document.getElementById('transfer-to').value;
                    const amount = document.getElementById('transfer-amount').value;
                    const csrfToken = document.getElementById('csrf-token').value;

                    if (!to || !amount) {
                        updateStatus('请填写收款方和转账金额!', 'error');
                        return;
                    }

                    try {
                        const response = await fetch('/transfer', {
                            method: 'POST',
                            headers: {
                                'Content-Type': 'application/json',
                                'X-CSRF-Token': csrfToken // 思考题:添加自定义头 X-CSRF-Token
                            },
                            body: JSON.stringify({ to, amount, csrfToken })
                        });

                        const data = await response.json();
                        const transferResultEl = document.getElementById('transfer-result');
                        transferResultEl.textContent = JSON.stringify(data, null, 2);
                        
                        if (response.ok) {
                            updateStatus('转账请求提交成功', 'success');
                        } else {
                            updateStatus('转账失败:' + data.error, 'error');
                        }
                    } catch (error) {
                        updateStatus('转账请求失败:' + error.message, 'error');
                    }
                }

                // 页面加载时自动检查登录状态
                window.onload = function() {
                    checkLoginStatus();
                };
            </script>
        </body>
        </html>
    `);
});

// 启动服务器
app.listen(3000, () => {
    console.log('受害者服务器运行在: http://localhost:3000');
});

2. Attacker 服务端与前端页面 (attacker/server.js & attacker/index.html)

启动 Attacker 服务:在 attacker 文件夹下打开终端,运行 node server.js

攻击站提供一个 HTML 页面,用于发起对 Victim 站的各种请求。

js 复制代码
Attacker 服务端代码 (attacker/server.js):
// attacker/server.js
const express = require('express');
const path = require('path');
const app = express();

app.use(express.static('.')); // 托管当前目录的静态文件

app.get('/', (req, res) => {
    res.sendFile(path.join(__dirname, 'index.html'));
});

app.listen(4000, () => {
    console.log('Attacker server listening on http://localhost:4000');
});

Attacker 前端页面 (attacker/index.html):

html 复制代码
<!-- attacker/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Attacker Page</title>
    <style>
        #result { margin: 20px 0; padding: 10px; border: 1px solid #ddd; }
        .error { color: red; }
        .success { color: green; }
        button { margin: 5px; padding: 8px 16px; cursor: pointer; }
    </style>
</head>
<body>
    <h1>Attacker's Page (http://localhost:4000)</h1>
    <p>This page will try to access resources from http://localhost:3000</p>

    <h2>实验一、二:使用 fetch API (CORS 场景)</h2>
    <!-- 新增:登录按钮(模拟受害者登录,无需Postman) -->
    <button onclick="fetchUserData()">1. Fetch User Data (GET) with Credentials (Cookie)</button>
    <button onclick="fetchWithCustomHeader()">2. Fetch with Custom Header (Trigger Preflight)</button>
    <!-- 新增:测试带 X-CSRF-Token 的跨域转账请求 -->
    <button onclick="fetchWithCSRFHeader()">3. Fetch Transfer with X-CSRF-Token (Preflight)</button>
    <div id="result"></div>
    <hr>

    <h2>实验三:CSRF 表单攻击 (非 CORS 场景)</h2>
    <p>这个表单是隐藏的,页面加载后会自动提交,模拟对 Victim 站点的转账操作。</p>
    <iframe name="csrf-frame" style="display:none;"></iframe>
    <form id="csrf-form" action="http://localhost:3000/transfer" method="POST" target="csrf-frame">
        <input type="hidden" name="to" value="Mallory (The Attacker)">
        <input type="hidden" name="amount" value="100">
        <!-- 防护一:尝试带上 Token,但攻击者无法获取 -->
        <!-- <input type="hidden" name="csrfToken" value="attacker_cannot_get_this"> -->
    </form>
    
    <script>
        const victimUrl = 'http://localhost:3000/user-data';
        const resultDiv = document.getElementById('result');

        // 实验一:基础 fetch(修改:新增credentials,适配CORS凭证)
        function fetchUserData() {
            resultDiv.innerText = 'Fetching...';
            fetch(victimUrl, {
                credentials: 'include' // 新增:强制携带Cookie(跨域必加)
            })
            .then(response => {
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                return response.json();
            })
            .then(data => {
                resultDiv.innerHTML = `<p class="success">✅ Success! Data: ${JSON.stringify(data)}</p>`;
            })
            .catch(e => {
                console.error('Fetch error:', e);
                resultDiv.innerHTML = `<p class="error">❌ Fetch failed: ${e.message}<br>请检查CORS配置和登录状态</p>`;
            });
        }
        
        // 实验二:带自定义头的 fetch,触发预检(修改:新增credentials)
        function fetchWithCustomHeader() {
            resultDiv.innerText = 'Fetching with custom header...';
            fetch('http://localhost:3000/transfer', {
                method: 'POST',
                headers: {
                    'X-Custom-Header': 'my-custom-value', // 自定义头
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({ to: 'Bob', amount: 50 }),
                credentials: 'include' // 新增:携带Cookie
            })
            .then(res => res.json())
            .then(data => {
                resultDiv.innerHTML = `<p class="success">✅ Success! Data: ${JSON.stringify(data)}</p>`;
            })
            .catch(e => {
                console.error('Fetch error:', e);
                resultDiv.innerHTML = `<p class="error">❌ Fetch failed: ${e.message}<br>检查预检请求(OPTIONS)是否通过</p>`;
            });
        }
        // 新增:测试带 X-CSRF-Token 的跨域转账请求
        function fetchWithCSRFHeader() {
            resultDiv.innerText = 'Fetching transfer with X-CSRF-Token...';
            fetch('http://localhost:3000/transfer', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-Token': 'fake_token_123456' // 思考题:攻击者无法获取真实Token
                },
                body: JSON.stringify({ to: 'Mallory', amount: 200 }),
                credentials: 'include'
            })
            .then(res => res.json())
            .catch(e => {
                console.error('Fetch error:', e);
                resultDiv.innerHTML = `<p class="error">❌ Fetch failed: ${e.message}<br>预检失败</p>`;
            });
        }
        // 页面加载后自动提交 CSRF 表单
        window.onload = function() {
            // console.log("Submitting CSRF form automatically...");
            // document.getElementById('csrf-form').submit(); // 注释:手动触发更易测试
            console.log("CSRF form ready (手动点击下方按钮触发)");
        };

        // 新增:手动触发CSRF表单提交(便于调试)
        function submitCSRFForm() {
            document.getElementById('csrf-form').submit();
            resultDiv.innerHTML = `<p class="success">✅ CSRF表单已提交!查看受害者服务端控制台</p>`;
        }
    </script>

    <!-- 新增:CSRF手动触发按钮 -->
    <button onclick="submitCSRFForm()">手动触发CSRF转账</button>
</body>
</html>

实验一:基础跨域读失败(默认无 CORS 配置)和跨站携带凭证 (Cookie)

目标: 观察在没有 Access-Control-Allow-Origin (ACAO) 头和Access-Control-Allow-Credentials(ACAC)时,浏览器如何阻止跨域读取以及如何阻止带凭据发请求。
操作步骤:

  1. 确保 victim/server.js 中所有 CORS 相关的 res.setHeader 都被注释掉。重启 Victim 服务。
  2. 登录 Victim 站:为了后续实验,我们先登录。

    此时应该会在浏览器 DevTools 的 Application -> Cookies 面板中看到 session_id。
  3. 打开浏览器,访问 Attacker 页面 http://localhost:4000, 点击页面上的 "1. Fetch User Data (GET)" 按钮。

    你会看到一个红色的 CORS 错误,内容类似: Access to fetch at 'http://localhost:3000/user-data' from origin 'http://localhost:4000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
  4. 打开 DevTools (F12),切换到 Console 和 Network 面板。

    你会看到对 /user-data 的请求。点击它,你会发现 请求已成功发送 (Status 200),并且 Response 标签页里有服务端返回的数据。但是,在页面上,我们的脚本却拿不到这个数据,这就是浏览器的同源策略在起作用。请求可以发,但响应不可读。

如何拿到请求的响应?

  1. 在 victim/server.js 中,找到 CORS 中间件部分,取消注释下面这行代码:

    js 复制代码
    res.setHeader('Access-Control-Allow-Origin', 'http://localhost:4000');
  2. 重启 Victim 服务 (node server.js)。

  3. 刷新 Attacker 页面 (http://localhost:4000),再次点击按钮。

    此时会发现还是拿不到响应,这还是浏览器安全策略的问题,当前端强制携带了cookie后,服务端的响应头必须携带Access-Control-Allow-Credentials:true

    Access to fetch at 'http://localhost:3000/user-data' from origin 'http://localhost:4000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'.

  4. 在 victim/server.js 中,找到 CORS 中间件部分,取消注释下面这两行代码:

    js 复制代码
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Access-Control-Allow-Origin', 'http://localhost:4000');
  5. 再次重启受害者服务端,测试

    此时成功拿到受害者服务端的响应

结论:

  • CORS 的核心机制就是服务端通过 Access-Control-Allow-Origin 头告诉浏览器:"我允许来自 http://localhost:4000 的脚本读取我的响应"。
  • 如果有携带cookie场景那么需要服务端通过 Access-Control-Allow-Credentials 头告诉浏览器:"我允许来自 http://localhost:4000 的脚本携带cookie来请求"。

实验二:预检请求 (Preflight Request)

目标: 理解什么情况下会触发预检请求 (OPTIONS),以及服务端如何正确响应。

某些"非简单请求(复杂请求)"会触发浏览器自动发送一个 OPTIONS 方法的预检请求,询问服务器是否允许接下来的实际请求。触发条件包括:

  • 使用了 GET, POST, HEAD 之外的方法(如 PUT, DELETE)。
  • Content-Type 是 application/json, application/xml 等。
  • 带有自定义请求头 (e.g., X-Custom-Header)。

操作步骤:

  1. 确保 victim/server.js 中只有 Access-Control-Allow-OriginAccess-Control-Allow-Credentials是开启的。其他 Access-Control-Allow-* 头先注释掉。重启服务。
  2. 在 Attacker 页面 (http://localhost:4000),点击 "2. Fetch with Custom Header (Trigger Preflight)" 按钮。

Console 面板:

你会看到一个关于预检请求失败的 CORS 错误,提示错误如下

Access to fetch at 'http://localhost:3000/transfer' from origin 'http://localhost:4000' has been blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.

Network 面板:你会看到 两个 请求:

  1. 一个 OPTIONS /transfer 请求,状态码可能是 204 No Content404 Not Found,但关键是它的响应头里没有我们需要的 Access-Control-Allow-Headers 和 Access-Control-Allow-Methods。
  2. 真正的 POST /transfer 请求 根本没有被发送。浏览器在预检失败后就直接阻止了它。

让这个复杂请求成功:

  1. 在 victim/server.js 的 CORS 中间件中,取消注释以下两行:

    js 复制代码
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Custom-Header');
  2. 重启 Victim 服务。

  3. 刷新 Attacker 页面,再次点击按钮。

  • Network 面板:OPTIONS 请求的响应头中包含了我们刚加的 Allow-Methods 和 Allow-Headers。随后,浏览器发出了实际的 POST 请求,并成功得到了响应。
  • 页面显示:✅ Success! Data: {"message":"成功转账 50 元给 Bob!"}

结论:预检请求是浏览器在发送复杂跨域请求前的"安全检查"。服务端必须正确响应 OPTIONS 请求,明确告知允许的方法和头,否则实际请求将被浏览器拦截。

实验三:CSRF 表单攻击 (不经过 CORS)

目标:证明传统的 HTML 表单提交 不受 CORS 策略的约束,并且浏览器会自动携带目标站点的 Cookie,从而导致 CSRF 攻击。

操作步骤:

  1. 准备环境:

    • 确保你已登录 Victim 站,且浏览器存有 session_id Cookie

    • 关闭所有 CORS 设置!在 victim/server.js 中,注释掉所有 Access-Control-Allow-* 的 res.setHeader 行。这模拟了一个完全没有配置 CORS 的、自以为安全的后端。

    • 确保 /transfer 接口中的 CSRF Token 校验逻辑也是注释掉的。

    • 重启 Victim 服务。

  2. 访问 Attacker 页面 http://localhost:4000。页面加载后,隐藏的表单会自动提交。

预期现象:

  • Victim 服务端控制台:你会看到一条转账请求日志,并且 成功接收到了 Cookie!
  • Attacker 页面:攻击者页面本身什么也看不到,因为表单提交到了一个隐藏的 iframe 中,并且由于同源策略,它也无法读取 iframe 的响应。但攻击已经发生了!

    结论:这是 CSRF 的核心。即使 Victim 服务器没有开放任何 CORS 权限,一个简单的 HTML 表单提交(其 Content-Type 为 application/x-www-form-urlencoded 或 text/plain 等,属于"简单请求")就能携带用户凭证,完成一次"写"操作。CORS 管不住它!

防护一:CSRF Token

目标: 演示如何通过在请求中加入一个攻击者无法获取的 Token 来有效防御 CSRF。
操作步骤:

  1. 开启 Token 校验:在 victim/server.js/transfer接口中,取消注释 CSRF Token 的校验逻辑。

    js 复制代码
    app.post('/transfer', (req, res) => {
        const { to, amount, csrfToken } = req.body;
        console.log('转账请求:', req.body);
        console.log('接收的Cookie:', req.cookies);
    
        if (!req.cookies.session_id) {
            return res.status(401).json({ error: '未授权,请先登录' });
        }
    
        // CSRF Token 校验(可注释/开启测试)
        if (csrfToken !== csrfTokenStore) {
            return res.status(403).json({ error: 'CSRF Token 不匹配!' });
        }
    
        res.json({ message: `成功转账 ${amount} 元给 ${to}!` });
    });
  2. 重启 Victim 服务。

  3. 获取合法 Token:访问 Victim 站首页 http://localhost:3000,页面上会显示一个 Token。攻击者是无法通过脚本从 http://localhost:4000 跨域获取这个 Token 的(因为没有 CORS 读权限)。

  4. 再次访问 Attacker 页面 http://localhost:4000,提交 CSRF 表单。

现象:

  • Victim 服务端控制台:你会看到 CSRF Token mismatch! 的日志,请求被 403 Forbidden 拒绝了。

    额外思考: 如果我们把 CSRF Token 放在自定义请求头 X-CSRF-Token 中,会发生什么?

    根据实验二的知识,这会使简单请求变成复杂请求从而,触发一个 预检请求。如果 Victim 服务器没有通过 Access-Control-Allow-Headers 允许这个自定义头,那么这个跨域的 fetch 请求甚至在预检阶段就会被浏览器阻止,这本身也提供了一层保护 。这被称为 "Double Submit Cookie" 模式的变种,或者更简单的说,利用了 CORS 预检来增强 CSRF 防护
    Access to fetch at 'http://localhost:3000/transfer' from origin 'http://localhost:4000' has been blocked by CORS policy: Request header field x-csrf-token is not allowed by Access-Control-Allow-Headers in preflight response.

目标: 观察 SameSite Cookie 属性如何从根本上阻止浏览器在跨站请求中发送 Cookie。

SameSite 有三个值:

  • Strict:最严格。任何跨站请求(包括链接跳转、表单提交)都不会携带 Cookie。
  • Lax:适中 (现代浏览器默认值)。允许一些顶层导航的 GET 请求携带 Cookie (如点击链接跳转),但禁止跨站的 POST 请求、<img>、<iframe> 等携带 Cookie。
  • None:最宽松。允许所有跨站请求携带 Cookie,但 必须同时设置 Secure 属性(即只在 HTTPS 环境下发送)。

操作步骤 (演示 Strict 的效果):

  1. 关闭 CSRF Token 校验:在 victim/server.js 中注释掉 Token 校验逻辑,以便我们只观察 SameSite 的效果。

  2. 用 Strict 模式登录:清空浏览器的 Cookie。向 http://localhost:3000/login 发送 POST 请求,Body 为 {"samesite": "Strict"}

  3. 重启 Victim 服务。

  4. 再次访问 Attacker 页面 http://127.0.0.1:4000【和localhost不同站】,提交 CSRF 表单。

现象:

  • Victim 服务端控制台:你会看到转账请求日志,但 这次没有接收到 Cookie!
  • 攻击失败,因为没有凭证,请求被 401 Unauthorized 拒绝。

尝试:

  • 将 samesite 设置为 Lax,会得到和 Strict 同样的结果(因为 CSRF 表单是 POST 请求)。
  • 将 samesite 设置为 None,SameSite=None 会让 Cookie 重新被发送,CSRF 攻击会再次成功。

结论: SameSite 是防御 CSRF 的一道非常强大且根本的防线。将关键的会话 Cookie 设置为 Strict 或 Lax 可以有效缓解大部分 CSRF 风险。

交叉总结

维度 CORS (跨域资源共享) CSRF (跨站请求伪造)
核心问题 一个域的脚本想 "读取" 另一个域的资源,被浏览器阻止了。 攻击者诱导用户在已登录状态下,向目标站发送了一个非本意的 "写入" 请求。
谁是主角 浏览器、JavaScript (fetch, XMLHttpRequest) 浏览器、用户身份 (Cookie)、攻击者构造的请求 (HTML form, <img>, 链接)
浏览器行为 * 检查响应头 Access-Control-Allow-Origin。 * 如果不匹配,请求可能已成功,但响应对脚本不可见。 * 对复杂请求,先发 OPTIONS 预检。 * 自动携带 目标域的 Cookie (除非被 SameSite 阻止)。 * 不会主动阻止请求发送,只要是合法的 HTML 元素触发的请求。
服务端配置要点 * Access-Control-Allow-Origin * Access-Control-Allow-Methods * Access-Control-Allow-Headers * Access-Control-Allow-Credentials * (防御) 实施 CSRF Token 校验。 * (防御) 设置 Cookie 的 SameSite 属性为 Strict 或 Lax。
典型现象/报错 * 控制台红色 CORS policy 错误。 * Network 面板看到 OPTIONS 请求失败。 * 用户在不知情的情况下,数据被修改(如银行转账、修改密码)。 * 服务端日志显示一个合法的、带凭证的请求,但源头可疑。
联系与误区 * CORS 不是 CSRF 的解决方案:一个完全没配 CORS 的网站,恰恰是 CSRF 攻击的温床(如实验四)。 * 但 CORS 可以辅助防御 CSRF:如果一个写操作强制要求 Content-Type: application/json 或自定义头,它就会触发 CORS 预检。攻击者的跨域 fetch 若没有得到 CORS 许可,就会在预检阶段被浏览器拦截,从而间接防御了 CSRF。
相关推荐
崔庆才丨静觅21 分钟前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment1 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅1 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊1 小时前
jwt介绍
前端
爱敲代码的小鱼1 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte2 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc
NEXT062 小时前
前端算法:从 O(n²) 到 O(n),列表转树的极致优化
前端·数据结构·算法
剪刀石头布啊2 小时前
生成随机数,Math.random的使用
前端
剪刀石头布啊2 小时前
css外边距重叠问题
前端