策略:防范服务器端请求伪造 (SSRF) - 以用户 Prompt API 为例
服务器端请求伪造 (SSRF) 是一种安全漏洞,攻击者利用服务器发起非预期的网络请求。当应用程序根据用户提供的输入构建请求 URL,并且未能充分验证或清理这些输入时,就可能发生 SSRF 攻击。这可能导致服务器访问内部资源、扫描内部网络、访问敏感数据或执行其他恶意操作。
本策略旨在说明 SSRF 漏洞的风险,并提供在后端 API 中防范此类攻击的实践方法,以我们应用中发现的一个具体案例(用户 Prompt API)为例进行说明。
问题描述:基于用户输入的 URL 导致 SSRF 风险
在前端文件 pages/dashboard.js
中,存在一个获取用户 Prompt 列表的逻辑,该逻辑直接使用了从 URL 查询参数中获取的 userId
来构建后端 API 的请求 URL:
1 | // pages/dashboard.js:131 |
虽然前端代码中使用了 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 或路径。必须对所有来自用户的输入进行严格的验证、清理和授权检查。
以下是针对此案例提炼出的防范策略和实践:
策略:严格验证用户输入的 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
13import 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'
});
}
// ... 后续逻辑
策略:实施严格的权限检查。
- 说明: 在后端 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: '没有权限查看其他用户的提示词'
});
}
// ... 后续逻辑
- 说明: 在后端 API 中,必须检查发出请求的用户是否有权限访问
策略:对用户输入进行清理。
- 说明: 移除用户输入中任何潜在的危险字符,特别是那些可能用于路径遍历 (
../
) 或其他注入攻击的字符。 - 实践案例(前端或后端,取决于清理的最佳时机):
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 进行后续操作
- 说明: 移除用户输入中任何潜在的危险字符,特别是那些可能用于路径遍历 (
策略:在后端 API 中再次进行验证和权限检查。
- 说明: 即使前端已经进行了验证,后端也必须重复相同的验证和权限检查逻辑。这是因为前端的验证可以被绕过,而后端是执行敏感操作的地方,必须确保输入的安全性。
- 实践案例: 参见策略 1 和策略 2 的后端代码示例。
策略:实施速率限制。
- 说明: 限制用户在一定时间内可以发起的请求次数,可以减轻自动化攻击(如暴力破解用户 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' });
}
策略:记录可疑活动。
- 说明: 记录任何看起来可疑的请求,例如无效 ID 格式、未授权访问尝试或速率限制触发,以便进行安全审计和分析。
- 实践案例(后端 API):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21const 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 漏洞和其他相关的访问控制问题。