refactor: modularize backend security and observability
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
// 加载环境变量(必须在最开始)
|
||||
require('dotenv').config();
|
||||
|
||||
const { installConsoleBridge, logger, requestContextMiddleware } = require('./utils/logger');
|
||||
installConsoleBridge();
|
||||
|
||||
const express = require('express');
|
||||
require('express-async-errors');
|
||||
const cors = require('cors');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const svgCaptcha = require('svg-captcha');
|
||||
@@ -117,7 +121,45 @@ const {
|
||||
resolveDownloadTrafficPolicyUpdates
|
||||
} = require('./utils/download-quota');
|
||||
const { expressErrorHandler } = require('./middleware/error-handler');
|
||||
const {
|
||||
CSRF_COOKIE_NAME,
|
||||
createCsrfCookieMiddleware,
|
||||
csrfProtection,
|
||||
generateCsrfToken,
|
||||
setCsrfCookie
|
||||
} = require('./middleware/csrf');
|
||||
const { RateLimiter, createGlobalApiLimiter } = require('./middleware/rate-limit');
|
||||
const {
|
||||
createHttpsEnforcementMiddleware,
|
||||
securityHeadersMiddleware
|
||||
} = require('./middleware/security');
|
||||
const { createZipArchive } = require('./utils/archive');
|
||||
const { normalizeNonNegativeInteger, parseBooleanLike } = require('./utils/coerce');
|
||||
const {
|
||||
buildDeviceName,
|
||||
detectDeviceTypeFromUserAgent,
|
||||
inferPlatformFromUserAgent,
|
||||
normalizeClientIp,
|
||||
resolveClientType,
|
||||
sanitizeDeviceText
|
||||
} = require('./utils/device');
|
||||
const { parseDownloadTrafficLine } = require('./utils/oss-log-parser');
|
||||
const { isPathInside, normalizeVirtualPath } = require('./utils/path-safety');
|
||||
const {
|
||||
isPathWithinShare,
|
||||
isShareIpAllowed,
|
||||
isValidShareCode,
|
||||
parseShareIpWhitelist
|
||||
} = require('./utils/share');
|
||||
const { buildHttpDownloadUrl } = require('./utils/url');
|
||||
const {
|
||||
compareLooseVersion,
|
||||
normalizeReleaseNotes,
|
||||
normalizeSha256,
|
||||
normalizeVersion
|
||||
} = require('./utils/version');
|
||||
const { createHealthService } = require('./services/health-service');
|
||||
const { createSystemRouter } = require('./routes/system.routes');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 40001;
|
||||
@@ -140,9 +182,9 @@ const DEFAULT_DESKTOP_INSTALLER_SHA256 = String(process.env.DESKTOP_INSTALLER_SH
|
||||
const DEFAULT_DESKTOP_INSTALLER_SIZE = Math.max(0, Number(process.env.DESKTOP_INSTALLER_SIZE || 0));
|
||||
const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || '';
|
||||
const FRONTEND_ROOT_DIR = path.resolve(__dirname, '../frontend');
|
||||
const FRONTEND_BUILD_DIR = path.join(FRONTEND_ROOT_DIR, 'dist');
|
||||
const DESKTOP_INSTALLERS_DIR = path.resolve(__dirname, '../frontend/downloads');
|
||||
const DESKTOP_INSTALLER_FILE_PATTERN = /^(wanwan-cloud-desktop|玩玩云)_.*_x64-setup\.exe$/i;
|
||||
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
|
||||
@@ -159,7 +201,6 @@ 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);
|
||||
const SHARE_CODE_REGEX = /^[A-Za-z0-9]{6,32}$/;
|
||||
const DOWNLOAD_SECURITY_DEFAULTS = Object.freeze({
|
||||
enabled: true,
|
||||
same_ip_same_file: {
|
||||
@@ -194,55 +235,6 @@ const DEFAULT_CAPTCHA_SECRETS = [
|
||||
const isSecureCookie = SHOULD_USE_SECURE_COOKIES;
|
||||
const sameSiteMode = isSecureCookie ? 'none' : 'lax';
|
||||
|
||||
function normalizeVersion(rawVersion, fallback = '0.0.0') {
|
||||
const value = String(rawVersion || '').trim();
|
||||
if (!value) return fallback;
|
||||
return value;
|
||||
}
|
||||
|
||||
function compareLooseVersion(left, right) {
|
||||
const normalize = (value) => normalizeVersion(value, '0.0.0')
|
||||
.replace(/^v/i, '')
|
||||
.split('.')
|
||||
.map((part) => parseInt(part, 10))
|
||||
.map((num) => (Number.isFinite(num) ? num : 0));
|
||||
|
||||
const a = normalize(left);
|
||||
const b = normalize(right);
|
||||
const size = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < size; i += 1) {
|
||||
const av = a[i] || 0;
|
||||
const bv = b[i] || 0;
|
||||
if (av > bv) return 1;
|
||||
if (av < bv) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeReleaseNotes(rawValue) {
|
||||
return String(rawValue || '')
|
||||
.replace(/\\r\\n/g, '\n')
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\r/g, '\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeSha256(rawValue) {
|
||||
const digest = String(rawValue || '').trim().toLowerCase();
|
||||
return DESKTOP_INSTALLER_SHA256_PATTERN.test(digest) ? digest : '';
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInteger(rawValue, fallback = 0) {
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isFinite(value) || value < 0) return Math.max(0, Number(fallback) || 0);
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function isPathInside(parent, child) {
|
||||
const rel = path.relative(parent, child);
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
||||
}
|
||||
|
||||
function resolveDesktopInstallerLocalPath(installerUrl) {
|
||||
const raw = String(installerUrl || '').trim();
|
||||
if (!raw) return null;
|
||||
@@ -551,98 +543,6 @@ const corsOptions = {
|
||||
}
|
||||
};
|
||||
|
||||
function applySecurityHeaders(req, res) {
|
||||
// 防止点击劫持
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
// 防止MIME类型嗅探
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
// XSS保护
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
// HTTPS严格传输安全(仅在可信的 HTTPS 连接时设置)
|
||||
// req.secure 基于 trust proxy 配置,不会被不可信代理伪造
|
||||
if ((req && req.secure) || (!req && (ENFORCE_HTTPS || SHOULD_USE_SECURE_COOKIES))) {
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
// 内容安全策略
|
||||
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');
|
||||
}
|
||||
|
||||
// 中间件
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
// 静态文件服务 - 提供前端页面
|
||||
const frontendPath = path.join(__dirname, '../frontend');
|
||||
console.log('[静态文件] 前端目录:', frontendPath);
|
||||
app.use(express.static(frontendPath, {
|
||||
setHeaders: (res) => {
|
||||
applySecurityHeaders(null, res);
|
||||
}
|
||||
}));
|
||||
|
||||
app.use(express.json({ limit: '10mb' })); // 限制请求体大小防止DoS
|
||||
app.use(cookieParser());
|
||||
|
||||
// ===== CSRF 防护 =====
|
||||
// 基于 Double Submit Cookie 模式的 CSRF 保护
|
||||
// 对于修改数据的请求(POST/PUT/DELETE),验证请求头中的 X-CSRF-Token 与 Cookie 中的值匹配
|
||||
|
||||
// 生成 CSRF Token
|
||||
function generateCsrfToken() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// CSRF Token Cookie 名称
|
||||
const CSRF_COOKIE_NAME = 'csrf_token';
|
||||
|
||||
// 设置 CSRF Cookie 的中间件
|
||||
app.use((req, res, next) => {
|
||||
// 如果没有 CSRF cookie,则生成一个
|
||||
if (!req.cookies[CSRF_COOKIE_NAME]) {
|
||||
const csrfToken = generateCsrfToken();
|
||||
const isSecureEnv = SHOULD_USE_SECURE_COOKIES;
|
||||
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
|
||||
httpOnly: false, // 前端需要读取此值
|
||||
secure: isSecureEnv,
|
||||
sameSite: isSecureEnv ? 'strict' : 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24小时
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// CSRF 验证中间件(仅用于需要保护的路由)
|
||||
function csrfProtection(req, res, next) {
|
||||
// GET、HEAD、OPTIONS 请求不需要 CSRF 保护
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// 仅对基于 Cookie 的浏览器会话启用 CSRF(Bearer API 客户端不强制)
|
||||
const hasCookieAuth = !!(
|
||||
req.cookies?.token ||
|
||||
req.cookies?.refreshToken
|
||||
);
|
||||
|
||||
if (!hasCookieAuth) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const cookieToken = req.cookies[CSRF_COOKIE_NAME];
|
||||
const headerToken = req.headers['x-csrf-token'];
|
||||
|
||||
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
|
||||
console.warn(`[CSRF] 验证失败: path=${req.path}, cookie=${!!cookieToken}, header=${!!headerToken}`);
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'CSRF 验证失败,请刷新页面后重试'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// CSRF 开关策略:
|
||||
// - 显式配置 ENABLE_CSRF 时按配置值
|
||||
// - 未配置时,生产环境默认开启
|
||||
@@ -650,85 +550,34 @@ const ENABLE_CSRF = process.env.ENABLE_CSRF !== undefined
|
||||
? process.env.ENABLE_CSRF === 'true'
|
||||
: process.env.NODE_ENV === 'production';
|
||||
|
||||
// 中间件顺序是安全边界:请求标识和安全头最先,限流先于请求体解析。
|
||||
app.use(requestContextMiddleware);
|
||||
app.use(securityHeadersMiddleware({
|
||||
secureByDefault: ENFORCE_HTTPS || SHOULD_USE_SECURE_COOKIES
|
||||
}));
|
||||
app.use(cors(corsOptions));
|
||||
app.use(createHttpsEnforcementMiddleware({ enabled: ENFORCE_HTTPS }));
|
||||
|
||||
const hasFrontendBuild = fs.existsSync(path.join(FRONTEND_BUILD_DIR, 'index.html'));
|
||||
const frontendPath = hasFrontendBuild ? FRONTEND_BUILD_DIR : FRONTEND_ROOT_DIR;
|
||||
if (!hasFrontendBuild && process.env.NODE_ENV === 'production') {
|
||||
logger.warn({ frontendPath }, 'frontend build is missing; serving source files as fallback');
|
||||
}
|
||||
logger.info({ frontendPath, built: hasFrontendBuild }, 'frontend static directory configured');
|
||||
|
||||
// 安装包由管理端动态发布,不进入 Vite 构建产物。
|
||||
app.use('/downloads', express.static(DESKTOP_INSTALLERS_DIR));
|
||||
app.use(express.static(frontendPath));
|
||||
app.use('/api', createGlobalApiLimiter());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(cookieParser());
|
||||
app.use(createCsrfCookieMiddleware({ secureCookies: SHOULD_USE_SECURE_COOKIES }));
|
||||
|
||||
if (ENABLE_CSRF) {
|
||||
console.log('[安全] CSRF 保护已启用');
|
||||
logger.info('CSRF protection enabled');
|
||||
app.use(csrfProtection);
|
||||
}
|
||||
|
||||
// 强制HTTPS(可通过环境变量控制,默认关闭以兼容本地环境)
|
||||
// 安全说明:使用 req.secure 判断,该值基于 trust proxy 配置,
|
||||
// 只有在信任代理链中的代理才会被采信其 X-Forwarded-Proto 头
|
||||
app.use((req, res, 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
|
||||
// - 如果 trust proxy 已配置,会检查可信代理的 X-Forwarded-Proto
|
||||
if (!req.secure) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '仅支持HTTPS访问,请使用HTTPS'
|
||||
});
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
// 安全响应头中间件
|
||||
app.use((req, res, next) => {
|
||||
applySecurityHeaders(req, res);
|
||||
next();
|
||||
});
|
||||
|
||||
// 规范化并校验HTTP直链前缀,只允许http/https
|
||||
function sanitizeHttpBaseUrl(raw) {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return null;
|
||||
}
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
// 去掉多余的结尾斜杠,保持路径稳定
|
||||
url.pathname = url.pathname.replace(/\/+$/, '');
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 构建安全的下载URL,编码路径片段并拒绝非HTTP(S)前缀
|
||||
function buildHttpDownloadUrl(rawBaseUrl, filePath) {
|
||||
const baseUrl = sanitizeHttpBaseUrl(rawBaseUrl);
|
||||
if (!baseUrl || !filePath) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
|
||||
const safeSegments = normalizedPath
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map(segment => encodeURIComponent(segment));
|
||||
const safePath = safeSegments.length ? '/' + safeSegments.join('/') : '';
|
||||
|
||||
const basePath = url.pathname.replace(/\/+$/, '');
|
||||
const joinedPath = `${basePath}${safePath || '/'}`;
|
||||
url.pathname = joinedPath || '/';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch (err) {
|
||||
console.warn('[安全] 生成下载URL失败:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 应用XSS过滤到所有POST/PUT请求的body
|
||||
app.use((req, res, next) => {
|
||||
if ((req.method === 'POST' || req.method === 'PUT') && req.body) {
|
||||
@@ -768,12 +617,6 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// 请求日志
|
||||
app.use((req, res, next) => {
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// 获取正确的协议(基于可信代理链)
|
||||
// 安全说明:req.protocol 由 Express 根据 trust proxy 配置计算,
|
||||
// 只有可信代理的 X-Forwarded-Proto 才会被采信
|
||||
@@ -796,23 +639,6 @@ 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)) {
|
||||
@@ -1610,116 +1436,6 @@ async function readS3BodyToBuffer(body) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
function parseDownloadTrafficLogTime(line) {
|
||||
if (!line || typeof line !== 'string') return null;
|
||||
const match = line.match(/\[(\d{2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})\s*([+-]\d{4})?\]/);
|
||||
if (!match) return null;
|
||||
|
||||
const monthMap = {
|
||||
Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5,
|
||||
Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11
|
||||
};
|
||||
const month = monthMap[match[2]];
|
||||
if (month === undefined) return null;
|
||||
|
||||
const year = Number(match[3]);
|
||||
const day = Number(match[1]);
|
||||
const hour = Number(match[4]);
|
||||
const minute = Number(match[5]);
|
||||
const second = Number(match[6]);
|
||||
|
||||
if ([year, day, hour, minute, second].some(v => !Number.isFinite(v))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 先按 UTC 构造,再应用时区偏移(若存在)
|
||||
let utcMillis = Date.UTC(year, month, day, hour, minute, second);
|
||||
const tzRaw = match[7];
|
||||
if (tzRaw && /^[+-]\d{4}$/.test(tzRaw)) {
|
||||
const sign = tzRaw[0] === '+' ? 1 : -1;
|
||||
const tzHour = Number(tzRaw.slice(1, 3));
|
||||
const tzMin = Number(tzRaw.slice(3, 5));
|
||||
const offsetMinutes = sign * (tzHour * 60 + tzMin);
|
||||
utcMillis -= offsetMinutes * 60 * 1000;
|
||||
}
|
||||
|
||||
const parsed = new Date(utcMillis);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function parseDownloadTrafficLine(line) {
|
||||
if (!line || typeof line !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// 仅处理 GET 请求(HEAD/PUT/POST 等不计下载流量)
|
||||
if (!/\bGET\b/i.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let statusCode = 0;
|
||||
let bytesSent = 0;
|
||||
const statusMatch = trimmed.match(/"\s*(\d{3})\s+(\d+|-)\b/);
|
||||
if (statusMatch) {
|
||||
statusCode = Number(statusMatch[1]);
|
||||
bytesSent = statusMatch[2] === '-' ? 0 : Number(statusMatch[2]);
|
||||
}
|
||||
|
||||
if (![200, 206].includes(statusCode) || !Number.isFinite(bytesSent) || bytesSent <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 尝试从请求路径提取 object key
|
||||
let objectKey = null;
|
||||
const requestMatch = trimmed.match(/"(?:GET|HEAD)\s+([^" ]+)\s+HTTP\//i);
|
||||
if (requestMatch && requestMatch[1]) {
|
||||
let requestPath = requestMatch[1];
|
||||
const qIndex = requestPath.indexOf('?');
|
||||
if (qIndex >= 0) {
|
||||
requestPath = requestPath.slice(0, qIndex);
|
||||
}
|
||||
requestPath = requestPath.replace(/^https?:\/\/[^/]+/i, '');
|
||||
requestPath = requestPath.replace(/^\/+/, '');
|
||||
try {
|
||||
requestPath = decodeURIComponent(requestPath);
|
||||
} catch {
|
||||
// ignore decode error
|
||||
}
|
||||
objectKey = requestPath || null;
|
||||
}
|
||||
|
||||
if (!objectKey) {
|
||||
const keyMatch = trimmed.match(/\buser_(\d+)\/[^\s"]+/);
|
||||
if (keyMatch && keyMatch[0]) {
|
||||
objectKey = keyMatch[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!objectKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userMatch = objectKey.match(/(?:^|\/)user_(\d+)\//);
|
||||
if (!userMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = Number(userMatch[1]);
|
||||
if (!Number.isFinite(userId) || userId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
bytes: Math.floor(bytesSent),
|
||||
objectKey,
|
||||
eventAt: parseDownloadTrafficLogTime(trimmed) || new Date()
|
||||
};
|
||||
}
|
||||
|
||||
function extractLogLinesFromBuffer(buffer, logKey = '') {
|
||||
let content = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || '');
|
||||
if ((logKey || '').toLowerCase().endsWith('.gz')) {
|
||||
@@ -2357,162 +2073,6 @@ const shareFileCache = new TTLCache(60 * 60 * 1000);
|
||||
|
||||
// ===== 防爆破限流器 =====
|
||||
|
||||
// 防爆破限流器类
|
||||
class RateLimiter {
|
||||
constructor(options = {}) {
|
||||
this.maxAttempts = options.maxAttempts || 5;
|
||||
this.windowMs = options.windowMs || 15 * 60 * 1000;
|
||||
this.blockDuration = options.blockDuration || 30 * 60 * 1000;
|
||||
this.attempts = new Map();
|
||||
this.blockedKeys = new Map();
|
||||
|
||||
// 每5分钟清理一次过期记录
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanup();
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
// 获取客户端IP(基于可信代理链)
|
||||
// 安全说明:req.ip 由 Express 根据 trust proxy 配置计算,
|
||||
// 只有可信代理的 X-Forwarded-For 才会被采信
|
||||
getClientKey(req) {
|
||||
// req.ip 会根据 trust proxy 配置:
|
||||
// - trust proxy = false: 使用直接连接的 IP(socket 地址)
|
||||
// - trust proxy = 1: 取 X-Forwarded-For 的最后 1 个 IP
|
||||
// - trust proxy = true: 取 X-Forwarded-For 的第 1 个 IP(不推荐)
|
||||
return req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
// 检查是否被封锁
|
||||
isBlocked(key) {
|
||||
const blockInfo = this.blockedKeys.get(key);
|
||||
if (!blockInfo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查封锁是否过期
|
||||
if (Date.now() > blockInfo.expiresAt) {
|
||||
this.blockedKeys.delete(key);
|
||||
this.attempts.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 记录失败尝试
|
||||
recordFailure(key) {
|
||||
const now = Date.now();
|
||||
|
||||
// 如果已被封锁,返回封锁信息
|
||||
if (this.isBlocked(key)) {
|
||||
const blockInfo = this.blockedKeys.get(key);
|
||||
return {
|
||||
blocked: true,
|
||||
remainingAttempts: 0,
|
||||
resetTime: blockInfo.expiresAt,
|
||||
waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60000),
|
||||
needCaptcha: true
|
||||
};
|
||||
}
|
||||
|
||||
// 获取或创建尝试记录
|
||||
let attemptInfo = this.attempts.get(key);
|
||||
if (!attemptInfo || now > attemptInfo.windowEnd) {
|
||||
attemptInfo = {
|
||||
count: 0,
|
||||
windowEnd: now + this.windowMs,
|
||||
firstAttempt: now
|
||||
};
|
||||
}
|
||||
|
||||
attemptInfo.count++;
|
||||
this.attempts.set(key, attemptInfo);
|
||||
|
||||
// 检查是否达到封锁阈值
|
||||
if (attemptInfo.count >= this.maxAttempts) {
|
||||
const blockExpiresAt = now + this.blockDuration;
|
||||
this.blockedKeys.set(key, {
|
||||
expiresAt: blockExpiresAt,
|
||||
blockedAt: now
|
||||
});
|
||||
console.warn(`[防爆破] 封锁Key: ${key}, 失败次数: ${attemptInfo.count}, 封锁时长: ${Math.ceil(this.blockDuration / 60000)}分钟`);
|
||||
return {
|
||||
blocked: true,
|
||||
remainingAttempts: 0,
|
||||
resetTime: blockExpiresAt,
|
||||
waitMinutes: Math.ceil(this.blockDuration / 60000),
|
||||
needCaptcha: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
blocked: false,
|
||||
remainingAttempts: this.maxAttempts - attemptInfo.count,
|
||||
resetTime: attemptInfo.windowEnd,
|
||||
waitMinutes: 0,
|
||||
needCaptcha: attemptInfo.count >= 2 // 失败2次后需要验证码
|
||||
};
|
||||
}
|
||||
|
||||
// 获取失败次数
|
||||
getFailureCount(key) {
|
||||
const attemptInfo = this.attempts.get(key);
|
||||
if (!attemptInfo || Date.now() > attemptInfo.windowEnd) {
|
||||
return 0;
|
||||
}
|
||||
return attemptInfo.count;
|
||||
}
|
||||
|
||||
// 记录成功(清除失败记录)
|
||||
recordSuccess(key) {
|
||||
this.attempts.delete(key);
|
||||
this.blockedKeys.delete(key);
|
||||
}
|
||||
|
||||
// 清理过期记录
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
let cleanedAttempts = 0;
|
||||
let cleanedBlocks = 0;
|
||||
|
||||
// 清理过期的尝试记录
|
||||
for (const [key, info] of this.attempts.entries()) {
|
||||
if (now > info.windowEnd) {
|
||||
this.attempts.delete(key);
|
||||
cleanedAttempts++;
|
||||
}
|
||||
}
|
||||
|
||||
// 清理过期的封锁记录
|
||||
for (const [key, info] of this.blockedKeys.entries()) {
|
||||
if (now > info.expiresAt) {
|
||||
this.blockedKeys.delete(key);
|
||||
cleanedBlocks++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedAttempts > 0 || cleanedBlocks > 0) {
|
||||
console.log(`[防爆破清理] 已清理 ${cleanedAttempts} 个过期尝试记录, ${cleanedBlocks} 个过期封锁记录`);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取统计信息
|
||||
getStats() {
|
||||
return {
|
||||
activeAttempts: this.attempts.size,
|
||||
blockedKeys: this.blockedKeys.size
|
||||
};
|
||||
}
|
||||
|
||||
// 停止清理定时器
|
||||
destroy() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建登录限流器(5次失败/15分钟,封锁30分钟)
|
||||
const loginLimiter = new RateLimiter({
|
||||
maxAttempts: 5,
|
||||
@@ -2717,82 +2277,6 @@ function safeDeleteFile(filePath) {
|
||||
}
|
||||
|
||||
|
||||
// 规范化虚拟文件路径(统一用于分享路径校验)
|
||||
function normalizeVirtualPath(rawPath) {
|
||||
if (typeof rawPath !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let decoded = rawPath;
|
||||
try {
|
||||
decoded = decodeURIComponent(rawPath);
|
||||
} catch {
|
||||
// 忽略解码失败,使用原始输入继续校验
|
||||
}
|
||||
|
||||
if (decoded.includes('\x00') || decoded.includes('%00')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unifiedPath = decoded.replace(/\\/g, '/');
|
||||
|
||||
// 严格拦截路径遍历片段(在 normalize 前先检查)
|
||||
if (/(^|\/)\.\.(\/|$)/.test(unifiedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalized = path.posix.normalize(unifiedPath);
|
||||
if (normalized === '' || normalized === '.') {
|
||||
normalized = '/';
|
||||
}
|
||||
|
||||
if (!normalized.startsWith('/')) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/\/+$/g, '');
|
||||
return normalized || '/';
|
||||
}
|
||||
|
||||
function isValidShareCode(code) {
|
||||
return typeof code === 'string' && SHARE_CODE_REGEX.test(code);
|
||||
}
|
||||
|
||||
// 验证请求路径是否在分享范围内(防止越权访问)
|
||||
function isPathWithinShare(requestPath, share) {
|
||||
if (!requestPath || !share) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedRequest = normalizeVirtualPath(requestPath);
|
||||
const normalizedShare = normalizeVirtualPath(share.share_path);
|
||||
|
||||
if (!normalizedRequest || !normalizedShare) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (share.share_type === 'file') {
|
||||
// 单文件分享:只允许下载该文件
|
||||
return normalizedRequest === normalizedShare;
|
||||
}
|
||||
|
||||
// 目录分享:只允许下载该目录及其子目录下的文件
|
||||
const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : `${normalizedShare}/`;
|
||||
return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix);
|
||||
}
|
||||
|
||||
function normalizeClientIp(rawIp) {
|
||||
const ip = String(rawIp || '').trim();
|
||||
if (!ip) return '';
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
return ip.slice(7);
|
||||
}
|
||||
if (ip === '::1') {
|
||||
return '127.0.0.1';
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
function getClientIp(req) {
|
||||
return normalizeClientIp(
|
||||
req?.ip
|
||||
@@ -2802,89 +2286,6 @@ function getClientIp(req) {
|
||||
);
|
||||
}
|
||||
|
||||
function parseShareIpWhitelist(rawValue) {
|
||||
if (typeof rawValue !== 'string') return [];
|
||||
return rawValue
|
||||
.split(/[\s,;]+/)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
function isShareIpAllowed(clientIp, whitelist = []) {
|
||||
if (!clientIp || !Array.isArray(whitelist) || whitelist.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const rule of whitelist) {
|
||||
const normalizedRule = String(rule || '').trim();
|
||||
if (!normalizedRule) continue;
|
||||
|
||||
if (normalizedRule === clientIp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedRule.endsWith('*')) {
|
||||
const prefix = normalizedRule.slice(0, -1);
|
||||
if (prefix && clientIp.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function detectDeviceTypeFromUserAgent(userAgent = '') {
|
||||
const ua = String(userAgent || '');
|
||||
const mobilePattern = /(Mobile|Android|iPhone|iPad|iPod|Windows Phone|HarmonyOS|Mobi)/i;
|
||||
return mobilePattern.test(ua) ? 'mobile' : 'desktop';
|
||||
}
|
||||
|
||||
function inferPlatformFromUserAgent(userAgent = '') {
|
||||
const ua = String(userAgent || '');
|
||||
if (!ua) return '未知平台';
|
||||
if (/windows/i.test(ua)) return 'Windows';
|
||||
if (/macintosh|mac os x/i.test(ua)) return 'macOS';
|
||||
if (/android/i.test(ua)) return 'Android';
|
||||
if (/iphone|ipad|ios/i.test(ua)) return 'iOS';
|
||||
if (/linux/i.test(ua)) return 'Linux';
|
||||
return '未知平台';
|
||||
}
|
||||
|
||||
function normalizeClientType(value = '') {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (['web', 'desktop', 'mobile', 'api'].includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveClientType(clientType, userAgent = '') {
|
||||
const normalized = normalizeClientType(clientType);
|
||||
if (normalized) return normalized;
|
||||
const ua = String(userAgent || '').toLowerCase();
|
||||
if (ua.includes('tauri') || ua.includes('electron') || ua.includes('wanwan-cloud-desktop') || ua.includes('玩玩云')) {
|
||||
return 'desktop';
|
||||
}
|
||||
return detectDeviceTypeFromUserAgent(ua) === 'mobile' ? 'mobile' : 'web';
|
||||
}
|
||||
|
||||
function sanitizeDeviceText(value, maxLength = 120) {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function buildDeviceName({ clientType, deviceName, platform }) {
|
||||
const preferred = sanitizeDeviceText(deviceName, 120);
|
||||
if (preferred) return preferred;
|
||||
const platformText = sanitizeDeviceText(platform, 80) || '未知平台';
|
||||
if (clientType === 'desktop') return `桌面客户端 · ${platformText}`;
|
||||
if (clientType === 'mobile') return `移动端浏览器 · ${platformText}`;
|
||||
if (clientType === 'api') return `API 客户端 · ${platformText}`;
|
||||
return `网页端浏览器 · ${platformText}`;
|
||||
}
|
||||
|
||||
function buildDeviceSessionContext(req, payload = {}) {
|
||||
const userAgent = sanitizeDeviceText(req.get('user-agent') || req.headers?.['user-agent'] || '', 1024);
|
||||
const clientType = resolveClientType(payload.client_type || req.headers?.['x-client-type'], userAgent);
|
||||
@@ -3603,21 +3004,11 @@ function verifyCaptcha(req, res, captcha, logPrefix = '验证码验证') {
|
||||
|
||||
// ===== 公开API =====
|
||||
|
||||
// 健康检查
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ success: true, message: 'Server is running' });
|
||||
});
|
||||
|
||||
// 获取公开的系统配置(不需要登录)
|
||||
app.get('/api/config', (req, res) => {
|
||||
const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240');
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
max_upload_size: maxUploadSize
|
||||
}
|
||||
});
|
||||
const healthService = createHealthService({
|
||||
db,
|
||||
diskPath: path.resolve(__dirname, 'data')
|
||||
});
|
||||
app.use('/api', createSystemRouter({ SettingsDB, healthService }));
|
||||
|
||||
// 桌面客户端更新信息(无需登录)
|
||||
app.get('/api/client/desktop-update', (req, res) => {
|
||||
@@ -3688,13 +3079,7 @@ app.get('/api/csrf-token', (req, res) => {
|
||||
// 如果没有 token,生成一个新的
|
||||
if (!csrfToken) {
|
||||
csrfToken = generateCsrfToken();
|
||||
const isSecureEnv = SHOULD_USE_SECURE_COOKIES;
|
||||
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
|
||||
httpOnly: false,
|
||||
secure: isSecureEnv,
|
||||
sameSite: isSecureEnv ? 'strict' : 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000
|
||||
});
|
||||
setCsrfCookie(res, csrfToken, SHOULD_USE_SECURE_COOKIES);
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -8303,19 +7688,6 @@ app.delete('/api/direct-link/:id', authMiddleware, (req, res) => {
|
||||
|
||||
// ===== 分享链接访问(公开) =====
|
||||
|
||||
// 获取公共主题设置(用于分享页面,无需认证)
|
||||
app.get('/api/public/theme', (req, res) => {
|
||||
try {
|
||||
const globalTheme = SettingsDB.get('global_theme') || 'dark';
|
||||
res.json({
|
||||
success: true,
|
||||
theme: globalTheme
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({ success: true, theme: 'dark' }); // 出错默认暗色
|
||||
}
|
||||
});
|
||||
|
||||
// 获取分享页面主题(基于分享者偏好或全局设置)
|
||||
app.get('/api/share/:code/theme', (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user