安全审查--跨站请求伪造--同步令牌模式

安全Top10 https://cheatsheetseries.owasp.org/IndexTopTen.html


摘要:从小白开始逐层讲解同步令牌模式Synchronizer Token Pattern

第一层:理解问题(为什么要用同步令牌?)

1.1 一个生活中的例子

想象一下:

  • 你在银行办了一张卡

  • 银行给了你一张身份识别卡

  • 每次取钱时,银行都会核对你的身份识别卡

  • 只有卡对了,才让你取钱

这里的"身份识别卡"就是令牌(Token)。

1.2 网络世界的问题

复制代码
  <!-- 正常的转账表单 -->
  <form action="https://mybank.com/transfer" method="POST">
    <input name="to" value="张三">
    <input name="amount" value="100">
    <button>转账</button>
  </form>

攻击场景:

  • 你登录了我的银行网站

  • 然后访问了一个恶意网站(比如"可爱猫咪图片网")

  • 这个网站偷偷放了一张图片:

<!-- 这看起来是图片,其实是转账请求! -->

<img src="https://mybank.com/transfer?to=黑客\&amount=10000" />

  • 浏览器会自动发送这个请求,带着你的登录cookie

  • 钱就被转走了!

1.3 解决方案思路

如果我们给每个表单都加一个一次性密码会怎样?

  • 银行给你一个随机密码:abc123

  • 你提交表单时必须带上这个密码

  • 用过一次就失效

  • 黑客网站不知道这个密码

这就是同步令牌的核心思想!

第二层:基本概念和工作原理

2.1 什么是同步令牌?

同步���牌 = 服务器生成的一次性随机字符串

特点:

  • ✅ 随机性:黑客猜不到

  • ✅ 一次性:用完就失效

  • ✅ 同步性:服务器知道这个令牌

2.2 工作流程(带图解)

2.3 为什么黑客攻击失败?

复制代码
 <!-- 黑客的网站 -->
  <img src="https://mybank.com/transfer?to=黑客&amount=10000" />

问题:

  1. 黑客不知道你的token是什么

  2. 即使知道,token在服务器的session中,他拿不到

  3. 没有token,服务���拒绝请求

第三层:代码实现(从简单到复杂)

3.1 最简单的实现

复制代码
  // 1. 生成token(服务器端)
  function generateToken() {
    return Math.random().toString(36).substring(2); // 简单版,实际要用更安全的方法
  }

// 2. 用户访问页面时
  app.get('/transfer', (req, res) => {
    // 生成token并存在session里
    const token = generateToken();
    req.session.csrfToken = token;

    // 返回带token的表单
    res.send(`
      <form method="POST" action="/transfer">
        <input type="hidden" name="_token" value="${token}">
        <input name="amount" placeholder="金额">
        <button>转账</button>
      </form>
    `);
  });

// 3. 用户提交表单时
  app.post('/transfer', (req, res) => {
    const submittedToken = req.body._token;
    const sessionToken = req.session.csrfToken;

    // 检查token是否匹配
    if (submittedToken !== sessionToken) {
      return res.status(403).send('非法请求!');
    }

    // token用完就删除
    delete req.session.csrfToken;

    // 执行转账逻辑...
    res.send('转账成功!');
  });

3.2 更安全的实现

复制代码
const crypto = require('crypto');

  class SecureTokenManager {
    // 生成更安全的token
    static generateToken() {
      return crypto.randomBytes(32).toString('hex');
    }

    // 创建并返回token
    static create(req) {
      const token = this.generateToken();

      // 存储在session中
      req.session.csrfToken = token;
      req.session.csrfTokenTime = Date.now(); // 记录生成时间

      return token;
    }

    // 验证token
    static verify(req, submittedToken) {
      // 检查是否存在
      if (!submittedToken || !req.session.csrfToken) {
        return false;
      }

      // 检查是否匹配
      if (submittedToken !== req.session.csrfToken) {
        return false;
      }

      // 检查是否过期(5分钟)
      const tokenAge = Date.now() - (req.session.csrfTokenTime || 0);
      if (tokenAge > 5 * 60 * 1000) {
        return false;
      }

      // 验证通过,删除token(防止重复使用)
      delete req.session.csrfToken;
      delete req.session.csrfTokenTime;

      return true;
    }
  }

3.3 实际项目中的完整实现

