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