fix: harden backend stability and test production utilities
This commit is contained in:
@@ -11,7 +11,6 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const zlib = require('zlib');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const archiver = require('archiver');
|
||||
const crypto = require('crypto');
|
||||
const { exec, execSync, execFile } = require('child_process');
|
||||
const util = require('util');
|
||||
@@ -95,6 +94,30 @@ const {
|
||||
} = require('./auth');
|
||||
const { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, formatOssError } = require('./storage');
|
||||
const { encryptSecret, decryptSecret } = require('./utils/encryption');
|
||||
const {
|
||||
sanitizeInput,
|
||||
decodeHtmlEntities,
|
||||
escapeHtml,
|
||||
isSafePathSegment,
|
||||
isFileExtensionSafe
|
||||
} = require('./utils/input-security');
|
||||
const {
|
||||
parseDateTimeValue,
|
||||
formatDateTimeForSqlite,
|
||||
getDateKeyFromDate,
|
||||
getRecentDateKeys,
|
||||
normalizeTimeHHmm,
|
||||
isCurrentTimeInWindow
|
||||
} = require('./utils/datetime');
|
||||
const {
|
||||
MAX_DOWNLOAD_TRAFFIC_BYTES,
|
||||
normalizeDownloadTrafficQuota,
|
||||
normalizeDownloadTrafficUsed,
|
||||
getDownloadTrafficState,
|
||||
resolveDownloadTrafficPolicyUpdates
|
||||
} = require('./utils/download-quota');
|
||||
const { expressErrorHandler } = require('./middleware/error-handler');
|
||||
const { createZipArchive } = require('./utils/archive');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 40001;
|
||||
@@ -102,7 +125,6 @@ const USERNAME_REGEX = /^[A-Za-z0-9_.\u4e00-\u9fa5-]{3,20}$/u; // 允许中英
|
||||
const ENFORCE_HTTPS = process.env.ENFORCE_HTTPS === 'true';
|
||||
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||
const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||
const MAX_DOWNLOAD_TRAFFIC_BYTES = 10 * 1024 * 1024 * 1024 * 1024; // 10TB
|
||||
const DOWNLOAD_POLICY_SWEEP_INTERVAL_MS = 30 * 60 * 1000; // 30分钟
|
||||
const DOWNLOAD_RESERVATION_TTL_MS = Number(process.env.DOWNLOAD_RESERVATION_TTL_MS || (30 * 60 * 1000)); // 30分钟
|
||||
const DOWNLOAD_LOG_RECONCILE_INTERVAL_MS = Number(process.env.DOWNLOAD_LOG_RECONCILE_INTERVAL_MS || (5 * 60 * 1000)); // 5分钟
|
||||
@@ -637,7 +659,13 @@ if (ENABLE_CSRF) {
|
||||
// 安全说明:使用 req.secure 判断,该值基于 trust proxy 配置,
|
||||
// 只有在信任代理链中的代理才会被采信其 X-Forwarded-Proto 头
|
||||
app.use((req, res, next) => {
|
||||
if (!ENFORCE_HTTPS) return next();
|
||||
const remoteAddress = req.socket?.remoteAddress || '';
|
||||
const isLocalHealthCheck = req.path === '/api/health' && [
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
'::ffff:127.0.0.1'
|
||||
].includes(remoteAddress);
|
||||
if (!ENFORCE_HTTPS || isLocalHealthCheck) return next();
|
||||
|
||||
// req.secure 由 Express 根据 trust proxy 配置计算:
|
||||
// - 如果 trust proxy = false,仅检查直接连接是否为 TLS
|
||||
@@ -657,95 +685,6 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* XSS过滤函数 - 过滤用户输入中的潜在XSS攻击代码
|
||||
* 注意:不转义 / 因为它是文件路径的合法字符
|
||||
* @param {string} str - 需要过滤的输入字符串
|
||||
* @returns {string} 过滤后的安全字符串
|
||||
*/
|
||||
function sanitizeInput(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
|
||||
// 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 实体解码为原始字符
|
||||
* 用于处理经过XSS过滤后的文件名/路径字段,恢复原始字符
|
||||
* 支持嵌套实体的递归解码(如 &#x60; -> ` -> `)
|
||||
* @param {string} str - 包含HTML实体的字符串
|
||||
* @returns {string} 解码后的原始字符串
|
||||
*/
|
||||
function decodeHtmlEntities(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
|
||||
// 支持常见实体和数字实体(含多次嵌套,如 &#x60;)
|
||||
const entityMap = {
|
||||
amp: '&',
|
||||
lt: '<',
|
||||
gt: '>',
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
'#x27': "'",
|
||||
'#x2F': '/',
|
||||
'#x60': '`'
|
||||
};
|
||||
|
||||
const decodeOnce = (input) =>
|
||||
input.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, code) => {
|
||||
if (code[0] === '#') {
|
||||
const isHex = code[1]?.toLowerCase() === 'x';
|
||||
const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
|
||||
if (!Number.isNaN(num)) {
|
||||
return String.fromCharCode(num);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
const mapped = entityMap[code];
|
||||
return mapped !== undefined ? mapped : match;
|
||||
});
|
||||
|
||||
let output = str;
|
||||
let decoded = decodeOnce(output);
|
||||
// 处理嵌套实体(如 &#x60;),直到稳定
|
||||
while (decoded !== output) {
|
||||
output = decoded;
|
||||
decoded = decodeOnce(output);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// HTML转义(用于模板输出)
|
||||
function escapeHtml(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
return str.replace(/[&<>"']/g, char => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[char]));
|
||||
}
|
||||
|
||||
// 规范化并校验HTTP直链前缀,只允许http/https
|
||||
function sanitizeHttpBaseUrl(raw) {
|
||||
if (!raw) return null;
|
||||
@@ -790,56 +729,6 @@ function buildHttpDownloadUrl(rawBaseUrl, filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// 校验文件名/路径片段安全(禁止分隔符、控制字符、..)
|
||||
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();
|
||||
const nameLower = filename.toLowerCase();
|
||||
|
||||
// 检查危险扩展名
|
||||
if (DANGEROUS_EXTENSIONS.includes(ext)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 特殊处理:检查以危险名称开头的文件(如 .htaccess, .htpasswd)
|
||||
// 因为 path.extname('.htaccess') 返回空字符串
|
||||
const dangerousFilenames = ['.htaccess', '.htpasswd'];
|
||||
if (dangerousFilenames.includes(nameLower)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查双扩展名攻击(如 file.php.jpg 可能被某些配置错误的服务器执行)
|
||||
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) {
|
||||
@@ -903,40 +792,6 @@ function normalizeOssQuota(rawQuota) {
|
||||
return parsedQuota;
|
||||
}
|
||||
|
||||
function normalizeDownloadTrafficQuota(rawQuota) {
|
||||
const parsedQuota = Number(rawQuota);
|
||||
if (!Number.isFinite(parsedQuota)) {
|
||||
return 0; // 0 表示禁止下载
|
||||
}
|
||||
if (parsedQuota < 0) {
|
||||
return -1; // -1 表示不限流量
|
||||
}
|
||||
return Math.min(MAX_DOWNLOAD_TRAFFIC_BYTES, Math.floor(parsedQuota));
|
||||
}
|
||||
|
||||
function normalizeDownloadTrafficUsed(rawUsed, quota = 0) {
|
||||
const parsedUsed = Number(rawUsed);
|
||||
const normalizedUsed = Number.isFinite(parsedUsed) && parsedUsed > 0
|
||||
? Math.floor(parsedUsed)
|
||||
: 0;
|
||||
if (quota >= 0) {
|
||||
return Math.min(normalizedUsed, quota);
|
||||
}
|
||||
return normalizedUsed;
|
||||
}
|
||||
|
||||
function getDownloadTrafficState(user) {
|
||||
const quota = normalizeDownloadTrafficQuota(user?.download_traffic_quota);
|
||||
const used = normalizeDownloadTrafficUsed(user?.download_traffic_used, quota);
|
||||
const isUnlimited = quota < 0;
|
||||
return {
|
||||
quota,
|
||||
used,
|
||||
isUnlimited,
|
||||
remaining: isUnlimited ? Number.POSITIVE_INFINITY : Math.max(0, quota - used)
|
||||
};
|
||||
}
|
||||
|
||||
function getBusyDownloadMessage() {
|
||||
return '当前网络繁忙,请稍后再试';
|
||||
}
|
||||
@@ -1468,37 +1323,6 @@ function sendPlainTextError(res, statusCode, message) {
|
||||
return res.status(statusCode).type('text/plain; charset=utf-8').send(message);
|
||||
}
|
||||
|
||||
function parseDateTimeValue(value) {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directDate = new Date(value);
|
||||
if (!Number.isNaN(directDate.getTime())) {
|
||||
return directDate;
|
||||
}
|
||||
|
||||
// 兼容 SQLite 常见 DATETIME 格式: YYYY-MM-DD HH:mm:ss
|
||||
const normalized = value.replace(' ', 'T');
|
||||
const normalizedDate = new Date(normalized);
|
||||
if (!Number.isNaN(normalizedDate.getTime())) {
|
||||
return normalizedDate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDateTimeForSqlite(date = new Date()) {
|
||||
const target = date instanceof Date ? date : new Date(date);
|
||||
const year = target.getFullYear();
|
||||
const month = String(target.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(target.getDate()).padStart(2, '0');
|
||||
const hours = String(target.getHours()).padStart(2, '0');
|
||||
const minutes = String(target.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(target.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
function createOssUploadReservationToken() {
|
||||
return crypto.randomBytes(24).toString('hex');
|
||||
}
|
||||
@@ -1519,132 +1343,6 @@ function encodeS3CopySource(bucket, key) {
|
||||
return `${encodedBucket}/${encodedKey}`;
|
||||
}
|
||||
|
||||
function getDateKeyFromDate(date = new Date()) {
|
||||
const target = date instanceof Date ? date : new Date(date);
|
||||
if (Number.isNaN(target.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const year = target.getFullYear();
|
||||
const month = String(target.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(target.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function getRecentDateKeys(days = 30, now = new Date()) {
|
||||
const safeDays = Math.max(1, Math.floor(Number(days) || 30));
|
||||
const keys = [];
|
||||
for (let i = safeDays - 1; i >= 0; i -= 1) {
|
||||
const date = new Date(now.getTime());
|
||||
date.setDate(date.getDate() - i);
|
||||
const key = getDateKeyFromDate(date);
|
||||
if (key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function getNextDownloadResetTime(lastResetAt, resetCycle) {
|
||||
const baseDate = parseDateTimeValue(lastResetAt);
|
||||
if (!baseDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const next = new Date(baseDate.getTime());
|
||||
if (resetCycle === 'daily') {
|
||||
next.setDate(next.getDate() + 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
if (resetCycle === 'weekly') {
|
||||
next.setDate(next.getDate() + 7);
|
||||
return next;
|
||||
}
|
||||
|
||||
if (resetCycle === 'monthly') {
|
||||
next.setMonth(next.getMonth() + 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDownloadTrafficPolicyUpdates(user, now = new Date()) {
|
||||
if (!user) {
|
||||
return {
|
||||
updates: {},
|
||||
hasUpdates: false,
|
||||
expired: false,
|
||||
resetApplied: false
|
||||
};
|
||||
}
|
||||
|
||||
const updates = {};
|
||||
let hasUpdates = false;
|
||||
let expired = false;
|
||||
let resetApplied = false;
|
||||
|
||||
const normalizedQuota = normalizeDownloadTrafficQuota(user.download_traffic_quota);
|
||||
const normalizedUsed = normalizeDownloadTrafficUsed(user.download_traffic_used, normalizedQuota);
|
||||
if (normalizedQuota !== Number(user.download_traffic_quota || 0)) {
|
||||
updates.download_traffic_quota = normalizedQuota;
|
||||
hasUpdates = true;
|
||||
}
|
||||
if (normalizedUsed !== Number(user.download_traffic_used || 0)) {
|
||||
updates.download_traffic_used = normalizedUsed;
|
||||
hasUpdates = true;
|
||||
}
|
||||
|
||||
const resetCycle = ['none', 'daily', 'weekly', 'monthly'].includes(user.download_traffic_reset_cycle)
|
||||
? user.download_traffic_reset_cycle
|
||||
: 'none';
|
||||
if (resetCycle !== (user.download_traffic_reset_cycle || 'none')) {
|
||||
updates.download_traffic_reset_cycle = resetCycle;
|
||||
hasUpdates = true;
|
||||
}
|
||||
|
||||
const expiresAt = parseDateTimeValue(user.download_traffic_quota_expires_at);
|
||||
if (normalizedQuota <= 0 && user.download_traffic_quota_expires_at) {
|
||||
updates.download_traffic_quota_expires_at = null;
|
||||
hasUpdates = true;
|
||||
} else if (normalizedQuota > 0 && expiresAt && now >= expiresAt) {
|
||||
// 到期后自动恢复为不限并重置已用量
|
||||
updates.download_traffic_quota = 0;
|
||||
updates.download_traffic_used = 0;
|
||||
updates.download_traffic_quota_expires_at = null;
|
||||
updates.download_traffic_reset_cycle = 'none';
|
||||
updates.download_traffic_last_reset_at = null;
|
||||
hasUpdates = true;
|
||||
expired = true;
|
||||
}
|
||||
|
||||
if (!expired && resetCycle !== 'none') {
|
||||
const lastResetAt = user.download_traffic_last_reset_at;
|
||||
if (!lastResetAt) {
|
||||
updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now);
|
||||
hasUpdates = true;
|
||||
} else {
|
||||
const nextResetAt = getNextDownloadResetTime(lastResetAt, resetCycle);
|
||||
if (nextResetAt && now >= nextResetAt) {
|
||||
updates.download_traffic_used = 0;
|
||||
updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now);
|
||||
hasUpdates = true;
|
||||
resetApplied = true;
|
||||
}
|
||||
}
|
||||
} else if (resetCycle === 'none' && user.download_traffic_last_reset_at) {
|
||||
updates.download_traffic_last_reset_at = null;
|
||||
hasUpdates = true;
|
||||
}
|
||||
|
||||
return {
|
||||
updates,
|
||||
hasUpdates,
|
||||
expired,
|
||||
resetApplied
|
||||
};
|
||||
}
|
||||
|
||||
const enforceDownloadTrafficPolicyTransaction = db.transaction((userId, trigger = 'runtime') => {
|
||||
let user = UserDB.findById(userId);
|
||||
if (!user) {
|
||||
@@ -3262,43 +2960,6 @@ function dedupeOnlineDeviceRows(rows = [], currentSessionId = '') {
|
||||
return Array.from(deduped.values());
|
||||
}
|
||||
|
||||
function normalizeTimeHHmm(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
const match = trimmed.match(/^(\d{2}):(\d{2})$/);
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
|
||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function toMinutesOfDay(hhmm) {
|
||||
const normalized = normalizeTimeHHmm(hhmm);
|
||||
if (!normalized) return null;
|
||||
const [hh, mm] = normalized.split(':').map(Number);
|
||||
return hh * 60 + mm;
|
||||
}
|
||||
|
||||
function isCurrentTimeInWindow(startTime, endTime, now = new Date()) {
|
||||
const start = toMinutesOfDay(startTime);
|
||||
const end = toMinutesOfDay(endTime);
|
||||
if (start === null || end === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
if (start === end) {
|
||||
return true;
|
||||
}
|
||||
if (start < end) {
|
||||
return nowMinutes >= start && nowMinutes < end;
|
||||
}
|
||||
// 跨天窗口:如 22:00 - 06:00
|
||||
return nowMinutes >= start || nowMinutes < end;
|
||||
}
|
||||
|
||||
function getSharePolicySummary(share) {
|
||||
const maxDownloads = Number(share?.max_downloads);
|
||||
const whitelist = parseShareIpWhitelist(share?.ip_whitelist || '');
|
||||
@@ -7916,7 +7577,7 @@ app.get('/api/upload/download-tool', authMiddleware, async (req, res) => {
|
||||
|
||||
// 创建文件写入流
|
||||
const output = fs.createWriteStream(tempZipPath);
|
||||
const archive = archiver('zip', {
|
||||
const archive = await createZipArchive({
|
||||
store: true // 使用STORE模式,不压缩,速度最快
|
||||
});
|
||||
|
||||
@@ -11791,6 +11452,35 @@ app.get("/s/:code", (req, res) => {
|
||||
res.redirect(frontendUrl);
|
||||
});
|
||||
|
||||
// Keep this after every route so Express can normalize synchronous and next(err) failures.
|
||||
app.use(expressErrorHandler);
|
||||
|
||||
let server = null;
|
||||
let fatalShutdownStarted = false;
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[unhandledRejection]', reason);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('[uncaughtException]', error);
|
||||
if (fatalShutdownStarted) return;
|
||||
fatalShutdownStarted = true;
|
||||
|
||||
const forceExitTimer = setTimeout(() => process.exit(1), 10_000);
|
||||
forceExitTimer.unref();
|
||||
const exitAfterClose = () => {
|
||||
clearTimeout(forceExitTimer);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
if (server?.listening) {
|
||||
server.close(exitAfterClose);
|
||||
} else {
|
||||
exitAfterClose();
|
||||
}
|
||||
});
|
||||
|
||||
// 启动时清理旧临时文件
|
||||
cleanupOldTempFiles();
|
||||
const desktopCleanupOnStartup = cleanupDesktopInstallerPackages(getDesktopUpdateConfig().installerUrl);
|
||||
@@ -11799,7 +11489,7 @@ if (desktopCleanupOnStartup.executed && desktopCleanupOnStartup.removed > 0) {
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
server = app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`\n========================================`);
|
||||
console.log(`玩玩云已启动`);
|
||||
console.log(`服务器地址: http://localhost:${PORT}`);
|
||||
|
||||
Reference in New Issue
Block a user