Web 的 CSRF 防护

使用 node.js 后端的 CSRF 程序

Posted by Jerry Chen on December 1, 2024

CSRF 是什么

CSRF (Cross-Site Request Forgery,跨站请求伪造) 是一种攻击方式。

攻击者诱导已经登录的用户在不知情的情况下执行一些非本意的操作。

CSRF 攻击示例

假设一个场景:

  1. 用户已经登录了银行网站 A;

  2. 用户访问了恶意网站 B;

  3. 恶意网站 B 包含了一个自动提交的表单,指向银行网站 A 的转账接口;

  4. 因为用户已登录银行网站 A,所以这个请求会带着用户的身份信息自动执行;

其中第 4 点跟浏览器的 Cookie 的自动携带机制有关。

当浏览器发送请求时,会自动携带与目标域名相关的所有 Cookie。这是浏览器的默认行为,与发起请求的页面来源无关。

简单的 CSRF 攻击代码示例

1
2
3
4
5
6
7
8
<!-- 恶意网站的代码 -->
<form action="https://bank.com/transfer" method="POST" id="hack-form">
    <input type="hidden" name="to" value="hacker-account" />
    <input type="hidden" name="amount" value="10000" />
</form>
<script>
    document.getElementById('hack-form').submit();
</script>

防御 CSRF 的主要方法

  1. 使用 CSRF Token

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    # 后端生成 token
    csrf_token = generate_random_token()
    session['csrf_token'] = csrf_token
       
    # 前端表单中包含 token
    <form action="/transfer" method="POST">
        <input type="hidden" name="csrf_token" value="">
        ...
    </form>
    
  2. 验证 Referer

    1
    2
    3
    
    def check_referer(request):
        referer = request.headers.get('Referer')
        return referer and referer.startswith('https://your-site.com')
    
  3. 使用 SameSite Cookie 属性

    1
    2
    
    # 设置 Cookie
    response.set_cookie('sessionid', 'abc123', samesite='Strict')
    

一个使用 CSRF Token 的网站示例

文件清单如下:

1
2
views/index.ejs
app.js

views/index.ejs 是一个模板文件,当前访问网页的时候,其中 <%= csrfToken %> 会被进行替换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<!DOCTYPE html>
<html>
<head>
    <title>CSRF 保护示例</title>
    <style>
        .container {
            display: flex;
            justify-content: space-around;
            margin: 20px;
        }
        .form-container {
            padding: 20px;
            border: 1px solid #ccc;
            border-radius: 5px;
        }
        .success { color: green; }
        .error { color: red; }
    </style>
</head>
<body>
    <h1>CSRF 保护演示</h1>
    
    <div class="container">
        <div class="form-container">
            <h2>带 CSRF 保护的表单</h2>
            <form action="/submit" method="POST">
                <input type="hidden" name="_csrf" value="<%= csrfToken %>">
                <div>
                    <label for="username1">用户名:</label>
                    <input type="text" id="username1" name="username">
                </div>
                <button type="submit">安全提交</button>
            </form>
        </div>

        <div class="form-container">
            <h2>无 CSRF 保护的表单</h2>
            <form action="/submit-unsafe" method="POST">
                <div>
                    <label for="username2">用户名:</label>
                    <input type="text" id="username2" name="username">
                </div>
                <button type="submit">不安全提交</button>
            </form>
        </div>
    </div>

    <script>
        // 显示提交结果
        async function submitForm(event, hasProtection) {
            event.preventDefault();
            const form = event.target;
            try {
                const response = await fetch(form.action, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded',
                    },
                    body: new URLSearchParams(new FormData(form)),
                    credentials: 'include'
                });
                const result = await response.text();
                alert(hasProtection ? '安全表单:' + result : '不安全表单:' + result);
            } catch (error) {
                alert('提交失败:' + error.message);
            }
        }

        // 为表单添加提交事件处理
        document.querySelectorAll('form').forEach(form => {
            form.onsubmit = (e) => submitForm(e, form.action.includes('submit'));
        });
    </script>
</body>
</html> 

app.js 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
const express = require('express');
const crypto = require('crypto');
const app = express();

// 设置使用 ejs 模板引擎
// express 默认会在 views 目录下查找模板文件
app.set('view engine', 'ejs');
// 解析 application/x-www-form-urlencoded 格式的请求体
app.use(express.urlencoded({ extended: true }));

// 内存中存储会话数据: { sessionId: { csrfToken, lastAccess } }
const sessions = new Map();

// 生成密码学安全的随机ID和令牌
function generateSessionId() {
    return crypto.randomBytes(32).toString('hex');
}

function generateToken() {
    return crypto.randomBytes(32).toString('hex');
}

// 会话管理中间件
app.use((req, res, next) => {
    let sessionId = req.headers.cookie?.split(';')
        .find(c => c.trim().startsWith('sessionId='))
        ?.split('=')[1];

    if (!sessionId || !sessions.has(sessionId)) {
        sessionId = generateSessionId();
        sessions.set(sessionId, {
            csrfToken: generateToken(),
            lastAccess: Date.now()
        });
        res.setHeader('Set-Cookie', `sessionId=${sessionId}; HttpOnly; Path=/`);
    }

    req.session = sessions.get(sessionId);
    req.session.lastAccess = Date.now(); // 更新最后访问时间
    next();
});

// CSRF 保护中间件
function csrfProtection(req, res, next) {
    if (req.method === 'GET') return next();

    const token = req.body._csrf;
    if (!token || token !== req.session.csrfToken) {
        return res.status(403).send('CSRF 令牌验证失败');
    }
    next();
}

// 路由处理
app.get('/', (req, res) => {
    res.render('index', { csrfToken: req.session.csrfToken });
});

// 带 CSRF 保护的路由
app.post('/submit', csrfProtection, (req, res) => {
    res.send('表单提交成功!用户名: ' + req.body.username);
});

// 不带 CSRF 保护的路由
app.post('/submit-unsafe', (req, res) => {
    res.send('不安全的表单提交成功!用户名: ' + req.body.username);
});

// 清理过期会话(30分钟无活动)
setInterval(() => {
    const expireTime = Date.now() - 30 * 60 * 1000;
    for (let [sessionId, session] of sessions.entries()) {
        if (session.lastAccess < expireTime) {
            sessions.delete(sessionId);
        }
    }
}, 5 * 60 * 1000);

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

安装依赖:

1
npm install express ejs

运行服务器:

1
node app.js

打开 http://localhost:3000,会发现有两个表单,对应两个 post 接口:

  • 带 CSRF 保护的表单:对应 /submit
  • 无 CSRF 保护的表单:对应 /submit-unsafe

这两个都是可以正常提交的。

我们编写一个恶意网站 evil.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html>
<head>
    <title>恶意网站</title>
</head>
<body>
    <h1>恶意网站</h1>
    
    <h2>尝试攻击有保护的端点</h2>
    <form action="http://localhost:3000/submit" method="POST">
        <input type="hidden" name="username" value="黑客攻击保护接口">
        <button type="submit">攻击受保护的接口</button>
    </form>

    <h2>尝试攻击无保护的端点</h2>
    <form action="http://localhost:3000/submit-unsafe" method="POST">
        <input type="hidden" name="username" value="黑客攻击无保护接口">
        <button type="submit">攻击未保护的接口</button>
    </form>
</body>
</html>

访问后,有两个表单按钮:

  • 点击 “攻击受保护的接口” 后返回 “CSRF 令牌验证失败”;
  • 点击 “攻击未保护的接口” 后返回 “不安全的表单提交成功!”;