fix: harden cloud storage security

This commit is contained in:
237899745
2026-06-13 18:45:12 +08:00
parent 7943b04ee2
commit bb6ad01018
28 changed files with 2229 additions and 996 deletions

View File

@@ -4,7 +4,6 @@ require('dotenv').config();
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const svgCaptcha = require('svg-captcha');
const multer = require('multer');
const nodemailer = require('nodemailer');
@@ -74,6 +73,7 @@ const {
DownloadTrafficReportDB,
DownloadTrafficReservationDB,
UploadSessionDB,
OssUploadReservationDB,
DeviceSessionDB,
FileHashIndexDB,
DownloadTrafficIngestDB,
@@ -112,7 +112,7 @@ const DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS = Math.max(
10,
Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30))
);
const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.28';
const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.31';
const DEFAULT_DESKTOP_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || '';
const DEFAULT_DESKTOP_INSTALLER_SHA256 = String(process.env.DESKTOP_INSTALLER_SHA256 || '').trim().toLowerCase();
const DEFAULT_DESKTOP_INSTALLER_SIZE = Math.max(0, Number(process.env.DESKTOP_INSTALLER_SIZE || 0));
@@ -124,6 +124,16 @@ const DESKTOP_INSTALLER_SHA256_PATTERN = /^[a-f0-9]{64}$/;
const RESUMABLE_UPLOAD_SESSION_TTL_MS = Number(process.env.RESUMABLE_UPLOAD_SESSION_TTL_MS || (24 * 60 * 60 * 1000)); // 24小时
const RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES || (4 * 1024 * 1024)); // 4MB
const RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES || (32 * 1024 * 1024)); // 32MB
const OSS_DIRECT_UPLOAD_TTL_SECONDS = Math.max(
60,
Math.min(3600, Number(process.env.OSS_DIRECT_UPLOAD_TTL_SECONDS || 900))
);
const OSS_UPLOAD_RESERVATION_TTL_MS = Math.max(
30 * 60 * 1000,
Number(process.env.OSS_UPLOAD_RESERVATION_TTL_MS || (45 * 60 * 1000)),
OSS_DIRECT_UPLOAD_TTL_SECONDS * 1000
);
const OSS_UPLOAD_TEMP_PREFIX = '__wanwan_tmp_uploads';
const GLOBAL_SEARCH_DEFAULT_LIMIT = Number(process.env.GLOBAL_SEARCH_DEFAULT_LIMIT || 80);
const GLOBAL_SEARCH_MAX_LIMIT = Number(process.env.GLOBAL_SEARCH_MAX_LIMIT || 200);
const GLOBAL_SEARCH_MAX_SCANNED_NODES = Number(process.env.GLOBAL_SEARCH_MAX_SCANNED_NODES || 4000);
@@ -150,6 +160,17 @@ const COOKIE_SECURE_MODE = String(process.env.COOKIE_SECURE || '').toLowerCase()
const SHOULD_USE_SECURE_COOKIES =
COOKIE_SECURE_MODE === 'true' ||
(process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'false');
const CAPTCHA_COOKIE_NAME = 'captcha.ticket';
const CAPTCHA_TTL_MS = 5 * 60 * 1000;
const CAPTCHA_COOKIE_MAX_AGE_MS = CAPTCHA_TTL_MS;
const CAPTCHA_SECRET = process.env.CAPTCHA_SECRET || JWT_SECRET;
const DEFAULT_CAPTCHA_SECRETS = [
'your-session-secret-change-in-production',
'session-secret-change-me',
'your-captcha-secret-change-in-production'
];
const isSecureCookie = SHOULD_USE_SECURE_COOKIES;
const sameSiteMode = isSecureCookie ? 'none' : 'lax';
function normalizeVersion(rawVersion, fallback = '0.0.0') {
const value = String(rawVersion || '').trim();
@@ -421,9 +442,8 @@ function getSecureBaseUrl(req) {
return `${getProtocol(req)}://${req.get('host')}`;
}
// 生产环境没有配置时,记录警告并使用请求的 Host不推荐
console.error('[安全警告] 生产环境配置 PUBLIC_BASE_URL,存在 Host Header 注入风险!');
return `${getProtocol(req)}://${req.get('host')}`;
// 生产环境绝不使用请求 Host 生成外部链接,避免 Host Header 注入。
throw new Error('生产环境必须配置 PUBLIC_BASE_URL 或 ALLOWED_HOSTS');
}
// ===== 安全配置:信任代理 =====
@@ -522,7 +542,7 @@ function applySecurityHeaders(req, res) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
// 内容安全策略
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'self';");
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'self';");
// 隐藏X-Powered-By
res.removeHeader('X-Powered-By');
}
@@ -631,44 +651,6 @@ app.use((req, res, next) => {
return next();
});
// Session配置用于验证码
const isSecureCookie = SHOULD_USE_SECURE_COOKIES;
const sameSiteMode = isSecureCookie ? 'none' : 'lax'; // HTTPS下允许跨域获取验证码
// 安全检查Session密钥配置
const SESSION_SECRET = process.env.SESSION_SECRET || 'your-session-secret-change-in-production';
const DEFAULT_SESSION_SECRETS = [
'your-session-secret-change-in-production',
'session-secret-change-me'
];
if (DEFAULT_SESSION_SECRETS.includes(SESSION_SECRET)) {
const sessionWarnMsg = `
[安全警告] SESSION_SECRET 使用默认值,存在安全风险!
请在 .env 文件中设置随机生成的 SESSION_SECRET
生成命令: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
`;
if (process.env.NODE_ENV === 'production') {
console.error(sessionWarnMsg);
throw new Error('生产环境必须设置 SESSION_SECRET');
} else {
console.warn(sessionWarnMsg);
}
}
app.use(session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false, // 仅在写入 session 时创建,减少无效会话
name: 'captcha.sid', // 自定义session cookie名称
cookie: {
secure: isSecureCookie,
httpOnly: true,
sameSite: sameSiteMode,
maxAge: 10 * 60 * 1000 // 10分钟
}
}));
// 安全响应头中间件
app.use((req, res, next) => {
applySecurityHeaders(req, res);
@@ -861,16 +843,32 @@ function isFileExtensionSafe(filename) {
// 应用XSS过滤到所有POST/PUT请求的body
app.use((req, res, next) => {
if ((req.method === 'POST' || req.method === 'PUT') && req.body) {
const sensitiveBodyFields = new Set([
'password',
'current_password',
'new_password',
'admin_password',
'api_key',
'access_key_secret',
'oss_access_key_secret',
'smtp_password',
'token',
'refreshToken'
]);
// 递归过滤所有字符串字段
function sanitizeObject(obj) {
function sanitizeObject(obj, fieldName = '') {
if (typeof obj === 'string') {
if (sensitiveBodyFields.has(fieldName)) {
return obj;
}
return sanitizeInput(obj);
} else if (Array.isArray(obj)) {
return obj.map(item => sanitizeObject(item));
return obj.map(item => sanitizeObject(item, fieldName));
} else if (obj && typeof obj === 'object') {
const sanitized = {};
for (const [key, value] of Object.entries(obj)) {
sanitized[key] = sanitizeObject(value);
sanitized[key] = sanitizeObject(value, key);
}
return sanitized;
}
@@ -1419,6 +1417,53 @@ function handleDownloadSecurityBlock(req, res, blockResult, options = {}) {
});
}
function requirePasswordConfirmation(req, res, next) {
try {
const password = String(
req.body?.current_password ||
req.body?.admin_password ||
req.body?.password_confirmation ||
''
);
if (!password) {
return res.status(403).json({
success: false,
message: '需要输入当前管理员密码进行确认',
requirePasswordConfirmation: true
});
}
const user = UserDB.findById(req.user?.id);
if (!user || !user.password) {
return res.status(403).json({
success: false,
message: '管理员账号不存在或状态异常'
});
}
const ok = require('bcryptjs').compareSync(password, user.password);
if (!ok) {
logAuth(req, 'admin_password_confirmation_failed', '管理员敏感操作密码确认失败', {
userId: req.user?.id
}, 'warning');
return res.status(403).json({
success: false,
message: '管理员密码确认失败',
requirePasswordConfirmation: true
});
}
next();
} catch (error) {
console.error('[安全] 管理员密码确认失败:', error);
res.status(500).json({
success: false,
message: '管理员密码确认失败,请稍后重试'
});
}
}
function sendPlainTextError(res, statusCode, message) {
return res.status(statusCode).type('text/plain; charset=utf-8').send(message);
}
@@ -1454,6 +1499,26 @@ function formatDateTimeForSqlite(date = new Date()) {
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
function createOssUploadReservationToken() {
return crypto.randomBytes(24).toString('hex');
}
function buildOssTempObjectKey(userId, reservationToken, filename) {
const safeUserId = Math.max(0, Math.floor(Number(userId) || 0));
const safeToken = String(reservationToken || '').replace(/[^a-f0-9]/gi, '').slice(0, 64);
const safeName = sanitizeFilename(filename || 'upload.bin') || 'upload.bin';
return `${OSS_UPLOAD_TEMP_PREFIX}/user_${safeUserId}/${safeToken}/${safeName}`;
}
function encodeS3CopySource(bucket, key) {
const encodedBucket = encodeURIComponent(String(bucket || ''));
const encodedKey = String(key || '')
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/');
return `${encodedBucket}/${encodedKey}`;
}
function getDateKeyFromDate(date = new Date()) {
const target = date instanceof Date ? date : new Date(date);
if (Number.isNaN(target.getTime())) {
@@ -2227,10 +2292,14 @@ function buildStorageUserContext(user, overrides = {}) {
return user;
}
const directSecret = user.oss_access_key_secret;
const storageUser = {
...user,
...overrides
};
if (!storageUser.oss_access_key_secret && directSecret) {
storageUser.oss_access_key_secret = directSecret;
}
if (typeof storageUser.oss_access_key_secret === 'string' && storageUser.oss_access_key_secret) {
try {
@@ -2270,6 +2339,70 @@ function createS3ClientContextForUser(user, overrides = {}) {
};
}
async function cleanupExpiredOssUploadReservations(trigger = 'interval') {
const rows = OssUploadReservationDB.listExpiredPending(200);
if (!Array.isArray(rows) || rows.length === 0) {
return;
}
const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
let cleaned = 0;
for (const row of rows) {
try {
const user = UserDB.findById(row.user_id);
if (!user) {
OssUploadReservationDB.expire(row.reservation_token);
cleaned += 1;
continue;
}
const ossClient = createOssClientForUser(user);
await ossClient.connect();
try {
await ossClient.s3Client.send(new DeleteObjectCommand({
Bucket: ossClient.getBucket(),
Key: row.temp_object_key
}));
} catch (deleteError) {
const statusCode = deleteError?.$metadata?.httpStatusCode;
if (deleteError?.name !== 'NotFound' && deleteError?.name !== 'NoSuchKey' && statusCode !== 404) {
throw deleteError;
}
}
OssUploadReservationDB.expire(row.reservation_token);
cleaned += 1;
} catch (error) {
console.error(`[OSS直传] 清理过期临时对象失败: reservation=${row.id}, user=${row.user_id}`, error);
}
}
const deletedHistory = OssUploadReservationDB.cleanupFinalizedHistory(7);
if (cleaned > 0 || Number(deletedHistory?.changes || 0) > 0) {
console.log(
`[OSS直传] 清理完成 (trigger=${trigger}) ` +
`expired=${cleaned}, deleted_history=${Number(deletedHistory?.changes || 0)}`
);
}
}
const ossUploadReservationSweepTimer = setInterval(() => {
cleanupExpiredOssUploadReservations('interval').catch(error => {
console.error('[OSS直传] 定期清理失败:', error);
});
}, 5 * 60 * 1000);
if (ossUploadReservationSweepTimer && typeof ossUploadReservationSweepTimer.unref === 'function') {
ossUploadReservationSweepTimer.unref();
}
setTimeout(() => {
cleanupExpiredOssUploadReservations('startup').catch(error => {
console.error('[OSS直传] 启动清理失败:', error);
});
}, 35 * 1000);
const EPHEMERAL_TOKEN_SECRET = JWT_SECRET;
function signEphemeralToken(payload, expiresInSeconds = 900) {
@@ -2734,6 +2867,7 @@ const fileListLimiter = new RateLimiter({
// 验证码最小请求间隔控制
const CAPTCHA_MIN_INTERVAL = 1000; // 1秒
const captchaLastRequest = new TTLCache(15 * 60 * 1000); // 15分钟自动清理
const captchaTicketCache = new TTLCache(CAPTCHA_TTL_MS);
// 验证码防刷中间件
function captchaRateLimitMiddleware(req, res, next) {
@@ -3358,15 +3492,7 @@ async function searchFilesRecursively(storage, startPath, keyword, options = {})
}
function normalizeUploadPath(rawPath) {
const safeRaw = typeof rawPath === 'string' ? rawPath : '/';
if (safeRaw.includes('..') || safeRaw.includes('\x00')) {
return null;
}
const normalized = path.posix.normalize(safeRaw || '/');
if (normalized.includes('..')) {
return null;
}
return normalized === '.' ? '/' : normalized;
return normalizeVirtualPath(typeof rawPath === 'string' ? rawPath : '/');
}
function buildVirtualFilePath(basePath, filename) {
@@ -3590,7 +3716,7 @@ function cleanupOldTempFiles() {
const filePath = path.join(uploadsDir, file);
try {
const stats = fs.statSync(filePath);
if (now - stats.mtimeMs > maxAge) {
if (stats.isFile() && now - stats.mtimeMs > maxAge) {
fs.unlinkSync(filePath);
cleaned++;
}
@@ -3689,43 +3815,121 @@ function checkMailRateLimit(req, type = 'mail') {
// ===== 验证码验证辅助函数 =====
function isCaptchaSecretSecure() {
return typeof CAPTCHA_SECRET === 'string' &&
CAPTCHA_SECRET.length >= 32 &&
!DEFAULT_CAPTCHA_SECRETS.includes(CAPTCHA_SECRET) &&
(CAPTCHA_SECRET !== JWT_SECRET || isJwtSecretSecure());
}
function signCaptchaTicket(ticketId) {
return crypto
.createHmac('sha256', CAPTCHA_SECRET)
.update(ticketId)
.digest('hex');
}
function isSafeHexDigest(value) {
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value);
}
function safeEqualHex(left, right) {
if (!isSafeHexDigest(left) || !isSafeHexDigest(right)) return false;
return crypto.timingSafeEqual(Buffer.from(left, 'hex'), Buffer.from(right, 'hex'));
}
function buildCaptchaCookieValue(ticketId) {
return `${ticketId}.${signCaptchaTicket(ticketId)}`;
}
function parseCaptchaTicket(rawValue) {
const value = String(rawValue || '');
const separatorIndex = value.lastIndexOf('.');
if (separatorIndex <= 0) return null;
const ticketId = value.slice(0, separatorIndex);
const signature = value.slice(separatorIndex + 1);
if (!/^[a-f0-9]{48}$/i.test(ticketId)) return null;
if (!safeEqualHex(signCaptchaTicket(ticketId), signature)) return null;
return ticketId;
}
function getCaptchaCookieOptions(maxAge = CAPTCHA_COOKIE_MAX_AGE_MS) {
return {
httpOnly: true,
secure: isSecureCookie,
sameSite: sameSiteMode,
maxAge,
path: '/'
};
}
function clearCaptchaTicketCookie(res) {
if (!res) return;
res.clearCookie(CAPTCHA_COOKIE_NAME, {
httpOnly: true,
secure: isSecureCookie,
sameSite: sameSiteMode,
path: '/'
});
}
function issueCaptchaTicket(res, captchaText) {
const ticketId = crypto.randomBytes(24).toString('hex');
captchaTicketCache.set(ticketId, {
captcha: String(captchaText || '').toLowerCase(),
createdAt: Date.now()
}, CAPTCHA_TTL_MS);
res.cookie(CAPTCHA_COOKIE_NAME, buildCaptchaCookieValue(ticketId), getCaptchaCookieOptions());
return ticketId;
}
/**
* 验证验证码
* @param {Object} req - 请求对象
* @param {Object} res - 响应对象
* @param {string} captcha - 用户输入的验证码
* @param {string} logPrefix - 日志前缀
* @returns {{valid: boolean, message?: string}} 验证结果
*/
function verifyCaptcha(req, captcha) {
function verifyCaptcha(req, res, captcha, logPrefix = '验证码验证') {
if (!captcha) {
return { valid: false, message: '请输入验证码' };
}
const sessionCaptcha = req.session.captcha;
const captchaTime = req.session.captchaTime;
// 调试日志
console.log('[验证码验证] SessionID:', req.sessionID, '输入:', captcha?.toLowerCase(), 'Session中:', sessionCaptcha);
if (!sessionCaptcha || !captchaTime) {
console.log('[验证码验证] 失败: session中无验证码');
const ticketId = parseCaptchaTicket(req.cookies?.[CAPTCHA_COOKIE_NAME]);
if (!ticketId) {
clearCaptchaTicketCookie(res);
console.log(`[${logPrefix}] 失败: 验证码票据无效或不存在`);
return { valid: false, message: '验证码已过期,请刷新验证码' };
}
// 验证码有效期5分钟
if (Date.now() - captchaTime > 5 * 60 * 1000) {
console.log('[验证码验证] 失败: 验证码已超时');
const captchaRecord = captchaTicketCache.get(ticketId);
console.log(`[${logPrefix}] 正在验证验证码票据:`, ticketId.slice(0, 8));
if (!captchaRecord || !captchaRecord.captcha || !captchaRecord.createdAt) {
captchaTicketCache.delete(ticketId);
clearCaptchaTicketCookie(res);
console.log(`[${logPrefix}] 失败: 验证码票据不存在`);
return { valid: false, message: '验证码已过期,请刷新验证码' };
}
if (captcha.toLowerCase() !== sessionCaptcha) {
console.log('[验证码验证] 失败: 验证码不匹配');
if (Date.now() - captchaRecord.createdAt > CAPTCHA_TTL_MS) {
captchaTicketCache.delete(ticketId);
clearCaptchaTicketCookie(res);
console.log(`[${logPrefix}] 失败: 验证码已超时`);
return { valid: false, message: '验证码已过期,请刷新验证码' };
}
if (String(captcha).toLowerCase() !== captchaRecord.captcha) {
console.log(`[${logPrefix}] 失败: 验证码不匹配`);
return { valid: false, message: '验证码错误' };
}
console.log('[验证码验证] 成功');
// 验证通过后清除session中的验证码
delete req.session.captcha;
delete req.session.captchaTime;
console.log(`[${logPrefix}] 成功`);
captchaTicketCache.delete(ticketId);
clearCaptchaTicketCookie(res);
return { valid: true };
}
@@ -3797,23 +4001,10 @@ app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => {
charPreset: 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 去掉易混淆字符,字母+数字
});
// 将验证码文本存储在session中
req.session.captcha = captcha.text.toLowerCase();
req.session.captchaTime = Date.now();
// 保存session后再返回响应修复确保session保存成功
req.session.save((err) => {
if (err) {
console.error('[验证码] Session保存失败:', err);
return res.status(500).json({
success: false,
message: '验证码生成失败'
});
}
console.log('[验证码] 生成成功, SessionID:', req.sessionID);
res.type('svg');
res.send(captcha.data);
});
const ticketId = issueCaptchaTicket(res, captcha.text);
console.log('[验证码] 生成成功, Ticket:', ticketId.slice(0, 8));
res.type('svg');
res.send(captcha.data);
} catch (error) {
console.error('生成验证码失败:', error);
res.status(500).json({
@@ -3907,7 +4098,7 @@ app.post('/api/register',
try {
// 验证验证码
const { captcha } = req.body;
const captchaResult = verifyCaptcha(req, captcha);
const captchaResult = verifyCaptcha(req, res, captcha);
if (!captchaResult.valid) {
return res.status(400).json({
success: false,
@@ -4017,7 +4208,7 @@ app.post('/api/resend-verification', [
try {
// 验证验证码
const { captcha } = req.body;
const captchaResult = verifyCaptcha(req, captcha);
const captchaResult = verifyCaptcha(req, res, captcha);
if (!captchaResult.valid) {
return res.status(400).json({
success: false,
@@ -4108,7 +4299,7 @@ app.post('/api/password/forgot', [
const { email, captcha } = req.body;
try {
// 验证验证码
const captchaResult = verifyCaptcha(req, captcha);
const captchaResult = verifyCaptcha(req, res, captcha);
if (!captchaResult.valid) {
return res.status(400).json({
success: false,
@@ -4246,45 +4437,14 @@ app.post('/api/login',
});
}
// 验证验证码
const sessionCaptcha = req.session.captcha;
const captchaTime = req.session.captchaTime;
// 安全:不记录验证码明文
console.log('[登录验证] 正在验证验证码...');
if (!sessionCaptcha || !captchaTime) {
console.log('[登录验证] 验证码不存在于Session中');
const captchaResult = verifyCaptcha(req, res, captcha, '登录验证');
if (!captchaResult.valid) {
return res.status(400).json({
success: false,
message: '验证码已过期,请刷新验证码',
message: captchaResult.message,
needCaptcha: true
});
}
// 验证码有效期5分钟
if (Date.now() - captchaTime > 5 * 60 * 1000) {
console.log('[登录验证] 验证码已超过5分钟');
return res.status(400).json({
success: false,
message: '验证码已过期,请刷新验证码',
needCaptcha: true
});
}
if (captcha.toLowerCase() !== sessionCaptcha) {
console.log('[登录验证] 验证码不匹配');
return res.status(400).json({
success: false,
message: '验证码错误',
needCaptcha: true
});
}
console.log('[登录验证] 验证码验证通过');
// 验证通过后清除session中的验证码
delete req.session.captcha;
delete req.session.captchaTime;
}
let user = UserDB.findByUsername(username);
@@ -5433,16 +5593,15 @@ app.get('/api/files', authMiddleware, async (req, res) => {
const rawPath = req.query.path || '/';
// 路径安全验证:在 API 层提前拒绝包含 .. 或空字节的路径
if (rawPath.includes('..') || rawPath.includes('\x00') || rawPath.includes('%00')) {
// 路径安全验证:在 API 层提前拒绝包含 ..、编码 .. 或空字节的路径
const dirPath = normalizeVirtualPath(rawPath);
if (!dirPath) {
return res.status(400).json({
success: false,
message: '路径包含非法字符'
});
}
// 规范化路径
const dirPath = path.posix.normalize(rawPath);
let storage;
try {
@@ -6604,8 +6763,9 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => {
});
}
// 路径安全验证:防止目录遍历攻击
if (uploadPath.includes('..') || uploadPath.includes('\x00')) {
// 路径安全验证:防止目录遍历攻击。使用统一虚拟路径规范化,覆盖编码后的 .. 片段。
const normalizedUploadPath = normalizeUploadPath(uploadPath);
if (!normalizedUploadPath) {
return res.status(400).json({
success: false,
message: '上传路径非法'
@@ -6630,7 +6790,7 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => {
// 构建对象 Key与 OssStorageClient.getObjectKey 格式一致)
// 格式user_${id}/${path}/${filename}
const sanitizedFilename = sanitizeFilename(filename);
let normalizedPath = uploadPath.replace(/\\/g, '/').replace(/\/+/g, '/');
let normalizedPath = normalizedUploadPath.replace(/\\/g, '/').replace(/\/+/g, '/');
// 移除开头的斜杠
normalizedPath = normalizedPath.replace(/^\/+/, '');
// 移除结尾的斜杠
@@ -6678,32 +6838,53 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => {
});
}
const reservationToken = createOssUploadReservationToken();
const tempObjectKey = buildOssTempObjectKey(req.user.id, reservationToken, sanitizedFilename);
const reservationExpiresAt = new Date(Date.now() + OSS_UPLOAD_RESERVATION_TTL_MS);
const reservation = OssUploadReservationDB.create({
reservationToken,
userId: req.user.id,
finalObjectKey: objectKey,
tempObjectKey,
expectedSize: fileSize,
previousSize,
fileHash: fileHash || null,
expiresAt: formatDateTimeForSqlite(reservationExpiresAt)
});
if (!reservation) {
throw new Error('创建上传预留记录失败');
}
const completionToken = signEphemeralToken({
type: 'upload_complete',
userId: req.user.id,
reservationToken,
objectKey,
tempObjectKey,
previousSize,
expectedSize: fileSize,
fileHash: fileHash || null
}, 30 * 60);
}, Math.ceil(OSS_UPLOAD_RESERVATION_TTL_MS / 1000));
// 创建 PutObject 命令
// 签名只允许写入临时对象;完成确认后由服务端复制到最终路径
const command = new PutObjectCommand({
Bucket: bucket,
Key: objectKey,
Key: tempObjectKey,
ContentType: contentType
});
// 生成签名 URL15分钟有效
const signedUrl = await getSignedUrl(client, command, { expiresIn: 900 });
const signedUrl = await getSignedUrl(client, command, { expiresIn: OSS_DIRECT_UPLOAD_TTL_SECONDS });
res.json({
success: true,
uploadUrl: signedUrl,
objectKey: objectKey,
uploadObjectKey: tempObjectKey,
previousSize,
completionToken,
expiresIn: 900
expiresIn: OSS_DIRECT_UPLOAD_TTL_SECONDS,
completionExpiresIn: Math.ceil(OSS_UPLOAD_RESERVATION_TTL_MS / 1000)
});
} catch (error) {
console.error('[OSS签名] 生成上传签名失败:', error);
@@ -6770,7 +6951,8 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
const completionPayload = completionTokenResult.payload || {};
if (
Number(completionPayload.userId) !== Number(req.user.id) ||
completionPayload.objectKey !== normalizedObjectKey
completionPayload.objectKey !== normalizedObjectKey ||
!completionPayload.reservationToken
) {
return res.status(403).json({
success: false,
@@ -6778,23 +6960,37 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
});
}
const previousObjectSize = Number.isFinite(Number(completionPayload.previousSize)) && Number(completionPayload.previousSize) >= 0
? Number(completionPayload.previousSize)
const reservation = OssUploadReservationDB.findPendingByToken(completionPayload.reservationToken);
if (
!reservation ||
Number(reservation.user_id) !== Number(req.user.id) ||
reservation.final_object_key !== normalizedObjectKey ||
reservation.temp_object_key !== completionPayload.tempObjectKey
) {
return res.status(403).json({
success: false,
message: '上传预留记录不存在、已完成或已过期'
});
}
const previousObjectSize = Number.isFinite(Number(reservation.previous_size)) && Number(reservation.previous_size) >= 0
? Number(reservation.previous_size)
: 0;
const completionFileHash = normalizeFileHash(completionPayload.fileHash);
const expectedSize = Math.max(0, Math.floor(Number(reservation.expected_size) || 0));
const completionFileHash = normalizeFileHash(reservation.file_hash || completionPayload.fileHash);
const virtualFilePath = `/${normalizedObjectKey.slice(expectedPrefix.length)}`;
let ossClient;
try {
const { HeadObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
const { HeadObjectCommand, CopyObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
ossClient = createOssClientForUser(req.user);
await ossClient.connect();
const headResponse = await ossClient.s3Client.send(new HeadObjectCommand({
Bucket: ossClient.getBucket(),
Key: normalizedObjectKey
Key: reservation.temp_object_key
}));
const verifiedSize = Number(headResponse.ContentLength || 0);
@@ -6807,6 +7003,19 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
console.warn(`[上传完成] 用户 ${req.user.id} 上报大小(${reportedSize})与实际大小(${verifiedSize})不一致,已使用实际大小`);
}
if (expectedSize <= 0 || verifiedSize !== expectedSize) {
await ossClient.s3Client.send(new DeleteObjectCommand({
Bucket: ossClient.getBucket(),
Key: reservation.temp_object_key
}));
OssUploadReservationDB.cancel(reservation.reservation_token);
clearOssUsageCache(req.user.id);
return res.status(400).json({
success: false,
message: `上传对象大小校验失败:期望 ${formatFileSize(expectedSize)},实际 ${formatFileSize(verifiedSize)}`
});
}
const deltaSize = verifiedSize - previousObjectSize;
// 二次校验 OSS 配额(防止签名后并发上传导致超限)
@@ -6816,17 +7025,13 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
const projectedUsage = Math.max(0, currentUsage + deltaSize);
if (projectedUsage > ossQuota) {
// 回滚:删除刚上传的对象,避免超配额文件残留
// 回滚:删除临时对象,避免超配额文件残留
await ossClient.s3Client.send(new DeleteObjectCommand({
Bucket: ossClient.getBucket(),
Key: normalizedObjectKey
Key: reservation.temp_object_key
}));
// 若为覆盖上传,旧对象已被替换,删除后需扣除旧对象体积
if (previousObjectSize > 0) {
await StorageUsageCache.updateUsage(req.user.id, -previousObjectSize);
}
OssUploadReservationDB.cancel(reservation.reservation_token);
clearOssUsageCache(req.user.id);
return res.status(400).json({
@@ -6835,6 +7040,18 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
});
}
await ossClient.s3Client.send(new CopyObjectCommand({
Bucket: ossClient.getBucket(),
CopySource: encodeS3CopySource(ossClient.getBucket(), reservation.temp_object_key),
Key: normalizedObjectKey,
MetadataDirective: 'COPY'
}));
await ossClient.s3Client.send(new DeleteObjectCommand({
Bucket: ossClient.getBucket(),
Key: reservation.temp_object_key
}));
// 更新存储使用量缓存(增量更新,覆盖上传只记录差值)
if (deltaSize !== 0) {
await StorageUsageCache.updateUsage(req.user.id, deltaSize);
@@ -6852,6 +7069,8 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
objectKey: normalizedObjectKey
});
OssUploadReservationDB.complete(reservation.reservation_token);
console.log(`[上传完成] 用户 ${req.user.id} 文件大小: ${verifiedSize} 字节, 增量: ${deltaSize} 字节`);
res.json({
success: true,
@@ -6885,9 +7104,9 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
});
}
// 路径安全验证:防止目录遍历攻击
const normalizedPath = path.posix.normalize(filePath);
if (normalizedPath.includes('..') || filePath.includes('\x00')) {
// 路径安全验证:防止目录遍历攻击。必须在 normalize 前拒绝 .. 片段。
const normalizedPath = normalizeVirtualPath(filePath);
if (!normalizedPath) {
return res.status(400).json({
success: false,
message: '文件路径非法'
@@ -7123,8 +7342,9 @@ app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res)
}
// 路径安全校验
const normalizedPath = path.posix.normalize(remotePath || '/');
if (normalizedPath.includes('..')) {
const normalizedPath = normalizeUploadPath(remotePath || '/');
if (!normalizedPath) {
safeDeleteFile(req.file.path);
return res.status(400).json({
success: false,
message: '上传路径非法'
@@ -7199,8 +7419,8 @@ app.get('/api/files/download-check', authMiddleware, async (req, res) => {
});
}
const normalizedPath = path.posix.normalize(filePath);
if (normalizedPath.includes('..') || filePath.includes('\x00')) {
const normalizedPath = normalizeVirtualPath(filePath);
if (!normalizedPath) {
return res.status(400).json({
success: false,
message: '文件路径非法'
@@ -7317,8 +7537,8 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
}
// 路径安全验证:防止目录遍历攻击
const normalizedPath = path.posix.normalize(filePath);
if (normalizedPath.includes('..') || filePath.includes('\x00')) {
const normalizedPath = normalizeVirtualPath(filePath);
if (!normalizedPath) {
return res.status(400).json({
success: false,
message: '文件路径非法'
@@ -8626,52 +8846,10 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) =>
// 记录下载次数(添加限流保护防止滥用)
app.post('/api/share/:code/download', shareRateLimitMiddleware, (req, res) => {
const { code } = req.params;
// 参数验证code 不能为空
if (!code || typeof code !== 'string' || code.length < 1 || code.length > 32) {
return res.status(400).json({
success: false,
message: '无效的分享码'
});
}
try {
const share = ShareDB.findByCode(code);
if (!share) {
return res.status(404).json({
success: false,
message: '分享不存在'
});
}
const accessPolicy = evaluateShareSecurityPolicy(share, req, {
action: 'download',
enforceDownloadLimit: true
});
if (!accessPolicy.allowed) {
return res.status(403).json({
success: false,
message: accessPolicy.message || '下载已受限',
policy_code: accessPolicy.code || 'policy_blocked'
});
}
// 增加下载次数
ShareDB.incrementDownloadCount(code);
res.json({
success: true,
message: '下载统计已记录'
});
} catch (error) {
console.error('记录下载失败:', error);
res.status(500).json({
success: false,
message: '记录下载失败: ' + error.message
});
}
res.status(410).json({
success: false,
message: '下载统计已合并到下载地址签发和文件下载接口'
});
});
// 生成分享文件下载签名 URLOSS 直连下载,公开 API添加限流保护
@@ -8903,6 +9081,8 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
}
}
ShareDB.incrementDownloadCount(code);
res.json({
success: true,
downloadUrl: signedUrl,
@@ -9225,10 +9405,11 @@ app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => {
});
// 更新系统设置
// 注意:已移除 requirePasswordConfirmation 中间件,依赖管理员登录认证
// 敏感系统设置需要管理员当前密码二次确认
app.post('/api/admin/settings',
authMiddleware,
adminMiddleware,
requirePasswordConfirmation,
(req, res) => {
try {
const {
@@ -9430,6 +9611,7 @@ app.get('/api/admin/unified-oss-config', authMiddleware, adminMiddleware, (req,
app.post('/api/admin/unified-oss-config',
authMiddleware,
adminMiddleware,
requirePasswordConfirmation,
[
body('provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'),
body('region').notEmpty().withMessage('地域不能为空'),
@@ -9571,6 +9753,7 @@ app.post('/api/admin/unified-oss-config/test',
app.delete('/api/admin/unified-oss-config',
authMiddleware,
adminMiddleware,
requirePasswordConfirmation,
(req, res) => {
try {
SettingsDB.clearUnifiedOssConfig();
@@ -9782,16 +9965,16 @@ app.get('/api/admin/health-check', authMiddleware, adminMiddleware, async (req,
suggestion: csrfEnabled ? null : '生产环境建议启用CSRF保护以防止跨站请求伪造攻击'
});
// 12. Session密钥检查
const sessionSecure = !DEFAULT_SESSION_SECRETS.includes(SESSION_SECRET) && SESSION_SECRET.length >= 32;
// 12. 验证码票据签名密钥检查
const captchaSecretSecure = isCaptchaSecretSecure();
checks.push({
name: 'Session密钥',
name: '验证码票据签名密钥',
category: 'security',
status: sessionSecure ? 'pass' : 'fail',
message: sessionSecure ? 'Session密钥已正确配置' : 'Session密钥使用默认值或长度不足,存在安全风险!',
suggestion: sessionSecure ? null : '请在.env中设置随机生成的SESSION_SECRET至少32字符'
status: captchaSecretSecure ? 'pass' : 'fail',
message: captchaSecretSecure ? '验证码票据签名密钥已正确配置' : '验证码票据签名密钥使用默认值或长度不足,存在安全风险!',
suggestion: captchaSecretSecure ? null : '请在.env中设置随机生成的JWT_SECRET或CAPTCHA_SECRET至少32字符'
});
if (!sessionSecure && overallStatus !== 'critical') overallStatus = 'critical';
if (!captchaSecretSecure && overallStatus !== 'critical') overallStatus = 'critical';
// 统计
const summary = {