From 96ff46aa4aaa13a457a749d51d88c8d38cb10142 Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Wed, 18 Feb 2026 09:48:14 +0800 Subject: [PATCH] feat: add configurable stealth download security policies --- backend/database.js | 34 +++ backend/server.js | 596 +++++++++++++++++++++++++++++++++++++++++++- frontend/app.html | 57 +++++ frontend/app.js | 49 ++++ 4 files changed, 735 insertions(+), 1 deletion(-) diff --git a/backend/database.js b/backend/database.js index 29ecdda..e9ada3c 100644 --- a/backend/database.js +++ b/backend/database.js @@ -1705,6 +1705,40 @@ function initDefaultSettings() { if (!SettingsDB.get('global_theme')) { SettingsDB.set('global_theme', 'dark'); } + + // 下载安全策略默认值(同IP同文件限速:5分钟3次、1小时10次、1天20次) + if (!SettingsDB.get('download_security_enabled')) { + SettingsDB.set('download_security_enabled', 'true'); + } + if (!SettingsDB.get('download_security_same_file_enabled')) { + SettingsDB.set('download_security_same_file_enabled', 'true'); + } + if (!SettingsDB.get('download_security_same_file_limit_5m')) { + SettingsDB.set('download_security_same_file_limit_5m', '3'); + } + if (!SettingsDB.get('download_security_same_file_limit_1h')) { + SettingsDB.set('download_security_same_file_limit_1h', '10'); + } + if (!SettingsDB.get('download_security_same_file_limit_1d')) { + SettingsDB.set('download_security_same_file_limit_1d', '20'); + } + + // 扩展策略默认关闭(管理员可按需开启) + if (!SettingsDB.get('download_security_same_user_enabled')) { + SettingsDB.set('download_security_same_user_enabled', 'false'); + } + if (!SettingsDB.get('download_security_same_user_limit_1h')) { + SettingsDB.set('download_security_same_user_limit_1h', '80'); + } + if (!SettingsDB.get('download_security_same_user_limit_1d')) { + SettingsDB.set('download_security_same_user_limit_1d', '300'); + } + if (!SettingsDB.get('download_security_same_file_min_interval_enabled')) { + SettingsDB.set('download_security_same_file_min_interval_enabled', 'false'); + } + if (!SettingsDB.get('download_security_same_file_min_interval_seconds')) { + SettingsDB.set('download_security_same_file_min_interval_seconds', '2'); + } } // 数据库迁移 - 主题偏好字段 diff --git a/backend/server.js b/backend/server.js index 51cb474..a99c6c0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -104,6 +104,24 @@ const GLOBAL_SEARCH_DEFAULT_LIMIT = Number(process.env.GLOBAL_SEARCH_DEFAULT_LIM 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); const SHARE_CODE_REGEX = /^[A-Za-z0-9]{6,32}$/; +const DOWNLOAD_SECURITY_DEFAULTS = Object.freeze({ + enabled: true, + same_ip_same_file: { + enabled: true, + limit_5m: 3, + limit_1h: 10, + limit_1d: 20 + }, + same_ip_same_user: { + enabled: false, + limit_1h: 80, + limit_1d: 300 + }, + same_ip_same_file_min_interval: { + enabled: false, + seconds: 2 + } +}); const COOKIE_SECURE_MODE = String(process.env.COOKIE_SECURE || '').toLowerCase(); const SHOULD_USE_SECURE_COOKIES = COOKIE_SECURE_MODE === 'true' || @@ -695,6 +713,482 @@ function getBusyDownloadMessage() { return '当前网络繁忙,请稍后再试'; } +function parseBooleanLike(rawValue, fallback = false) { + if (rawValue === null || rawValue === undefined || rawValue === '') { + return fallback; + } + if (typeof rawValue === 'boolean') { + return rawValue; + } + const normalized = String(rawValue).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + return fallback; +} + +function clampIntegerSetting(rawValue, fallback, min, max) { + const value = Number(rawValue); + if (!Number.isFinite(value)) { + return fallback; + } + return Math.min(max, Math.max(min, Math.floor(value))); +} + +function getDownloadSecuritySettings() { + const defaults = DOWNLOAD_SECURITY_DEFAULTS; + return { + enabled: parseBooleanLike(SettingsDB.get('download_security_enabled'), defaults.enabled), + same_ip_same_file: { + enabled: parseBooleanLike( + SettingsDB.get('download_security_same_file_enabled'), + defaults.same_ip_same_file.enabled + ), + limit_5m: clampIntegerSetting( + SettingsDB.get('download_security_same_file_limit_5m'), + defaults.same_ip_same_file.limit_5m, + 1, + 20000 + ), + limit_1h: clampIntegerSetting( + SettingsDB.get('download_security_same_file_limit_1h'), + defaults.same_ip_same_file.limit_1h, + 1, + 50000 + ), + limit_1d: clampIntegerSetting( + SettingsDB.get('download_security_same_file_limit_1d'), + defaults.same_ip_same_file.limit_1d, + 1, + 200000 + ) + }, + same_ip_same_user: { + enabled: parseBooleanLike( + SettingsDB.get('download_security_same_user_enabled'), + defaults.same_ip_same_user.enabled + ), + limit_1h: clampIntegerSetting( + SettingsDB.get('download_security_same_user_limit_1h'), + defaults.same_ip_same_user.limit_1h, + 1, + 50000 + ), + limit_1d: clampIntegerSetting( + SettingsDB.get('download_security_same_user_limit_1d'), + defaults.same_ip_same_user.limit_1d, + 1, + 200000 + ) + }, + same_ip_same_file_min_interval: { + enabled: parseBooleanLike( + SettingsDB.get('download_security_same_file_min_interval_enabled'), + defaults.same_ip_same_file_min_interval.enabled + ), + seconds: clampIntegerSetting( + SettingsDB.get('download_security_same_file_min_interval_seconds'), + defaults.same_ip_same_file_min_interval.seconds, + 1, + 3600 + ) + } + }; +} + +function normalizeDownloadSecuritySettingsPayload(rawPayload, fallbackSettings = DOWNLOAD_SECURITY_DEFAULTS) { + const source = rawPayload && typeof rawPayload === 'object' ? rawPayload : {}; + const base = fallbackSettings && typeof fallbackSettings === 'object' + ? fallbackSettings + : DOWNLOAD_SECURITY_DEFAULTS; + + const sameFileRaw = source.same_ip_same_file && typeof source.same_ip_same_file === 'object' + ? source.same_ip_same_file + : {}; + const sameUserRaw = source.same_ip_same_user && typeof source.same_ip_same_user === 'object' + ? source.same_ip_same_user + : {}; + const minIntervalRaw = source.same_ip_same_file_min_interval && typeof source.same_ip_same_file_min_interval === 'object' + ? source.same_ip_same_file_min_interval + : {}; + + return { + enabled: parseBooleanLike(source.enabled, !!base.enabled), + same_ip_same_file: { + enabled: parseBooleanLike(sameFileRaw.enabled, !!base.same_ip_same_file?.enabled), + limit_5m: clampIntegerSetting( + sameFileRaw.limit_5m, + Number(base.same_ip_same_file?.limit_5m || DOWNLOAD_SECURITY_DEFAULTS.same_ip_same_file.limit_5m), + 1, + 20000 + ), + limit_1h: clampIntegerSetting( + sameFileRaw.limit_1h, + Number(base.same_ip_same_file?.limit_1h || DOWNLOAD_SECURITY_DEFAULTS.same_ip_same_file.limit_1h), + 1, + 50000 + ), + limit_1d: clampIntegerSetting( + sameFileRaw.limit_1d, + Number(base.same_ip_same_file?.limit_1d || DOWNLOAD_SECURITY_DEFAULTS.same_ip_same_file.limit_1d), + 1, + 200000 + ) + }, + same_ip_same_user: { + enabled: parseBooleanLike(sameUserRaw.enabled, !!base.same_ip_same_user?.enabled), + limit_1h: clampIntegerSetting( + sameUserRaw.limit_1h, + Number(base.same_ip_same_user?.limit_1h || DOWNLOAD_SECURITY_DEFAULTS.same_ip_same_user.limit_1h), + 1, + 50000 + ), + limit_1d: clampIntegerSetting( + sameUserRaw.limit_1d, + Number(base.same_ip_same_user?.limit_1d || DOWNLOAD_SECURITY_DEFAULTS.same_ip_same_user.limit_1d), + 1, + 200000 + ) + }, + same_ip_same_file_min_interval: { + enabled: parseBooleanLike( + minIntervalRaw.enabled, + !!base.same_ip_same_file_min_interval?.enabled + ), + seconds: clampIntegerSetting( + minIntervalRaw.seconds, + Number(base.same_ip_same_file_min_interval?.seconds || DOWNLOAD_SECURITY_DEFAULTS.same_ip_same_file_min_interval.seconds), + 1, + 3600 + ) + } + }; +} + +function saveDownloadSecuritySettings(settings) { + SettingsDB.set('download_security_enabled', settings.enabled ? 'true' : 'false'); + SettingsDB.set('download_security_same_file_enabled', settings.same_ip_same_file.enabled ? 'true' : 'false'); + SettingsDB.set('download_security_same_file_limit_5m', String(settings.same_ip_same_file.limit_5m)); + SettingsDB.set('download_security_same_file_limit_1h', String(settings.same_ip_same_file.limit_1h)); + SettingsDB.set('download_security_same_file_limit_1d', String(settings.same_ip_same_file.limit_1d)); + SettingsDB.set('download_security_same_user_enabled', settings.same_ip_same_user.enabled ? 'true' : 'false'); + SettingsDB.set('download_security_same_user_limit_1h', String(settings.same_ip_same_user.limit_1h)); + SettingsDB.set('download_security_same_user_limit_1d', String(settings.same_ip_same_user.limit_1d)); + SettingsDB.set( + 'download_security_same_file_min_interval_enabled', + settings.same_ip_same_file_min_interval.enabled ? 'true' : 'false' + ); + SettingsDB.set( + 'download_security_same_file_min_interval_seconds', + String(settings.same_ip_same_file_min_interval.seconds) + ); +} + +class DownloadSecurityCounter { + constructor() { + this.windowEvents = new Map(); + this.lastSeenAt = new Map(); + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, 10 * 60 * 1000); + } + + getRecentEvents(key, maxWindowMs, now = Date.now()) { + const safeWindow = Math.max(60 * 1000, Number(maxWindowMs) || (24 * 60 * 60 * 1000)); + const cutoff = now - safeWindow; + const list = this.windowEvents.get(key) || []; + if (list.length === 0) { + return []; + } + + let firstValidIndex = 0; + while (firstValidIndex < list.length && list[firstValidIndex] <= cutoff) { + firstValidIndex += 1; + } + if (firstValidIndex > 0) { + list.splice(0, firstValidIndex); + if (list.length === 0) { + this.windowEvents.delete(key); + } else { + this.windowEvents.set(key, list); + } + } + return this.windowEvents.get(key) || []; + } + + wouldExceedWindowLimit(key, rules = [], now = Date.now()) { + if (!key || !Array.isArray(rules) || rules.length === 0) { + return { blocked: false }; + } + + const normalizedRules = rules + .map((rule) => ({ + code: String(rule?.code || 'window_limit'), + windowMs: Math.max(1000, Math.floor(Number(rule?.windowMs) || 0)), + limit: Math.max(1, Math.floor(Number(rule?.limit) || 0)) + })) + .filter(rule => rule.windowMs > 0 && rule.limit > 0); + + if (normalizedRules.length === 0) { + return { blocked: false }; + } + + const maxWindowMs = normalizedRules.reduce((max, item) => Math.max(max, item.windowMs), 0); + const events = this.getRecentEvents(key, maxWindowMs, now); + + for (const rule of normalizedRules) { + const threshold = now - rule.windowMs; + let count = 0; + let limitHitTimestamp = null; + + for (let i = events.length - 1; i >= 0; i -= 1) { + const ts = events[i]; + if (ts <= threshold) { + break; + } + count += 1; + if (count === rule.limit) { + limitHitTimestamp = ts; + break; + } + } + + if (count >= rule.limit) { + const retryAfterMs = limitHitTimestamp + ? Math.max(1000, (limitHitTimestamp + rule.windowMs) - now + 120) + : rule.windowMs; + return { + blocked: true, + code: rule.code, + retryAfterSec: Math.ceil(retryAfterMs / 1000) + }; + } + } + + return { blocked: false }; + } + + recordWindowEvent(key, now = Date.now()) { + if (!key) { + return; + } + const list = this.windowEvents.get(key); + if (list) { + list.push(now); + this.windowEvents.set(key, list); + return; + } + this.windowEvents.set(key, [now]); + } + + wouldViolateMinInterval(key, minIntervalMs, now = Date.now()) { + const safeIntervalMs = Math.max(1000, Math.floor(Number(minIntervalMs) || 0)); + if (!key || safeIntervalMs <= 0) { + return { blocked: false }; + } + + const lastSeen = Number(this.lastSeenAt.get(key) || 0); + if (lastSeen > 0 && now - lastSeen < safeIntervalMs) { + return { + blocked: true, + code: 'same_ip_same_file_min_interval', + retryAfterSec: Math.ceil((safeIntervalMs - (now - lastSeen)) / 1000) + }; + } + return { blocked: false }; + } + + recordMinIntervalEvent(key, now = Date.now()) { + if (!key) { + return; + } + this.lastSeenAt.set(key, now); + } + + cleanup() { + const now = Date.now(); + const keepWindowMs = 2 * 24 * 60 * 60 * 1000; + const staleWindowBefore = now - keepWindowMs; + + for (const [key, list] of this.windowEvents.entries()) { + if (!Array.isArray(list) || list.length === 0) { + this.windowEvents.delete(key); + continue; + } + + let firstValidIndex = 0; + while (firstValidIndex < list.length && list[firstValidIndex] <= staleWindowBefore) { + firstValidIndex += 1; + } + + if (firstValidIndex > 0) { + list.splice(0, firstValidIndex); + } + + if (list.length === 0) { + this.windowEvents.delete(key); + } else { + this.windowEvents.set(key, list); + } + } + + for (const [key, value] of this.lastSeenAt.entries()) { + if (!Number.isFinite(value) || value <= staleWindowBefore) { + this.lastSeenAt.delete(key); + } + } + } +} + +const downloadSecurityCounter = new DownloadSecurityCounter(); + +function evaluateDownloadSecurityPolicy(req, { + ownerUserId, + filePath, + source = 'download' +} = {}) { + const settings = getDownloadSecuritySettings(); + if (!settings.enabled) { + return { allowed: true }; + } + + const ownerId = Math.floor(Number(ownerUserId) || 0); + const normalizedPath = normalizeVirtualPath(filePath || ''); + if (ownerId <= 0 || !normalizedPath) { + return { allowed: true }; + } + + const clientIp = String(req?.ip || req?.socket?.remoteAddress || '').trim(); + if (!clientIp) { + return { allowed: true }; + } + + const now = Date.now(); + const eventKeys = []; + const minIntervalKeys = []; + + if (settings.same_ip_same_file.enabled) { + const sameFileKey = `download_sec:file:${clientIp}:${ownerId}:${normalizedPath}`; + const sameFileRules = [ + { + code: 'same_ip_same_file_5m', + windowMs: 5 * 60 * 1000, + limit: settings.same_ip_same_file.limit_5m + }, + { + code: 'same_ip_same_file_1h', + windowMs: 60 * 60 * 1000, + limit: settings.same_ip_same_file.limit_1h + }, + { + code: 'same_ip_same_file_1d', + windowMs: 24 * 60 * 60 * 1000, + limit: settings.same_ip_same_file.limit_1d + } + ]; + + const sameFileCheck = downloadSecurityCounter.wouldExceedWindowLimit(sameFileKey, sameFileRules, now); + if (sameFileCheck.blocked) { + return { + allowed: false, + code: sameFileCheck.code, + retryAfterSec: sameFileCheck.retryAfterSec + }; + } + eventKeys.push(sameFileKey); + + if (settings.same_ip_same_file_min_interval.enabled) { + const minIntervalKey = `download_sec:min:${clientIp}:${ownerId}:${normalizedPath}`; + const minIntervalCheck = downloadSecurityCounter.wouldViolateMinInterval( + minIntervalKey, + settings.same_ip_same_file_min_interval.seconds * 1000, + now + ); + if (minIntervalCheck.blocked) { + return { + allowed: false, + code: minIntervalCheck.code, + retryAfterSec: minIntervalCheck.retryAfterSec + }; + } + minIntervalKeys.push(minIntervalKey); + } + } + + if (settings.same_ip_same_user.enabled) { + const sameUserKey = `download_sec:user:${clientIp}:${ownerId}`; + const sameUserRules = [ + { + code: 'same_ip_same_user_1h', + windowMs: 60 * 60 * 1000, + limit: settings.same_ip_same_user.limit_1h + }, + { + code: 'same_ip_same_user_1d', + windowMs: 24 * 60 * 60 * 1000, + limit: settings.same_ip_same_user.limit_1d + } + ]; + + const sameUserCheck = downloadSecurityCounter.wouldExceedWindowLimit(sameUserKey, sameUserRules, now); + if (sameUserCheck.blocked) { + return { + allowed: false, + code: sameUserCheck.code, + retryAfterSec: sameUserCheck.retryAfterSec + }; + } + eventKeys.push(sameUserKey); + } + + for (const key of eventKeys) { + downloadSecurityCounter.recordWindowEvent(key, now); + } + for (const key of minIntervalKeys) { + downloadSecurityCounter.recordMinIntervalEvent(key, now); + } + + return { + allowed: true, + source + }; +} + +function handleDownloadSecurityBlock(req, res, blockResult, options = {}) { + const statusCode = Number(options.statusCode || 503); + const plainText = options.plainText === true; + const ownerUserId = Number(options.ownerUserId || 0); + const filePath = typeof options.filePath === 'string' ? options.filePath : ''; + const source = options.source || 'download'; + + logSecurity( + req, + 'download_security_block', + `下载请求触发限速策略(${source})`, + { + source, + policy_code: blockResult?.code || 'download_security_blocked', + retry_after_sec: Number(blockResult?.retryAfterSec || 0), + owner_user_id: ownerUserId || null, + file_path: filePath || null + }, + 'warn' + ); + + if (plainText) { + return sendPlainTextError(res, statusCode, getBusyDownloadMessage()); + } + + return res.status(statusCode).json({ + success: false, + message: getBusyDownloadMessage(), + security_blocked: true + }); +} + function sendPlainTextError(res, statusCode, message) { return res.status(statusCode).type('text/plain; charset=utf-8').send(message); } @@ -5770,6 +6264,22 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => { }); } + if (!isPreviewMode) { + const securityResult = evaluateDownloadSecurityPolicy(req, { + ownerUserId: latestUser.id, + filePath: normalizedPath, + source: 'user_download_url' + }); + if (!securityResult.allowed) { + return handleDownloadSecurityBlock(req, res, securityResult, { + statusCode: 503, + ownerUserId: latestUser.id, + filePath: normalizedPath, + source: 'user_download_url' + }); + } + } + const trafficState = getDownloadTrafficState(latestUser); // 检查用户是否配置了 OSS(包括个人配置和系统级统一配置) @@ -6183,6 +6693,21 @@ app.get('/api/files/download', authMiddleware, async (req, res) => { message: '用户不存在' }); } + + const securityResult = evaluateDownloadSecurityPolicy(req, { + ownerUserId: latestUser.id, + filePath: normalizedPath, + source: 'user_download_stream' + }); + if (!securityResult.allowed) { + return handleDownloadSecurityBlock(req, res, securityResult, { + statusCode: 503, + ownerUserId: latestUser.id, + filePath: normalizedPath, + source: 'user_download_stream' + }); + } + const trafficState = getDownloadTrafficState(latestUser); // 使用统一存储接口 @@ -7517,6 +8042,22 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, const storageType = share.storage_type || 'oss'; const ownerTrafficState = getDownloadTrafficState(shareOwner); + if (storageType === 'oss') { + const securityResult = evaluateDownloadSecurityPolicy(req, { + ownerUserId: shareOwner.id, + filePath: normalizedFilePath, + source: 'share_download_url_oss' + }); + if (!securityResult.allowed) { + return handleDownloadSecurityBlock(req, res, securityResult, { + statusCode: 503, + ownerUserId: shareOwner.id, + filePath: normalizedFilePath, + source: 'share_download_url_oss' + }); + } + } + // 本地存储:继续走后端下载 if (storageType !== 'oss') { if (!ownerTrafficState.isUnlimited) { @@ -7808,6 +8349,21 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, }); } shareOwnerId = shareOwner.id; + + const securityResult = evaluateDownloadSecurityPolicy(req, { + ownerUserId: shareOwner.id, + filePath, + source: 'share_download_stream' + }); + if (!securityResult.allowed) { + return handleDownloadSecurityBlock(req, res, securityResult, { + statusCode: 503, + ownerUserId: shareOwner.id, + filePath, + source: 'share_download_stream' + }); + } + const ownerTrafficState = getDownloadTrafficState(shareOwner); // 使用统一存储接口,根据分享的storage_type选择存储后端 @@ -7916,12 +8472,14 @@ app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { const smtpFrom = SettingsDB.get('smtp_from') || smtpUser; const smtpHasPassword = !!SettingsDB.get('smtp_password'); const globalTheme = SettingsDB.get('global_theme') || 'dark'; + const downloadSecurity = getDownloadSecuritySettings(); res.json({ success: true, settings: { max_upload_size: maxUploadSize, global_theme: globalTheme, + download_security: downloadSecurity, smtp: { host: smtpHost || '', port: smtpPort ? parseInt(smtpPort, 10) : 465, @@ -7948,7 +8506,12 @@ app.post('/api/admin/settings', adminMiddleware, (req, res) => { try { - const { max_upload_size, smtp, global_theme } = req.body; + const { + max_upload_size, + smtp, + global_theme, + download_security + } = req.body; if (max_upload_size !== undefined) { const size = parseInt(max_upload_size); @@ -7973,6 +8536,22 @@ app.post('/api/admin/settings', SettingsDB.set('global_theme', global_theme); } + if (download_security !== undefined) { + if (!download_security || typeof download_security !== 'object') { + return res.status(400).json({ + success: false, + message: '下载安全策略配置格式错误' + }); + } + + const currentSecuritySettings = getDownloadSecuritySettings(); + const normalizedSecuritySettings = normalizeDownloadSecuritySettingsPayload( + download_security, + currentSecuritySettings + ); + saveDownloadSecuritySettings(normalizedSecuritySettings); + } + if (smtp) { if (!smtp.host || !smtp.port || !smtp.user) { return res.status(400).json({ success: false, message: 'SMTP配置不完整' }); @@ -9813,6 +10392,21 @@ app.get('/d/:code', async (req, res) => { } linkOwnerId = linkOwner.id; + const securityResult = evaluateDownloadSecurityPolicy(req, { + ownerUserId: linkOwner.id, + filePath: normalizedPath, + source: 'direct_link_download' + }); + if (!securityResult.allowed) { + return handleDownloadSecurityBlock(req, res, securityResult, { + statusCode: 503, + plainText: true, + ownerUserId: linkOwner.id, + filePath: normalizedPath, + source: 'direct_link_download' + }); + } + const ownerTrafficState = getDownloadTrafficState(linkOwner); const storageType = directLink.storage_type || 'oss'; const directFileName = (directLink.file_name && String(directLink.file_name).trim()) diff --git a/frontend/app.html b/frontend/app.html index 4f1aecc..90f8090 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -3396,6 +3396,63 @@ 修改后需要重启服务才能生效 +