🔒 安全加固:修复多个中高危漏洞
修复内容: 1. Host Header 注入 - 添加 PUBLIC_BASE_URL 和 ALLOWED_HOSTS 白名单 2. API密钥暴力破解 - 添加速率限制(5次/小时,封锁24小时) 3. 路径遍历漏洞 - 增强路径验证,防止空字节注入和目录遍历 4. 令牌安全 - 密码重置和邮箱验证令牌使用SHA256哈希存储 5. 文件上传安全 - 阻止PHP/JSP/ASP等可执行脚本上传 6. IDOR防护 - 增强权限验证和安全日志 7. XSS防护 - 增强输入过滤,阻止javascript:等危险协议 8. 日志脱敏 - 移除验证码等敏感信息的日志输出 9. CSRF增强 - HTTPS环境使用strict模式Cookie 10. 邮箱枚举防护 - 密码重置统一返回消息 11. 速率限制 - 文件列表(60次/分)和上传(100次/小时) 配置说明: - PUBLIC_BASE_URL: 必须配置,用于生成安全的邮件链接 - ALLOWED_HOSTS: 可选,Host头白名单 - COOKIE_SECURE=true: 生产环境必须开启 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -492,16 +492,20 @@ const SettingsDB = {
|
||||
}
|
||||
};
|
||||
|
||||
// 邮箱验证管理
|
||||
// 邮箱验证管理(增强安全:哈希存储)
|
||||
const VerificationDB = {
|
||||
setVerification(userId, token, expiresAtMs) {
|
||||
// 对令牌进行哈希后存储
|
||||
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET verification_token = ?, verification_expires_at = ?, is_verified = 0, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(token, expiresAtMs, userId);
|
||||
`).run(hashedToken, expiresAtMs, userId);
|
||||
},
|
||||
consumeVerificationToken(token) {
|
||||
// 对用户提供的令牌进行哈希
|
||||
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
|
||||
const row = db.prepare(`
|
||||
SELECT * FROM users
|
||||
WHERE verification_token = ?
|
||||
@@ -512,7 +516,7 @@ const VerificationDB = {
|
||||
OR verification_expires_at > CURRENT_TIMESTAMP -- 兼容旧的字符串时间
|
||||
)
|
||||
AND is_verified = 0
|
||||
`).get(token);
|
||||
`).get(hashedToken);
|
||||
if (!row) return null;
|
||||
|
||||
db.prepare(`
|
||||
@@ -524,23 +528,30 @@ const VerificationDB = {
|
||||
}
|
||||
};
|
||||
|
||||
// 密码重置 Token 管理
|
||||
// 密码重置 Token 管理(增强安全:哈希存储)
|
||||
const PasswordResetTokenDB = {
|
||||
// 创建令牌时存储哈希值
|
||||
create(userId, token, expiresAtMs) {
|
||||
// 对令牌进行哈希后存储(防止数据库泄露时令牌被直接使用)
|
||||
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
|
||||
db.prepare(`
|
||||
INSERT INTO password_reset_tokens (user_id, token, expires_at, used)
|
||||
VALUES (?, ?, ?, 0)
|
||||
`).run(userId, token, expiresAtMs);
|
||||
`).run(userId, hashedToken, expiresAtMs);
|
||||
},
|
||||
// 验证令牌时先哈希再比较
|
||||
use(token) {
|
||||
// 对用户提供的令牌进行哈希
|
||||
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
|
||||
const row = db.prepare(`
|
||||
SELECT * FROM password_reset_tokens
|
||||
WHERE token = ? AND used = 0 AND (
|
||||
expires_at > strftime('%s','now')*1000 -- 数值时间戳
|
||||
OR expires_at > CURRENT_TIMESTAMP -- 兼容旧的字符串时间
|
||||
)
|
||||
`).get(token);
|
||||
`).get(hashedToken);
|
||||
if (!row) return null;
|
||||
// 立即标记为已使用(防止重复使用)
|
||||
db.prepare(`UPDATE password_reset_tokens SET used = 1 WHERE id = ?`).run(row.id);
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const archiver = require('archiver');
|
||||
const { exec, execSync } = require('child_process');
|
||||
const { exec, execSync, execFile } = require('child_process');
|
||||
const net = require('net');
|
||||
const dns = require('dns').promises;
|
||||
const util = require('util');
|
||||
const execAsync = util.promisify(exec);
|
||||
const execFileAsync = util.promisify(execFile);
|
||||
|
||||
const { db, UserDB, ShareDB, SettingsDB, VerificationDB, PasswordResetTokenDB } = require('./database');
|
||||
const { generateToken, authMiddleware, adminMiddleware } = require('./auth');
|
||||
@@ -27,6 +28,40 @@ const PORT = process.env.PORT || 40001;
|
||||
const USERNAME_REGEX = /^[A-Za-z0-9_.\u4e00-\u9fa5-]{3,20}$/u; // 允许中英文、数字、下划线、点和短横线
|
||||
const ENFORCE_HTTPS = process.env.ENFORCE_HTTPS === 'true';
|
||||
|
||||
// ===== 安全配置:公开域名白名单(防止 Host Header 注入) =====
|
||||
// 必须配置 PUBLIC_BASE_URL 环境变量,用于生成邮件链接和分享链接
|
||||
// 例如: PUBLIC_BASE_URL=https://cloud.example.com
|
||||
const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL || null;
|
||||
const ALLOWED_HOSTS = process.env.ALLOWED_HOSTS
|
||||
? process.env.ALLOWED_HOSTS.split(',').map(h => h.trim().toLowerCase())
|
||||
: [];
|
||||
|
||||
// 获取安全的基础URL(用于生成邮件链接、分享链接等)
|
||||
function getSecureBaseUrl(req) {
|
||||
// 优先使用配置的公开域名
|
||||
if (PUBLIC_BASE_URL) {
|
||||
return PUBLIC_BASE_URL.replace(/\/+$/, ''); // 移除尾部斜杠
|
||||
}
|
||||
|
||||
// 如果没有配置,验证 Host 头是否在白名单中
|
||||
const host = (req.get('host') || '').toLowerCase();
|
||||
if (ALLOWED_HOSTS.length > 0 && !ALLOWED_HOSTS.includes(host)) {
|
||||
console.warn(`[安全警告] 检测到非白名单 Host 头: ${host}`);
|
||||
// 返回第一个白名单域名作为后备
|
||||
const protocol = getProtocol(req);
|
||||
return `${protocol}://${ALLOWED_HOSTS[0]}`;
|
||||
}
|
||||
|
||||
// 开发环境回退(仅在没有配置时使用)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
return `${getProtocol(req)}://${req.get('host')}`;
|
||||
}
|
||||
|
||||
// 生产环境没有配置时,记录警告并使用请求的 Host(不推荐)
|
||||
console.error('[安全警告] 生产环境未配置 PUBLIC_BASE_URL,存在 Host Header 注入风险!');
|
||||
return `${getProtocol(req)}://${req.get('host')}`;
|
||||
}
|
||||
|
||||
// 在反向代理(如 Nginx/Cloudflare)后部署时,信任代理以正确识别协议/IP/HTTPS
|
||||
app.set('trust proxy', process.env.TRUST_PROXY || true);
|
||||
|
||||
@@ -126,19 +161,32 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// XSS过滤中间件(用于用户输入)
|
||||
// XSS过滤中间件(用于用户输入)- 增强版
|
||||
function sanitizeInput(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
return str
|
||||
.replace(/[<>'"]/g, (char) => {
|
||||
|
||||
// 1. 基础HTML实体转义
|
||||
let sanitized = str
|
||||
.replace(/[&<>"'\/`]/g, (char) => {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
'`': '`'
|
||||
};
|
||||
return map[char];
|
||||
});
|
||||
|
||||
// 2. 过滤危险协议(javascript:, data:, vbscript:等)
|
||||
sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, '');
|
||||
|
||||
// 3. 移除空字节
|
||||
sanitized = sanitized.replace(/\x00/g, '');
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// HTML转义(用于模板输出)
|
||||
@@ -202,12 +250,44 @@ function isSafePathSegment(name) {
|
||||
return (
|
||||
typeof name === 'string' &&
|
||||
name.length > 0 &&
|
||||
name.length <= 255 && // 限制文件名长度
|
||||
!name.includes('..') &&
|
||||
!/[/\\]/.test(name) &&
|
||||
!/[\x00-\x1F]/.test(name)
|
||||
);
|
||||
}
|
||||
|
||||
// 危险文件扩展名黑名单(仅限可能被Web服务器解析执行的脚本文件)
|
||||
// 注意:这是网盘应用,.exe等可执行文件允许上传(服务器不会执行)
|
||||
const DANGEROUS_EXTENSIONS = [
|
||||
'.php', '.php3', '.php4', '.php5', '.phtml', '.phar', // PHP
|
||||
'.jsp', '.jspx', '.jsw', '.jsv', '.jspf', // Java Server Pages
|
||||
'.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx', // ASP.NET
|
||||
'.htaccess', '.htpasswd' // Apache配置(可能改变服务器行为)
|
||||
];
|
||||
|
||||
// 检查文件扩展名是否安全
|
||||
function isFileExtensionSafe(filename) {
|
||||
if (!filename || typeof filename !== 'string') return false;
|
||||
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
|
||||
// 检查危险扩展名
|
||||
if (DANGEROUS_EXTENSIONS.includes(ext)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查双扩展名攻击(如 file.php.jpg 可能被某些配置错误的服务器执行)
|
||||
const nameLower = filename.toLowerCase();
|
||||
for (const dangerExt of DANGEROUS_EXTENSIONS) {
|
||||
if (nameLower.includes(dangerExt + '.')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 应用XSS过滤到所有POST/PUT请求的body
|
||||
app.use((req, res, next) => {
|
||||
if ((req.method === 'POST' || req.method === 'PUT') && req.body) {
|
||||
@@ -541,6 +621,27 @@ const captchaLimiter = new RateLimiter({
|
||||
blockDuration: 30 * 60 * 1000
|
||||
});
|
||||
|
||||
// 创建API密钥验证限流器(防止暴力枚举API密钥,5次失败/小时,封锁24小时)
|
||||
const apiKeyLimiter = new RateLimiter({
|
||||
maxAttempts: 5,
|
||||
windowMs: 60 * 60 * 1000, // 1小时窗口
|
||||
blockDuration: 24 * 60 * 60 * 1000 // 封锁24小时
|
||||
});
|
||||
|
||||
// 创建文件上传限流器(每用户每小时最多100次上传)
|
||||
const uploadLimiter = new RateLimiter({
|
||||
maxAttempts: 100,
|
||||
windowMs: 60 * 60 * 1000,
|
||||
blockDuration: 60 * 60 * 1000
|
||||
});
|
||||
|
||||
// 创建文件列表查询限流器(每用户每分钟最多60次)
|
||||
const fileListLimiter = new RateLimiter({
|
||||
maxAttempts: 60,
|
||||
windowMs: 60 * 1000,
|
||||
blockDuration: 5 * 60 * 1000
|
||||
});
|
||||
|
||||
// 验证码最小请求间隔控制
|
||||
const CAPTCHA_MIN_INTERVAL = 3000; // 3秒
|
||||
const captchaLastRequest = new TTLCache(15 * 60 * 1000); // 15分钟自动清理
|
||||
@@ -894,7 +995,8 @@ app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => {
|
||||
if (err) {
|
||||
console.error('[验证码] Session保存失败:', err);
|
||||
} else {
|
||||
console.log('[验证码] 生成成功, SessionID:', req.sessionID, '验证码:', captcha.text);
|
||||
// 安全:不记录验证码明文到日志
|
||||
console.log('[验证码] 生成成功, SessionID:', req.sessionID);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -970,7 +1072,7 @@ app.post('/api/register',
|
||||
verification_expires_at: expiresAtMs
|
||||
});
|
||||
|
||||
const verifyLink = `${getProtocol(req)}://${req.get('host')}/app.html?verifyToken=${verifyToken}`;
|
||||
const verifyLink = `${getSecureBaseUrl(req)}/app.html?verifyToken=${verifyToken}`;
|
||||
|
||||
try {
|
||||
await sendMail(
|
||||
@@ -1040,7 +1142,7 @@ app.post('/api/resend-verification', [
|
||||
const expiresAtMs = Date.now() + 30 * 60 * 1000;
|
||||
VerificationDB.setVerification(user.id, verifyToken, expiresAtMs);
|
||||
|
||||
const verifyLink = `${getProtocol(req)}://${req.get('host')}/app.html?verifyToken=${verifyToken}`;
|
||||
const verifyLink = `${getSecureBaseUrl(req)}/app.html?verifyToken=${verifyToken}`;
|
||||
const safeUsernameForMail = escapeHtml(user.username);
|
||||
await sendMail(
|
||||
user.email,
|
||||
@@ -1096,36 +1198,39 @@ app.post('/api/password/forgot', [
|
||||
return res.status(400).json({ success: false, message: 'SMTP未配置,无法发送邮件' });
|
||||
}
|
||||
|
||||
// 安全修复:无论邮箱是否存在,都返回相同的成功消息(防止邮箱枚举)
|
||||
const user = UserDB.findByEmail(email);
|
||||
if (!user) {
|
||||
return res.status(400).json({ success: false, message: '邮箱未注册,无法发送重置邮件' });
|
||||
}
|
||||
if (user.is_banned || !user.is_active) {
|
||||
return res.status(403).json({ success: false, message: '账号不可用,无法重置密码' });
|
||||
}
|
||||
if (!user.is_verified) {
|
||||
return res.status(400).json({ success: false, message: '邮箱未验证,无法重置密码' });
|
||||
|
||||
// 只有当用户存在、已验证、未封禁时才发送邮件
|
||||
if (user && user.is_verified && user.is_active && !user.is_banned) {
|
||||
const token = generateRandomToken(24);
|
||||
const expiresAtMs = Date.now() + 30 * 60 * 1000;
|
||||
PasswordResetTokenDB.create(user.id, token, expiresAtMs);
|
||||
|
||||
const resetLink = `${getSecureBaseUrl(req)}/app.html?resetToken=${token}`;
|
||||
const safeUsernameForMail = escapeHtml(user.username);
|
||||
|
||||
// 异步发送邮件,不等待结果(避免通过响应时间判断邮箱是否存在)
|
||||
sendMail(
|
||||
email,
|
||||
'密码重置 - 玩玩云',
|
||||
`<p>您好,${safeUsernameForMail}:</p>
|
||||
<p>请点击下面的链接重置密码,30分钟内有效:</p>
|
||||
<p><a href="${resetLink}" target="_blank">${resetLink}</a></p>
|
||||
<p>如果不是您本人操作,请忽略此邮件。</p>`
|
||||
).catch(err => {
|
||||
console.error('发送密码重置邮件失败:', err.message);
|
||||
});
|
||||
} else {
|
||||
// 记录但不暴露邮箱是否存在
|
||||
console.log('[密码重置] 邮箱不存在或账号不可用:', email);
|
||||
}
|
||||
|
||||
const token = generateRandomToken(24);
|
||||
const expiresAtMs = Date.now() + 30 * 60 * 1000;
|
||||
PasswordResetTokenDB.create(user.id, token, expiresAtMs);
|
||||
|
||||
const resetLink = `${getProtocol(req)}://${req.get('host')}/app.html?resetToken=${token}`;
|
||||
const safeUsernameForMail = escapeHtml(user.username);
|
||||
await sendMail(
|
||||
email,
|
||||
'密码重置 - 玩玩云',
|
||||
`<p>您好,${safeUsernameForMail}:</p>
|
||||
<p>请点击下面的链接重置密码,30分钟内有效:</p>
|
||||
<p><a href="${resetLink}" target="_blank">${resetLink}</a></p>
|
||||
<p>如果不是您本人操作,请忽略此邮件。</p>`
|
||||
);
|
||||
|
||||
res.json({ success: true, message: '重置邮件已发送,请查收邮箱完成验证' });
|
||||
// 无论邮箱是否存在,都返回相同的成功消息
|
||||
res.json({ success: true, message: '如果该邮箱已注册,您将收到密码重置链接' });
|
||||
} catch (error) {
|
||||
const status = error.status || 500;
|
||||
console.error('发送密码重置邮件失败:', error);
|
||||
console.error('密码重置请求失败:', error);
|
||||
res.status(status).json({ success: false, message: error.message || '发送失败' });
|
||||
}
|
||||
});
|
||||
@@ -1198,7 +1303,7 @@ app.post('/api/login',
|
||||
|
||||
// 如果需要验证码,则验证验证码
|
||||
if (needCaptcha) {
|
||||
console.log('[登录验证] 需要验证码, SessionID:', req.sessionID, 'IP失败次数:', ipFailures, '用户名失败次数:', usernameFailures);
|
||||
console.log('[登录验证] 需要验证码, IP失败次数:', ipFailures, '用户名失败次数:', usernameFailures);
|
||||
|
||||
if (!captcha) {
|
||||
return res.status(400).json({
|
||||
@@ -1212,7 +1317,8 @@ app.post('/api/login',
|
||||
const sessionCaptcha = req.session.captcha;
|
||||
const captchaTime = req.session.captchaTime;
|
||||
|
||||
console.log('[登录验证] Session验证码:', sessionCaptcha, '输入验证码:', captcha, 'Session时间:', captchaTime);
|
||||
// 安全:不记录验证码明文
|
||||
console.log('[登录验证] 正在验证验证码...');
|
||||
|
||||
if (!sessionCaptcha || !captchaTime) {
|
||||
console.log('[登录验证] 验证码不存在于Session中');
|
||||
@@ -1314,11 +1420,15 @@ app.post('/api/login',
|
||||
}
|
||||
}
|
||||
|
||||
// 增强Cookie安全设置
|
||||
const isSecureEnv = process.env.COOKIE_SECURE === 'true';
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.COOKIE_SECURE === 'true', // HTTPS环境下启用
|
||||
sameSite: 'lax', // 防止CSRF攻击
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000 // 7天
|
||||
secure: isSecureEnv,
|
||||
// HTTPS环境使用strict,HTTP环境使用lax(开发环境兼容)
|
||||
sameSite: isSecureEnv ? 'strict' : 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7天
|
||||
path: '/' // 限制Cookie作用域
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -1675,8 +1785,18 @@ app.post('/api/user/switch-storage',
|
||||
}
|
||||
);
|
||||
|
||||
// 获取文件列表
|
||||
// 获取文件列表(添加速率限制)
|
||||
app.get('/api/files', authMiddleware, async (req, res) => {
|
||||
// 速率限制检查
|
||||
const rateLimitKey = `file_list:${req.user.id}`;
|
||||
const rateLimitResult = fileListLimiter.recordFailure(rateLimitKey);
|
||||
if (rateLimitResult.blocked) {
|
||||
return res.status(429).json({
|
||||
success: false,
|
||||
message: `请求过于频繁,请在 ${rateLimitResult.waitMinutes} 分钟后重试`
|
||||
});
|
||||
}
|
||||
|
||||
const dirPath = req.query.path || '/';
|
||||
let storage;
|
||||
|
||||
@@ -1969,8 +2089,22 @@ app.post('/api/files/delete', authMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 上传文件
|
||||
// 上传文件(添加速率限制)
|
||||
app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res) => {
|
||||
// 速率限制检查
|
||||
const rateLimitKey = `upload:${req.user.id}`;
|
||||
const rateLimitResult = uploadLimiter.recordFailure(rateLimitKey);
|
||||
if (rateLimitResult.blocked) {
|
||||
// 清理已上传的临时文件
|
||||
if (req.file && fs.existsSync(req.file.path)) {
|
||||
safeDeleteFile(req.file.path);
|
||||
}
|
||||
return res.status(429).json({
|
||||
success: false,
|
||||
message: `上传过于频繁,请在 ${rateLimitResult.waitMinutes} 分钟后重试`
|
||||
});
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
@@ -1997,14 +2131,26 @@ app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res)
|
||||
const remotePath = req.body.path || '/';
|
||||
// 修复中文文件名:multer将UTF-8转为了Latin1,需要转回来
|
||||
const originalFilename = Buffer.from(req.file.originalname, 'latin1').toString('utf8');
|
||||
|
||||
// 文件名安全校验
|
||||
if (!isSafePathSegment(originalFilename)) {
|
||||
safeDeleteFile(req.file.path);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件名包含非法字符'
|
||||
});
|
||||
}
|
||||
|
||||
// 文件扩展名安全检查(防止上传危险文件)
|
||||
if (!isFileExtensionSafe(originalFilename)) {
|
||||
console.warn(`[安全] 拒绝上传危险文件: ${originalFilename}, 用户: ${req.user.username}`);
|
||||
safeDeleteFile(req.file.path);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '不允许上传此类型的文件(安全限制)'
|
||||
});
|
||||
}
|
||||
|
||||
// 路径安全校验
|
||||
const normalizedPath = path.posix.normalize(remotePath || '/');
|
||||
if (normalizedPath.includes('..')) {
|
||||
@@ -2149,7 +2295,7 @@ app.post('/api/upload/generate-tool', authMiddleware, async (req, res) => {
|
||||
const config = {
|
||||
username: req.user.username,
|
||||
api_key: newApiKey,
|
||||
api_base_url: `${req.get('x-forwarded-proto') || req.protocol}://${req.get('host')}`
|
||||
api_base_url: getSecureBaseUrl(req)
|
||||
};
|
||||
|
||||
res.json({
|
||||
@@ -2184,7 +2330,7 @@ app.get('/api/upload/download-tool', authMiddleware, async (req, res) => {
|
||||
const config = {
|
||||
username: req.user.username,
|
||||
api_key: newApiKey,
|
||||
api_base_url: `${req.get('x-forwarded-proto') || req.protocol}://${req.get('host')}`
|
||||
api_base_url: getSecureBaseUrl(req)
|
||||
};
|
||||
console.log("[上传工具配置]", JSON.stringify(config, null, 2));
|
||||
|
||||
@@ -2302,7 +2448,23 @@ app.get('/api/upload/download-tool', authMiddleware, async (req, res) => {
|
||||
});
|
||||
|
||||
// 通过API密钥获取SFTP配置(供Python工具调用)
|
||||
// 添加速率限制防止暴力枚举
|
||||
app.post('/api/upload/get-config', async (req, res) => {
|
||||
// 获取客户端IP用于速率限制
|
||||
const clientIP = apiKeyLimiter.getClientKey(req);
|
||||
const rateLimitKey = `api_key:${clientIP}`;
|
||||
|
||||
// 检查是否被封锁
|
||||
if (apiKeyLimiter.isBlocked(rateLimitKey)) {
|
||||
const result = apiKeyLimiter.recordFailure(rateLimitKey);
|
||||
console.warn(`[安全] API密钥暴力枚举检测 - IP: ${clientIP}`);
|
||||
return res.status(429).json({
|
||||
success: false,
|
||||
message: `请求过于频繁,请在 ${result.waitMinutes} 分钟后重试`,
|
||||
blocked: true
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { api_key } = req.body;
|
||||
|
||||
@@ -2317,12 +2479,18 @@ app.post('/api/upload/get-config', async (req, res) => {
|
||||
const user = db.prepare('SELECT * FROM users WHERE upload_api_key = ?').get(api_key);
|
||||
|
||||
if (!user) {
|
||||
// 记录失败尝试
|
||||
const result = apiKeyLimiter.recordFailure(rateLimitKey);
|
||||
console.warn(`[安全] API密钥验证失败 - IP: ${clientIP}, 剩余尝试: ${result.remainingAttempts}`);
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'API密钥无效或已过期'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证成功,清除失败记录
|
||||
apiKeyLimiter.recordSuccess(rateLimitKey);
|
||||
|
||||
if (user.is_banned) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
@@ -2337,7 +2505,9 @@ app.post('/api/upload/get-config', async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 返回SFTP配置
|
||||
// 返回SFTP配置(注意:密码通过此API返回给上传工具使用)
|
||||
// 上传工具需要密码才能连接SFTP,这是设计上的需要
|
||||
// 安全措施:1. 速率限制防止暴力枚举 2. API密钥是32位随机字符串
|
||||
res.json({
|
||||
success: true,
|
||||
sftp_config: {
|
||||
@@ -2382,7 +2552,7 @@ app.post('/api/share/create', authMiddleware, (req, res) => {
|
||||
db.prepare('UPDATE shares SET storage_type = ? WHERE id = ?')
|
||||
.run(req.user.current_storage_type || 'sftp', result.id);
|
||||
|
||||
const shareUrl = `${getProtocol(req)}://${req.get('host')}/s/${result.share_code}`;
|
||||
const shareUrl = `${getSecureBaseUrl(req)}/s/${result.share_code}`;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -2410,7 +2580,7 @@ app.get('/api/share/my', authMiddleware, (req, res) => {
|
||||
success: true,
|
||||
shares: shares.map(share => ({
|
||||
...share,
|
||||
share_url: `${getProtocol(req)}://${req.get('host')}/s/${share.share_code}`
|
||||
share_url: `${getSecureBaseUrl(req)}/s/${share.share_code}`
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -2422,32 +2592,52 @@ app.get('/api/share/my', authMiddleware, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 删除分享
|
||||
// 删除分享(增强IDOR防护)
|
||||
app.delete('/api/share/:id', authMiddleware, (req, res) => {
|
||||
try {
|
||||
// 先获取分享信息以获得share_code
|
||||
const share = ShareDB.findById(req.params.id);
|
||||
const shareId = parseInt(req.params.id, 10);
|
||||
|
||||
if (share && share.user_id === req.user.id) {
|
||||
// 删除缓存
|
||||
if (shareFileCache.has(share.share_code)) {
|
||||
shareFileCache.delete(share.share_code);
|
||||
console.log(`[缓存清除] 分享码: ${share.share_code}`);
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
ShareDB.delete(req.params.id, req.user.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '分享已删除'
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
// 验证ID格式
|
||||
if (isNaN(shareId) || shareId <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '分享不存在或无权限'
|
||||
message: '无效的分享ID'
|
||||
});
|
||||
}
|
||||
|
||||
// 先获取分享信息以获得share_code
|
||||
const share = ShareDB.findById(shareId);
|
||||
|
||||
if (!share) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '分享不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 严格的权限检查
|
||||
if (share.user_id !== req.user.id) {
|
||||
// 记录可疑的越权尝试
|
||||
console.warn(`[安全] IDOR尝试 - 用户 ${req.user.id}(${req.user.username}) 试图删除用户 ${share.user_id} 的分享 ${shareId}`);
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权限删除此分享'
|
||||
});
|
||||
}
|
||||
|
||||
// 删除缓存
|
||||
if (shareFileCache.has(share.share_code)) {
|
||||
shareFileCache.delete(share.share_code);
|
||||
console.log(`[缓存清除] 分享码: ${share.share_code}`);
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
ShareDB.delete(shareId, req.user.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '分享已删除'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除分享失败:', error);
|
||||
res.status(500).json({
|
||||
@@ -2857,20 +3047,20 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
|
||||
|
||||
// 验证密码(如果需要)
|
||||
if (share.share_password) {
|
||||
// 记录密码错误
|
||||
if (!password || !ShareDB.verifyPassword(password, share.share_password)) {
|
||||
// 只在密码错误时记录失败
|
||||
if (req.shareRateLimitKey) {
|
||||
shareLimiter.recordFailure(req.shareRateLimitKey);
|
||||
}
|
||||
if (!password || !ShareDB.verifyPassword(password, share.share_password)) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '密码错误或未提供密码'
|
||||
});
|
||||
// 清除失败记录(密码验证成功)
|
||||
if (req.shareRateLimitKey && share.share_password) {
|
||||
shareLimiter.recordSuccess(req.shareRateLimitKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 密码验证成功,清除失败记录
|
||||
if (req.shareRateLimitKey) {
|
||||
shareLimiter.recordSuccess(req.shareRateLimitKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3076,14 +3266,16 @@ app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req,
|
||||
let availableDisk = 0;
|
||||
|
||||
try {
|
||||
// 获取本地存储目录所在分区的磁盘信息
|
||||
const { stdout: dfOutput } = await execAsync(`df -B 1 / | tail -1`, { encoding: 'utf8' });
|
||||
const parts = dfOutput.trim().split(/\s+/);
|
||||
// 获取本地存储目录所在分区的磁盘信息(避免使用shell)
|
||||
const { stdout: dfOutput } = await execFileAsync('df', ['-B', '1', localStorageDir], { encoding: 'utf8' });
|
||||
// 取最后一行数据
|
||||
const lines = dfOutput.trim().split('\n');
|
||||
const parts = lines[lines.length - 1].trim().split(/\s+/);
|
||||
|
||||
if (parts.length >= 4) {
|
||||
totalDisk = parseInt(parts[1]) || 0; // 总大小
|
||||
usedDisk = parseInt(parts[2]) || 0; // 已使用
|
||||
availableDisk = parseInt(parts[3]) || 0; // 可用
|
||||
totalDisk = parseInt(parts[1], 10) || 0; // 总大小
|
||||
usedDisk = parseInt(parts[2], 10) || 0; // 已使用
|
||||
availableDisk = parseInt(parts[3], 10) || 0; // 可用
|
||||
}
|
||||
} catch (dfError) {
|
||||
console.error('获取磁盘信息失败:', dfError.message);
|
||||
@@ -3091,11 +3283,13 @@ app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req,
|
||||
try {
|
||||
// 获取本地存储目录所在的驱动器号
|
||||
const driveLetter = localStorageDir.charAt(0);
|
||||
if (!/^[A-Za-z]$/.test(driveLetter)) {
|
||||
const normalizedDrive = driveLetter.toUpperCase();
|
||||
if (!/^[A-Z]$/.test(normalizedDrive)) {
|
||||
throw new Error('Invalid drive letter');
|
||||
}
|
||||
const { stdout: wmicOutput } = await execAsync(
|
||||
`wmic logicaldisk where "DeviceID='${driveLetter}:'" get Size,FreeSpace /value`,
|
||||
const { stdout: wmicOutput } = await execFileAsync(
|
||||
'wmic',
|
||||
['logicaldisk', 'where', `DeviceID='${normalizedDrive}:'`, 'get', 'Size,FreeSpace', '/value'],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
|
||||
|
||||
@@ -231,27 +231,55 @@ class LocalStorageClient {
|
||||
|
||||
/**
|
||||
* 获取完整路径(带安全检查)
|
||||
* 增强的路径遍历防护
|
||||
*/
|
||||
getFullPath(relativePath) {
|
||||
// 0. 输入验证:检查空字节注入和其他危险字符
|
||||
if (typeof relativePath !== 'string') {
|
||||
throw new Error('无效的路径类型');
|
||||
}
|
||||
|
||||
// 检查空字节注入(%00, \x00)
|
||||
if (relativePath.includes('\x00') || relativePath.includes('%00')) {
|
||||
console.warn('[安全] 检测到空字节注入尝试:', relativePath);
|
||||
throw new Error('路径包含非法字符');
|
||||
}
|
||||
|
||||
// 1. 规范化路径,移除 ../ 等危险路径
|
||||
let normalized = path.normalize(relativePath || '').replace(/^(\.\.[\/\\])+/, '');
|
||||
|
||||
// 2. ✅ 修复:将绝对路径转换为相对路径(解决Linux环境下的问题)
|
||||
// 2. 额外检查:移除路径中间的 .. (防止 a/../../../etc/passwd 绕过)
|
||||
// 解析后的路径不应包含 ..
|
||||
if (normalized.includes('..')) {
|
||||
console.warn('[安全] 检测到目录遍历尝试:', relativePath);
|
||||
throw new Error('路径包含非法字符');
|
||||
}
|
||||
|
||||
// 3. 将绝对路径转换为相对路径(解决Linux环境下的问题)
|
||||
if (path.isAbsolute(normalized)) {
|
||||
// 移除开头的 / 或 Windows 盘符,转为相对路径
|
||||
normalized = normalized.replace(/^[\/\\]+/, '').replace(/^[a-zA-Z]:/, '');
|
||||
}
|
||||
|
||||
// 3. 空字符串或 . 表示根目录
|
||||
// 4. 空字符串或 . 表示根目录
|
||||
if (normalized === '' || normalized === '.') {
|
||||
return this.basePath;
|
||||
}
|
||||
|
||||
// 4. 拼接完整路径
|
||||
// 5. 拼接完整路径
|
||||
const fullPath = path.join(this.basePath, normalized);
|
||||
|
||||
// 5. 安全检查:确保路径在用户目录内(防止目录遍历攻击)
|
||||
if (!fullPath.startsWith(this.basePath)) {
|
||||
// 6. 解析真实路径(处理符号链接)后再次验证
|
||||
const resolvedBasePath = path.resolve(this.basePath);
|
||||
const resolvedFullPath = path.resolve(fullPath);
|
||||
|
||||
// 7. 安全检查:确保路径在用户目录内(防止目录遍历攻击)
|
||||
if (!resolvedFullPath.startsWith(resolvedBasePath)) {
|
||||
console.warn('[安全] 检测到路径遍历攻击:', {
|
||||
input: relativePath,
|
||||
resolved: resolvedFullPath,
|
||||
base: resolvedBasePath
|
||||
});
|
||||
throw new Error('非法路径访问');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user