复制代码
// middleware/csrf.js
  const csrfProtection = {
    // 为GET请求添加token
    addToken: (req, res, next) => {
      if (req.method === 'GET' && req.session) {
        // 为每个GET请求生成token
        res.locals.csrfToken = SecureTokenManager.create(req);
      }
      next();
    },

    // 验证POST请求的token
    validateToken: (req, res, next) => {
      // GET请求不需要验证
      if (req.method === 'GET') {
        return next();
      }

      // 从表单或header获取token
      const token = req.body._csrf ||
                    req.headers['x-csrf-token'] ||
                    req.query._csrf;

      if (!SecureTokenManager.verify(req, token)) {
        // 记录攻击尝试
        console.warn('CSRF攻击尝试:', {
          ip: req.ip,
          userAgent: req.get('User-Agent'),
          url: req.url
        });

        return res.status(403).json({
          error: 'CSRF token验证失败',
          code: 'INVALID_CSRF_TOKEN'
        });
      }

      next();
    }
  };

// app.js
  app.use(session({
    secret: 'your-secret-key',
    resave: false,
    saveUninitialized: false
  }));

  // 应用CSRF保护
  app.use(csrfProtection.addToken);
  app.use(csrfProtection.validateToken);

第四层:前端如何配合

4.1 传统表单

复制代码
<!-- EJS模板示例 -->
  <!DOCTYPE html>
  <html>
  <body>
    <h1>转账页面</h1>

    <!-- 这里的csrfToken由服务器提供 -->
    <form method="POST" action="/transfer">
      <!-- 隐藏字段存储token -->
      <input type="hidden" name="_csrf" value="<%= csrfToken %>">

      <div>
        <label>收款人:</label>
        <input type="text" name="recipient" required>
      </div>

      <div>
        <label>金额:</label>
        <input type="number" name="amount" required>
      </div>

      <button type="submit">确认转账</button>
    </form>
  </body>
  </html>

4.2 AJAX请求

复制代码
// 获取当前页面的token
  function getCsrfToken() {
    // 方式1:从meta标签获取
    return document.querySelector('meta[name="csrf-token"]')?.content;

    // 方式2:从隐藏表单字段获取
    // return document.querySelector('input[name="_csrf"]')?.value;
  }

  // 发送AJAX请求
  async function makeTransfer(recipient, amount) {
    const token = getCsrfToken();

    if (!token) {
      alert('安全令牌丢失,请刷新页面');
      return;
    }

    try {
      const response = await fetch('/api/transfer', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': token // 在header中发送token
        },
        body: JSON.stringify({
          recipient: recipient,
          amount: amount
        })
      });

      if (response.ok) {
        const result = await response.json();
        alert('转账成功!');
      } else {
        const error = await response.json();
        alert('转账失败:' + error.error);
      }
    } catch (error) {
      alert('请求失败:' + error.message);
    }
  }

 // 使用示例
  document.getElementById('transfer-btn').addEventListener('click', () => {
    const recipient = document.getElementById('recipient').value;
    const amount = document.getElementById('amount').value;
    makeTransfer(recipient, amount);
  });

第五层:常见问题和解决方案

5.1 多标签页问题

问题:用户打开多个标签页,后面的请求会让前面的token失效 解决方案:

复制代码
// 生成多个token,每个表单一个
  class MultiTokenManager {
    static createToken(req, formId) {
      const token = crypto.randomBytes(32).toString('hex');

      if (!req.session.csrfTokens) {
        req.session.csrfTokens = {};
      }

      req.session.csrfTokens[formId] = {
        token: token,
        time: Date.now()
      };

      return token;
    }

    static verifyToken(req, formId, submittedToken) {
      const storedToken = req.session.csrfTokens?.[formId];

      if (!storedToken || storedToken.token !== submittedToken) {
        return false;
      }

      // 删除已使用的token
      delete req.session.csrfTokens[formId];

      return true;
    }
  }

5.2 AJAX SPA应用

问题:单页面应用需要动态获取token 解决方案:

复制代码
// 提供获取token的API
  app.get('/api/csrf-token', (req, res) => {
    const token = SecureTokenManager.create(req);
    res.json({ csrfToken: token });
  });

  // 前端定期刷新token
  class TokenManager {
    constructor() {
      this.token = null;
      this.refreshInterval = null;
    }

    async init() {
      await this.refreshToken();
      // 每30分钟刷新一次
      this.refreshInterval = setInterval(() => {
        this.refreshToken();
      }, 30 * 60 * 1000);
    }

    async refreshToken() {
      const response = await fetch('/api/csrf-token');
      const data = await response.json();
      this.token = data.csrfToken;
    }

    getToken() {
      return this.token;
    }

    destroy() {
      if (this.refreshInterval) {
        clearInterval(this.refreshInterval);
      }
    }
  }

5.3 测试CSRF防护

