feat: add configurable stealth download security policies

This commit is contained in:
2026-02-18 09:48:14 +08:00
parent 8956270a60
commit 96ff46aa4a
4 changed files with 735 additions and 1 deletions

View File

@@ -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())