策略:防范服务器端请求伪造 (SSRF) - 以用户 Prompt API 为例

服务器端请求伪造 (SSRF) 是一种安全漏洞,攻击者利用服务器发起非预期的网络请求。当应用程序根据用户提供的输入构建请求 URL,并且未能充分验证或清理这些输入时,就可能发生 SSRF 攻击。这可能导致服务器访问内部资源、扫描内部网络、访问敏感数据或执行其他恶意操作。

本策略旨在说明 SSRF 漏洞的风险,并提供在后端 API 中防范此类攻击的实践方法,以我们应用中发现的一个具体案例(用户 Prompt API)为例进行说明。

问题描述:基于用户输入的 URL 导致 SSRF 风险

在前端文件 pages/dashboard.js 中,存在一个获取用户 Prompt 列表的逻辑,该逻辑直接使用了从 URL 查询参数中获取的 userId 来构建后端 API 的请求 URL:

1
2
3
4
5
6
7
// pages/dashboard.js:131
setLoadingPrompts(true);
setError(null);
try {
const res = await fetch(`/api/users/${userId}/prompts`); // 这里的 userId 直接来自用户输入
// ...
}

虽然前端代码中使用了 fetch API,但如果后端 API (pages/api/users/[userId]/prompts.js) 没有对 userId 进行严格的验证和权限检查,攻击者可以通过修改 URL 中的 userId 参数,尝试访问其他用户的 Prompt 数据,甚至如果服务器配置不当,可能被诱导访问内部服务或执行其他恶意请求。

根本原因分析

问题根源在于后端 API (pages/api/users/[userId]/prompts.js) 在处理 /api/users/${userId}/prompts 请求时,未能充分验证 URL 路径参数 userId 的合法性(是否是有效的用户 ID 格式)以及请求用户是否有权限访问该 userId 对应的数据。前端直接使用用户输入的 userId 构建 URL 加剧了风险。

安全风险

  • 数据泄露: 攻击者可能绕过权限检查,访问其他用户的敏感数据(如 Prompt 内容)。
  • 权限绕过: 攻击者可能利用此漏洞执行他们本无权执行的操作。
  • 内部服务探测/访问: 如果服务器能够访问内部网络,攻击者可能利用 SSRF 探测或攻击内部服务。
  • 路径遍历: 虽然在此案例中直接路径遍历风险较低,但在其他构建文件路径或 URL 的场景中,未经验证的用户输入可能导致路径遍历攻击。

防范策略与实践

防范 SSRF 的核心原则是永远不要信任用户输入来构建请求的 URL 或路径。必须对所有来自用户的输入进行严格的验证、清理和授权检查。

