fix(security): harden CORS/cookie policy and share path validation

This commit is contained in:
2026-02-12 21:39:01 +08:00
parent a3932747e3
commit b0e89df5c4

View File

@@ -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)) {
// 白名单中的域名,或通配符允许所有域名
callback(null, true);
} else {
// 拒绝不在白名单中的跨域请求
console.warn(`[CORS] 拒绝来自未授权来源的请求: ${origin}`);
callback(new Error('CORS策略不允许来自该来源的访问'));
return;
}
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));
// 静态文件服务 - 提供前端页面
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())}"`
});
// 生成签名 URL1小时有效
@@ -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())}"`
});
// 生成签名 URL1小时有效
@@ -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}`);