复制代码
 // 测试没有token的请求
  test('应该拒绝没有CSRF token的请求', async () => {
    const response = await request(app)
      .post('/transfer')
      .send({ recipient: 'user2', amount: 100 })
      .expect(403);

    expect(response.body.error).toBe('CSRF token验证失败');
  });

  // 测试有正确token的请求
  test('应该接受有效CSRF token的请求', async () => {
    // 先获取token
    const response1 = await request(app)
      .get('/transfer')
      .expect(200);

    // 从HTML中提取token
    const tokenMatch = response1.text.match(/name="_csrf" value="([^"]+)"/);
    const token = tokenMatch[1];

    // 使用token发送请求
    const response2 = await request(app)
      .post('/transfer')
      .send({
        _csrf: token,
        recipient: 'user2',
        amount: 100
      })
      .expect(200);

    expect(response2.body.success).toBe(true);
  });

第六层:最佳实践和注意事项

6.1 安全最佳实践

// ✅ 推荐做法

  1. 使用加密安全的随机数生成器

  2. Token要有足够的长度(至少32字节)

  3. Token使用后立即失效

  4. 设置合理的过期时间(5-10分钟)

  5. 重要操作要求二次确认

  6. 记录CSRF攻击尝试

  7. 结合其他安全措施(SameSite Cookie等)

// ❌ 避免的做法

  1. 用时间戳或可预测的数据做token

  2. Token存储在前端localstorage

  3. 多个页面共享同一个token

  4. Token永不过期

  5. 在GET请求中执行敏感操作

6.2 性能优化

复制代码
 // 减少session存储
  class OptimizedTokenManager {
    // 使用Redis等外部存储
    static async createToken(req) {
      const token = crypto.randomBytes(32).toString('hex');
      const sessionId = req.sessionID;

      // 存储在Redis,设置过期时间
      await redis.setex(`csrf:${sessionId}`, 300, token); // 5分钟过期

      return token;
    }

    static async verifyToken(req, token) {
      const sessionId = req.sessionID;
      const storedToken = await redis.get(`csrf:${sessionId}`);

      if (storedToken === token) {
        // 删除token
        await redis.del(`csrf:${sessionId}`);
        return true;
      }

      return false;
    }
  }

6.3 错误处理

复制代码
// 友好的错误提示
  app.use((err, req, res, next) => {
    if (err.code === 'EBADCSRFTOKEN') {
      // 如果是AJAX请求,返回JSON
      if (req.xhr || req.headers.accept.includes('application/json')) {
        return res.status(403).json({
          error: '安全验证失败,请刷新页面重试',
          code: 'CSRF_EXPIRED'
        });
      }

      // 普通请求返回页面
      return res.status(403).send(`
        <h1>安全验证失败</h1>
        <p>您的操作已过期,请刷新页面后重试</p>
        <button onclick="location.reload()">刷新页面</button>
      `);
    }

    next(err);
  });

总结

同步令牌模式就像:

  1. 银行给你的一次性密码:每次交易都需要

  2. 用完即废:防止重复使用

  3. 只有你知道:黑客猜不到

  4. 银行记录在案:服务器能验证

通过这种机制,即使黑客诱骗你点击恶意链接,由于没有正确的令牌,请求也会被拒绝,从而保护了你的安全。

相关推荐
旺旺的碎冰冰~3 小时前
(八)正确安全规约
安全·规约·证明
#微爱帮#4 小时前
微爱帮监狱寄信写信小程序使用DeepSeek智能写信助手的技术文档
安全
北京云帆互联科技4 小时前
云帆国产化培训考试系统:为数字化转型构筑安全智能的人才培养基石
安全·考试系统·高校考试系统·国产化考试系统·企业培训系统·培训考试系统
用户47949283569155 小时前
CVE-2025-55182:React 史上最严重漏洞,CVSS 满分 10.0
安全·react.js·全栈
隐语SecretFlow6 小时前
如何在 Kuscia 上运行 SCQL 联合分析任务
分布式·安全·架构·开源
网安小白的进阶之路6 小时前
B模块 安全通信网络 第二门课 核心网路由技术-1-OSPF邻居表建立
网络·安全
猴哥聊项目管理7 小时前
2025年项目管理软件10款云原生部署方案的稳定性对比
安全·云原生·金融·软件工程·项目管理工具·项目管理软件·企业管理
汽车仪器仪表相关领域8 小时前
SCG-1 增压 + 空燃比二合一仪表:涡轮改装的 “空间杀手” 与 “安全保镖”
大数据·服务器·人工智能·功能测试·安全·汽车·可用性测试
松☆8 小时前
OpenHarmony + Flutter 车机系统开发实战:构建高性能、高安全的智能座舱应用
安全·flutter