fix(security): harden CORS/cookie policy and share path validation
This commit is contained in:
@@ -74,6 +74,15 @@ 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 SHARE_CODE_REGEX = /^[A-Za-z0-9]{6,32}$/;
|
||||
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');
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'true') {
|
||||
console.warn('[安全警告] 生产环境建议设置 COOKIE_SECURE=true,以避免会话Cookie在HTTP下传输');
|
||||
}
|
||||
|
||||
// ===== 安全配置:公开域名白名单(防止 Host Header 注入) =====
|
||||
// 必须配置 PUBLIC_BASE_URL 环境变量,用于生成邮件链接和分享链接
|
||||
@@ -139,22 +148,35 @@ app.set('trust proxy', trustProxyValue);
|
||||
console.log(`[安全] trust proxy 配置: ${JSON.stringify(trustProxyValue)}`);
|
||||
|
||||
// 配置CORS - 严格白名单模式
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',').map(origin => origin.trim())
|
||||
: []; // 默认为空数组,不允许任何域名
|
||||
const rawAllowedOrigins = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',').map(origin => origin.trim()).filter(Boolean)
|
||||
: [];
|
||||
const wildcardOriginConfigured = rawAllowedOrigins.includes('*');
|
||||
const allowAllOriginsForDev = wildcardOriginConfigured && process.env.NODE_ENV !== 'production';
|
||||
const allowedOrigins = rawAllowedOrigins.filter(origin => origin !== '*');
|
||||
|
||||
if (wildcardOriginConfigured && process.env.NODE_ENV === 'production') {
|
||||
console.error('❌ 错误: 生产环境禁止 ALLOWED_ORIGINS=*。请改为明确的域名白名单。');
|
||||
}
|
||||
|
||||
const corsOptions = {
|
||||
credentials: true,
|
||||
origin: (origin, callback) => {
|
||||
// 生产环境禁止通配符(credentials=true 时会导致任意来源携带Cookie)
|
||||
if (wildcardOriginConfigured && process.env.NODE_ENV === 'production') {
|
||||
callback(new Error('生产环境不允许 CORS 通配符配置'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 生产环境必须配置白名单
|
||||
if (allowedOrigins.length === 0 && process.env.NODE_ENV === 'production') {
|
||||
if (allowedOrigins.length === 0 && !allowAllOriginsForDev && process.env.NODE_ENV === 'production') {
|
||||
console.error('❌ 错误: 生产环境必须配置 ALLOWED_ORIGINS 环境变量!');
|
||||
callback(new Error('CORS未配置'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 开发环境如果没有配置,允许 localhost
|
||||
if (allowedOrigins.length === 0) {
|
||||
if (allowedOrigins.length === 0 && !allowAllOriginsForDev) {
|
||||
const devOrigins = ['http://localhost:3000', 'http://127.0.0.1:3000'];
|
||||
if (!origin || devOrigins.some(o => origin.startsWith(o))) {
|
||||
callback(null, true);
|
||||
@@ -162,32 +184,52 @@ const corsOptions = {
|
||||
}
|
||||
}
|
||||
|
||||
// 严格白名单模式:只允许白名单中的域名
|
||||
// 但需要允许没有Origin头的同源请求(浏览器访问时不会发送Origin)
|
||||
// 允许没有Origin头的同源请求和服务器请求
|
||||
if (!origin) {
|
||||
// 没有Origin头的请求通常是:
|
||||
// 1. 浏览器的同源请求(不触发CORS)
|
||||
// 2. 直接的服务器请求
|
||||
// 这些都应该允许
|
||||
callback(null, true);
|
||||
} else if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) {
|
||||
// 白名单中的域名,或通配符允许所有域名
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowedOrigins.includes(origin) || allowAllOriginsForDev) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// 拒绝不在白名单中的跨域请求
|
||||
console.warn(`[CORS] 拒绝来自未授权来源的请求: ${origin}`);
|
||||
callback(new Error('CORS策略不允许来自该来源的访问'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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'; 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));
|
||||
app.use(express.static(frontendPath, {
|
||||
setHeaders: (res) => {
|
||||
applySecurityHeaders(null, res);
|
||||
}
|
||||
}));
|
||||
|
||||
app.use(express.json({ limit: '10mb' })); // 限制请求体大小防止DoS
|
||||
app.use(cookieParser());
|
||||
@@ -209,7 +251,7 @@ app.use((req, res, next) => {
|
||||
// 如果没有 CSRF cookie,则生成一个
|
||||
if (!req.cookies[CSRF_COOKIE_NAME]) {
|
||||
const csrfToken = generateCsrfToken();
|
||||
const isSecureEnv = process.env.COOKIE_SECURE === 'true';
|
||||
const isSecureEnv = SHOULD_USE_SECURE_COOKIES;
|
||||
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
|
||||
httpOnly: false, // 前端需要读取此值
|
||||
secure: isSecureEnv,
|
||||
@@ -282,7 +324,7 @@ app.use((req, res, next) => {
|
||||
});
|
||||
|
||||
// Session配置(用于验证码)
|
||||
const isSecureCookie = process.env.COOKIE_SECURE === 'true';
|
||||
const isSecureCookie = SHOULD_USE_SECURE_COOKIES;
|
||||
const sameSiteMode = isSecureCookie ? 'none' : 'lax'; // HTTPS下允许跨域获取验证码
|
||||
|
||||
// 安全检查:Session密钥配置
|
||||
@@ -321,21 +363,7 @@ app.use(session({
|
||||
|
||||
// 安全响应头中间件
|
||||
app.use((req, res, next) => {
|
||||
// 防止点击劫持
|
||||
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.secure) {
|
||||
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';");
|
||||
// 隐藏X-Powered-By
|
||||
res.removeHeader('X-Powered-By');
|
||||
applySecurityHeaders(req, res);
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -1153,6 +1181,14 @@ function loginRateLimitMiddleware(req, res, next) {
|
||||
function shareRateLimitMiddleware(req, res, next) {
|
||||
const clientIP = shareLimiter.getClientKey(req);
|
||||
const { code } = req.params;
|
||||
|
||||
if (!isValidShareCode(code)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的分享码'
|
||||
});
|
||||
}
|
||||
|
||||
const key = `share:${code}:${clientIP}`;
|
||||
|
||||
// 检查是否被封锁
|
||||
@@ -1173,6 +1209,7 @@ function shareRateLimitMiddleware(req, res, next) {
|
||||
|
||||
|
||||
|
||||
|
||||
// ===== 工具函数 =====
|
||||
|
||||
/**
|
||||
@@ -1214,25 +1251,68 @@ 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 = path.normalize(requestPath).replace(/^(\.\.[\/\\])+/, '').replace(/\\/g, '/');
|
||||
const normalizedShare = path.normalize(share.share_path).replace(/\\/g, '/');
|
||||
const normalizedRequest = normalizeVirtualPath(requestPath);
|
||||
const normalizedShare = normalizeVirtualPath(share.share_path);
|
||||
|
||||
if (!normalizedRequest || !normalizedShare) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (share.share_type === 'file') {
|
||||
// 单文件分享:只允许下载该文件
|
||||
return normalizedRequest === normalizedShare;
|
||||
} else {
|
||||
// 目录分享:只允许下载该目录及其子目录下的文件
|
||||
// 确保分享路径以斜杠结尾用于前缀匹配
|
||||
const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : normalizedShare + '/';
|
||||
return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix);
|
||||
}
|
||||
|
||||
// 目录分享:只允许下载该目录及其子目录下的文件
|
||||
const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : `${normalizedShare}/`;
|
||||
return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix);
|
||||
}
|
||||
// 清理旧的临时文件(启动时执行一次)
|
||||
function cleanupOldTempFiles() {
|
||||
@@ -1456,7 +1536,7 @@ app.get('/api/csrf-token', (req, res) => {
|
||||
// 如果没有 token,生成一个新的
|
||||
if (!csrfToken) {
|
||||
csrfToken = generateCsrfToken();
|
||||
const isSecureEnv = process.env.COOKIE_SECURE === 'true';
|
||||
const isSecureEnv = SHOULD_USE_SECURE_COOKIES;
|
||||
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
|
||||
httpOnly: false,
|
||||
secure: isSecureEnv,
|
||||
@@ -1984,7 +2064,7 @@ app.post('/api/login',
|
||||
}
|
||||
|
||||
// 增强Cookie安全设置
|
||||
const isSecureEnv = process.env.COOKIE_SECURE === 'true';
|
||||
const isSecureEnv = SHOULD_USE_SECURE_COOKIES;
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: isSecureEnv,
|
||||
@@ -2062,7 +2142,7 @@ app.post('/api/refresh-token', (req, res) => {
|
||||
}
|
||||
|
||||
// 更新Cookie中的token
|
||||
const isSecureEnv = process.env.COOKIE_SECURE === 'true';
|
||||
const isSecureEnv = SHOULD_USE_SECURE_COOKIES;
|
||||
res.cookie('token', result.token, {
|
||||
httpOnly: true,
|
||||
secure: isSecureEnv,
|
||||
@@ -3520,7 +3600,7 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: objectKey,
|
||||
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(filePath.split('/').pop())}"`
|
||||
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(normalizedPath.split('/').pop())}"`
|
||||
});
|
||||
|
||||
// 生成签名 URL(1小时有效)
|
||||
@@ -3535,7 +3615,7 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
|
||||
console.error('[OSS签名] 生成下载签名失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '生成下载签名失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '生成下载签名失败,请稍后重试', '生成下载签名')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -3764,7 +3844,7 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '文件下载失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '下载文件')
|
||||
});
|
||||
}
|
||||
// 发生错误时关闭存储连接
|
||||
@@ -4095,8 +4175,8 @@ app.post('/api/share/create', authMiddleware, (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 路径安全验证:防止路径遍历攻击
|
||||
if (file_path.includes('..') || file_path.includes('\x00')) {
|
||||
const normalizedSharePath = normalizeVirtualPath(file_path);
|
||||
if (!normalizedSharePath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '路径包含非法字符'
|
||||
@@ -4108,12 +4188,12 @@ app.post('/api/share/create', authMiddleware, (req, res) => {
|
||||
category: 'share',
|
||||
action: 'create_share',
|
||||
message: '创建分享请求',
|
||||
details: { share_type: actualShareType, file_path, file_name, expiry_days }
|
||||
details: { share_type: actualShareType, file_path: normalizedSharePath, file_name, expiry_days }
|
||||
});
|
||||
|
||||
const result = ShareDB.create(req.user.id, {
|
||||
share_type: actualShareType,
|
||||
file_path: file_path || '',
|
||||
file_path: normalizedSharePath,
|
||||
file_name: file_name || '',
|
||||
password: normalizedPassword || null,
|
||||
expiry_days: expiry_days || null
|
||||
@@ -4127,8 +4207,8 @@ app.post('/api/share/create', authMiddleware, (req, res) => {
|
||||
|
||||
// 记录分享创建日志
|
||||
logShare(req, 'create_share',
|
||||
`用户创建分享: ${actualShareType === 'file' ? '文件' : '目录'} ${file_path}`,
|
||||
{ shareCode: result.share_code, sharePath: file_path, shareType: actualShareType, hasPassword: !!normalizedPassword }
|
||||
`用户创建分享: ${actualShareType === 'file' ? '文件' : '目录'} ${normalizedSharePath}`,
|
||||
{ shareCode: result.share_code, sharePath: normalizedSharePath, shareType: actualShareType, hasPassword: !!normalizedPassword }
|
||||
);
|
||||
|
||||
res.json({
|
||||
@@ -4250,9 +4330,17 @@ app.get('/api/public/theme', (req, res) => {
|
||||
app.get('/api/share/:code/theme', (req, res) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const share = ShareDB.findByCode(code);
|
||||
const globalTheme = SettingsDB.get('global_theme') || 'dark';
|
||||
|
||||
if (!isValidShareCode(code)) {
|
||||
return res.json({
|
||||
success: true,
|
||||
theme: globalTheme
|
||||
});
|
||||
}
|
||||
|
||||
const share = ShareDB.findByCode(code);
|
||||
|
||||
if (!share) {
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -4413,7 +4501,7 @@ app.post('/api/share/:code/verify', shareRateLimitMiddleware, async (req, res) =
|
||||
console.error('验证分享失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '验证失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '验证失败,请稍后重试', '分享验证')
|
||||
});
|
||||
} finally {
|
||||
if (storage) await storage.end();
|
||||
@@ -4480,11 +4568,26 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) =>
|
||||
}
|
||||
|
||||
// 构造安全的请求路径,防止越权遍历
|
||||
const baseSharePath = (share.share_path || '/').replace(/\\/g, '/');
|
||||
const requestedPath = subPath
|
||||
? path.posix.normalize(`${baseSharePath}/${subPath}`)
|
||||
const baseSharePath = normalizeVirtualPath(share.share_path || '/');
|
||||
if (!baseSharePath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '分享路径非法'
|
||||
});
|
||||
}
|
||||
|
||||
const rawSubPath = typeof subPath === 'string' ? subPath : '';
|
||||
const requestedPath = rawSubPath
|
||||
? normalizeVirtualPath(`${baseSharePath}/${rawSubPath}`)
|
||||
: baseSharePath;
|
||||
|
||||
if (!requestedPath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '请求路径非法'
|
||||
});
|
||||
}
|
||||
|
||||
// 校验请求路径是否在分享范围内
|
||||
if (!isPathWithinShare(requestedPath, share)) {
|
||||
return res.status(403).json({
|
||||
@@ -4568,7 +4671,7 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) =>
|
||||
console.error('获取分享文件列表失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取文件列表失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '获取文件列表失败,请稍后重试', '分享列表')
|
||||
});
|
||||
} finally {
|
||||
if (storage) await storage.end();
|
||||
@@ -4626,14 +4729,8 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
|
||||
});
|
||||
}
|
||||
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '缺少文件路径参数'
|
||||
});
|
||||
}
|
||||
|
||||
if (filePath.includes('\x00')) {
|
||||
const normalizedFilePath = normalizeVirtualPath(filePath);
|
||||
if (!normalizedFilePath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件路径非法'
|
||||
@@ -4669,7 +4766,7 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
|
||||
}
|
||||
|
||||
// 安全验证:检查请求路径是否在分享范围内
|
||||
if (!isPathWithinShare(filePath, share)) {
|
||||
if (!isPathWithinShare(normalizedFilePath, share)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权访问该文件'
|
||||
@@ -4689,13 +4786,13 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
|
||||
|
||||
// 本地存储模式:返回后端下载 URL(短期 token,避免在 URL 中传密码)
|
||||
if (storageType !== 'oss') {
|
||||
let downloadUrl = `${getSecureBaseUrl(req)}/api/share/${code}/download-file?path=${encodeURIComponent(filePath)}`;
|
||||
let downloadUrl = `${getSecureBaseUrl(req)}/api/share/${code}/download-file?path=${encodeURIComponent(normalizedFilePath)}`;
|
||||
|
||||
if (share.share_password) {
|
||||
const downloadToken = signEphemeralToken({
|
||||
type: 'share_download',
|
||||
code,
|
||||
path: filePath
|
||||
path: normalizedFilePath
|
||||
}, 15 * 60);
|
||||
downloadUrl += `&token=${encodeURIComponent(downloadToken)}`;
|
||||
}
|
||||
@@ -4720,13 +4817,13 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
|
||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||
|
||||
const { client, bucket, ossClient } = createS3ClientContextForUser(shareOwner);
|
||||
const objectKey = ossClient.getObjectKey(filePath);
|
||||
const objectKey = ossClient.getObjectKey(normalizedFilePath);
|
||||
|
||||
// 创建 GetObject 命令
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: objectKey,
|
||||
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(filePath.split('/').pop())}"`
|
||||
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(normalizedFilePath.split('/').pop())}"`
|
||||
});
|
||||
|
||||
// 生成签名 URL(1小时有效)
|
||||
@@ -4743,7 +4840,7 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
|
||||
console.error('[分享签名] 生成下载签名失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '生成下载签名失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '生成下载签名失败,请稍后重试', '生成下载签名')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -4752,7 +4849,9 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
|
||||
// 注意:OSS 模式请使用 /api/share/:code/download-url 获取直连下载链接
|
||||
app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, res) => {
|
||||
const { code } = req.params;
|
||||
const { path: filePath, password, token } = req.query;
|
||||
const rawFilePath = typeof req.query?.path === 'string' ? req.query.path : '';
|
||||
const { password, token } = req.query;
|
||||
const filePath = normalizeVirtualPath(rawFilePath);
|
||||
let storage;
|
||||
let storageEnded = false; // 防止重复关闭
|
||||
|
||||
@@ -4769,14 +4868,6 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
|
||||
};
|
||||
|
||||
if (!filePath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '缺少文件路径参数'
|
||||
});
|
||||
}
|
||||
|
||||
// 路径安全验证:防止目录遍历攻击
|
||||
if (filePath.includes('\x00')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件路径非法'
|
||||
@@ -4884,7 +4975,7 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '文件下载失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '分享下载')
|
||||
});
|
||||
}
|
||||
// 发生错误时关闭存储连接
|
||||
@@ -4904,7 +4995,7 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '下载文件失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '分享下载')
|
||||
});
|
||||
}
|
||||
// 如果发生错误,关闭存储连接
|
||||
@@ -5272,7 +5363,7 @@ app.get('/api/admin/health-check', authMiddleware, adminMiddleware, async (req,
|
||||
|
||||
// 3. HTTPS/Cookie安全配置
|
||||
const enforceHttps = process.env.ENFORCE_HTTPS === 'true';
|
||||
const cookieSecure = process.env.COOKIE_SECURE === 'true';
|
||||
const cookieSecure = SHOULD_USE_SECURE_COOKIES;
|
||||
const httpsConfigured = enforceHttps && cookieSecure;
|
||||
checks.push({
|
||||
name: 'HTTPS安全配置',
|
||||
@@ -6453,6 +6544,9 @@ app.delete('/api/admin/shares/:id',
|
||||
// 分享页面访问路由
|
||||
app.get("/s/:code", (req, res) => {
|
||||
const shareCode = req.params.code;
|
||||
if (!isValidShareCode(shareCode)) {
|
||||
return res.status(404).send('分享链接不存在');
|
||||
}
|
||||
// 使用相对路径重定向,浏览器会自动使用当前的协议和host
|
||||
const frontendUrl = `/share.html?code=${shareCode}`;
|
||||
console.log(`[分享] 重定向到: ${frontendUrl}`);
|
||||
|
||||
Reference in New Issue
Block a user