以下是针对此案例提炼出的防范策略和实践:

  1. 策略:严格验证用户输入的 ID 格式。

    • 说明: 对于作为数据库记录 ID 的用户输入,必须验证其格式是否符合预期的 ID 格式(例如,MongoDB 的 ObjectId 是 24 位十六进制字符串)。无效格式的请求应直接拒绝。
    • 实践案例(前端 pages/dashboard.js):
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      // 验证 userId 是否为有效的 MongoDB ObjectId
      const isValidObjectId = (id) => {
      // MongoDB ObjectId 是24位十六进制字符串
      return /^[0-9a-fA-F]{24}$/.test(id);
      };

      const fetchUserPrompts = async (userId) => {
      // 1. 验证 userId 格式
      if (!userId || !isValidObjectId(userId)) {
      setError('无效的用户ID格式');
      setLoadingPrompts(false);
      return;
      }
      // ... 后续逻辑
      };
    • 实践案例(后端 pages/api/users/[userId]/prompts.js):
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      import mongoose from 'mongoose'; // 确保导入 mongoose

      // ... API handler 函数开始 ...
      const { query: { userId }, method } = req;

      // 验证 userId 是否是有效的 MongoDB ObjectId
      if (!userId || !mongoose.Types.ObjectId.isValid(userId)) {
      return res.status(400).json({
      success: false,
      error: 'Invalid user ID format'
      });
      }
      // ... 后续逻辑
  2. 策略:实施严格的权限检查。

    • 说明: 在后端 API 中,必须检查发出请求的用户是否有权限访问 userId 对应的资源。通常,用户只能访问自己的数据,除非是管理员或其他具有特殊权限的用户。
    • 实践案例(前端 pages/dashboard.js):
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      // 验证用户权限
      const hasPermissionToView = (currentUserId, targetUserId, isAdmin) => {
      return currentUserId === targetUserId || isAdmin;
      };

      const fetchUserPrompts = async (userId) => {
      // ... 格式验证后 ...
      // 2. 检查权限 (需要从 session 或其他地方获取当前用户ID和管理员状态)
      // 假设 session 和 isAdmin 已经在组件中可用
      if (!hasPermissionToView(session?.user?.id, userId, isAdmin)) {
      setError('没有权限查看此用户的提示词');
      setLoadingPrompts(false);
      return;
      }
      // ... 后续逻辑
      };
    • 实践案例(后端 pages/api/users/[userId]/prompts.js):
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      // ... 格式验证后 ...
      // 权限检查 (需要从 session 中获取当前用户ID和角色)
      const session = await getSession({ req }); // 确保导入 getSession
      if (!session) {
      return res.status(401).json({ success: false, error: 'Authentication required' });
      }

      const isOwnDashboard = session.user.id === userId;
      // 假设用户角色存储在 session.user.role 中
      const isAdmin = session.user.role === 'admin';

      if (!isOwnDashboard && !isAdmin) {
      return res.status(403).json({
      success: false,
      error: '没有权限查看其他用户的提示词'
      });
      }
      // ... 后续逻辑
  3. 策略:对用户输入进行清理。

    • 说明: 移除用户输入中任何潜在的危险字符,特别是那些可能用于路径遍历 (../) 或其他注入攻击的字符。
    • 实践案例(前端或后端,取决于清理的最佳时机):
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      // 清理和验证用户输入
      const sanitizeUserId = (userId) => {
      if (!userId || typeof userId !== 'string') {
      return null;
      }

      // 移除潜在的危险字符,只保留十六进制字符
      const cleaned = userId.replace(/[^a-fA-F0-9]/g, '');

      // 验证长度和格式
      if (cleaned.length === 24 && isValidObjectId(cleaned)) { // 结合格式验证
      return cleaned;
      }

      return null;
      };

      // 在使用 userId 之前先进行清理和验证
      const cleanUserId = sanitizeUserId(userId);
      if (!cleanUserId) {
      // 处理无效输入
      }
      // 使用 cleanUserId 进行后续操作
  4. 策略:在后端 API 中再次进行验证和权限检查。

    • 说明: 即使前端已经进行了验证,后端也必须重复相同的验证和权限检查逻辑。这是因为前端的验证可以被绕过,而后端是执行敏感操作的地方,必须确保输入的安全性。
    • 实践案例: 参见策略 1 和策略 2 的后端代码示例。
  5. 策略:实施速率限制。

    • 说明: 限制用户在一定时间内可以发起的请求次数,可以减轻自动化攻击(如暴力破解用户 ID)的影响。
    • 实践案例(后端 API):
      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
      // 简单的内存速率限制示例 (生产环境应使用更可靠的存储,如 Redis)
      const rateLimitMap = new Map();
      const MAX_REQUESTS_PER_MINUTE = 10;
      const WINDOW_MS = 60 * 1000; // 1 minute

      const checkRateLimit = (userId) => {
      const now = Date.now();
      const userRequests = rateLimitMap.get(userId) || [];

      // 清理窗口期外的请求记录
      const recentRequests = userRequests.filter(time => now - time < WINDOW_MS);

      if (recentRequests.length >= MAX_REQUESTS_PER_MINUTE) {
      return false; // 超过速率限制
      }

      recentRequests.push(now);
      rateLimitMap.set(userId, recentRequests);
      return true; // 未超过速率限制
      };

      // 在 API handler 开始时调用
      if (!checkRateLimit(session.user.id)) { // 对当前登录用户进行速率限制
      return res.status(429).json({ success: false, error: 'Too many requests' });
      }
  6. 策略:记录可疑活动。

    • 说明: 记录任何看起来可疑的请求,例如无效 ID 格式、未授权访问尝试或速率限制触发,以便进行安全审计和分析。
    • 实践案例(后端 API):
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      const logSuspiciousActivity = (type, details) => {
      console.warn(`可疑安全活动 (${type}):`, {
      ...details,
      timestamp: new Date().toISOString()
      });
      };

      // 在验证失败或权限检查失败时调用
      if (!isValidObjectId(userId)) {
      logSuspiciousActivity('InvalidUserIdFormat', { userId, ip: req.socket.remoteAddress });
      // ... 返回错误响应
      }

      if (!isOwnDashboard && !isAdmin) {
      logSuspiciousActivity('UnauthorizedAccess', {
      requestingUserId: session.user.id,
      targetUserId: userId,
      ip: req.socket.remoteAddress
      });
      // ... 返回错误响应
      }

验证修复

应用上述策略后,应进行以下测试以验证修复效果:

  • 正常用户访问: 登录用户只能成功访问自己的 Prompt 列表。
  • 管理员访问: 管理员用户可以成功访问所有用户的 Prompt 列表。
  • 无效 ID 拒绝: 使用非法的 userId 格式(例如,非 24 位十六进制字符串)请求 API 应返回 400 Bad Request 错误。
  • 未授权访问阻止: 尝试使用一个用户的身份访问另一个用户的 Prompt 列表应返回 403 Forbidden 错误。
  • 速率限制测试: 在短时间内发起大量请求应触发速率限制,并返回 429 Too Many Requests 错误。

总结

通过在前端和后端都实施严格的输入验证、清理和权限检查,并辅以速率限制和日志记录,可以有效地防范基于用户输入的 URL 导致的 SSRF 漏洞和其他相关的访问控制问题。

更多资源


© 2025 vmoranv 使用 Stellar 创建


😊本站2025.05.05日起🎉累计访问人次💻


614447.xyz