Files
vue-driven-cloud-storage/backend/storage.js
yuyx 474c8fe9b5 🔒 安全加固:修复多个中高危漏洞
修复内容:
1. Host Header 注入 - 添加 PUBLIC_BASE_URL 和 ALLOWED_HOSTS 白名单
2. API密钥暴力破解 - 添加速率限制(5次/小时,封锁24小时)
3. 路径遍历漏洞 - 增强路径验证,防止空字节注入和目录遍历
4. 令牌安全 - 密码重置和邮箱验证令牌使用SHA256哈希存储
5. 文件上传安全 - 阻止PHP/JSP/ASP等可执行脚本上传
6. IDOR防护 - 增强权限验证和安全日志
7. XSS防护 - 增强输入过滤,阻止javascript:等危险协议
8. 日志脱敏 - 移除验证码等敏感信息的日志输出
9. CSRF增强 - HTTPS环境使用strict模式Cookie
10. 邮箱枚举防护 - 密码重置统一返回消息
11. 速率限制 - 文件列表(60次/分)和上传(100次/小时)

配置说明:
- PUBLIC_BASE_URL: 必须配置,用于生成安全的邮件链接
- ALLOWED_HOSTS: 可选,Host头白名单
- COOKIE_SECURE=true: 生产环境必须开启

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 09:48:46 +08:00

417 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const SftpClient = require('ssh2-sftp-client');
const fs = require('fs');
const path = require('path');
const { UserDB } = require('./database');
// ===== 统一存储接口 =====
/**
* 存储接口工厂
* 根据用户的存储类型返回对应的存储客户端
*/
class StorageInterface {
constructor(user) {
this.user = user;
this.type = user.current_storage_type || 'sftp';
}
/**
* 创建并返回存储客户端
*/
async connect() {
if (this.type === 'local') {
const client = new LocalStorageClient(this.user);
await client.init();
return client;
} else {
const client = new SftpStorageClient(this.user);
await client.connect();
return client;
}
}
}
// ===== 本地存储客户端 =====
class LocalStorageClient {
constructor(user) {
this.user = user;
// 使用环境变量或默认路径(不硬编码)
const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage');
this.basePath = path.join(storageRoot, `user_${user.id}`);
}
/**
* 初始化用户存储目录
*/
async init() {
if (!fs.existsSync(this.basePath)) {
fs.mkdirSync(this.basePath, { recursive: true, mode: 0o755 });
console.log(`[本地存储] 创建用户目录: ${this.basePath}`);
}
}
/**
* 列出目录内容
*/
async list(dirPath) {
const fullPath = this.getFullPath(dirPath);
// 确保目录存在
if (!fs.existsSync(fullPath)) {
fs.mkdirSync(fullPath, { recursive: true });
return [];
}
const items = fs.readdirSync(fullPath, { withFileTypes: true });
return items.map(item => {
const itemPath = path.join(fullPath, item.name);
const stats = fs.statSync(itemPath);
return {
name: item.name,
type: item.isDirectory() ? 'd' : '-',
size: stats.size,
modifyTime: stats.mtimeMs
};
});
}
/**
* 上传文件
*/
async put(localPath, remotePath) {
const destPath = this.getFullPath(remotePath);
// 获取新文件大小
const newFileSize = fs.statSync(localPath).size;
// 如果目标文件存在,计算实际需要的额外空间
let oldFileSize = 0;
if (fs.existsSync(destPath)) {
try {
oldFileSize = fs.statSync(destPath).size;
} catch (err) {
// 文件可能已被删除,忽略错误
}
}
// 检查配额:只检查净增量(新文件大小 - 旧文件大小)
const netIncrease = newFileSize - oldFileSize;
if (netIncrease > 0) {
this.checkQuota(netIncrease);
}
// 确保目标目录存在
const destDir = path.dirname(destPath);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
// 使用临时文件+重命名模式,避免文件被占用问题
const tempPath = `${destPath}.uploading_${Date.now()}`;
try {
// 复制到临时文件
fs.copyFileSync(localPath, tempPath);
// 如果目标文件存在,先删除
if (fs.existsSync(destPath)) {
fs.unlinkSync(destPath);
}
// 重命名临时文件为目标文件
fs.renameSync(tempPath, destPath);
// 更新已使用空间(使用净增量)
if (netIncrease !== 0) {
this.updateUsedSpace(netIncrease);
}
} catch (error) {
// 清理临时文件
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
throw error;
}
}
/**
* 删除文件或文件夹
*/
async delete(filePath) {
const fullPath = this.getFullPath(filePath);
const stats = fs.statSync(fullPath);
if (stats.isDirectory()) {
// 删除文件夹 - 递归删除
// 先计算文件夹内所有文件的总大小
const folderSize = this.calculateFolderSize(fullPath);
// 删除文件夹及其内容
fs.rmSync(fullPath, { recursive: true, force: true });
// 更新已使用空间
if (folderSize > 0) {
this.updateUsedSpace(-folderSize);
}
} else {
// 删除文件
fs.unlinkSync(fullPath);
// 更新已使用空间
this.updateUsedSpace(-stats.size);
}
}
/**
* 计算文件夹大小
*/
calculateFolderSize(folderPath) {
let totalSize = 0;
const items = fs.readdirSync(folderPath, { withFileTypes: true });
for (const item of items) {
const itemPath = path.join(folderPath, item.name);
if (item.isDirectory()) {
// 递归计算子文件夹
totalSize += this.calculateFolderSize(itemPath);
} else {
// 累加文件大小
const stats = fs.statSync(itemPath);
totalSize += stats.size;
}
}
return totalSize;
}
/**
* 重命名文件
*/
async rename(oldPath, newPath) {
const oldFullPath = this.getFullPath(oldPath);
const newFullPath = this.getFullPath(newPath);
// 确保新路径的目录存在
const newDir = path.dirname(newFullPath);
if (!fs.existsSync(newDir)) {
fs.mkdirSync(newDir, { recursive: true });
}
fs.renameSync(oldFullPath, newFullPath);
}
/**
* 获取文件信息
*/
async stat(filePath) {
const fullPath = this.getFullPath(filePath);
return fs.statSync(fullPath);
}
/**
* 创建文件读取流
*/
createReadStream(filePath) {
const fullPath = this.getFullPath(filePath);
return fs.createReadStream(fullPath);
}
/**
* 关闭连接(本地存储无需关闭)
*/
async end() {
// 本地存储无需关闭连接
}
// ===== 辅助方法 =====
/**
* 获取完整路径(带安全检查)
* 增强的路径遍历防护
*/
getFullPath(relativePath) {
// 0. 输入验证:检查空字节注入和其他危险字符
if (typeof relativePath !== 'string') {
throw new Error('无效的路径类型');
}
// 检查空字节注入(%00, \x00
if (relativePath.includes('\x00') || relativePath.includes('%00')) {
console.warn('[安全] 检测到空字节注入尝试:', relativePath);
throw new Error('路径包含非法字符');
}
// 1. 规范化路径,移除 ../ 等危险路径
let normalized = path.normalize(relativePath || '').replace(/^(\.\.[\/\\])+/, '');
// 2. 额外检查:移除路径中间的 .. (防止 a/../../../etc/passwd 绕过)
// 解析后的路径不应包含 ..
if (normalized.includes('..')) {
console.warn('[安全] 检测到目录遍历尝试:', relativePath);
throw new Error('路径包含非法字符');
}
// 3. 将绝对路径转换为相对路径解决Linux环境下的问题
if (path.isAbsolute(normalized)) {
// 移除开头的 / 或 Windows 盘符,转为相对路径
normalized = normalized.replace(/^[\/\\]+/, '').replace(/^[a-zA-Z]:/, '');
}
// 4. 空字符串或 . 表示根目录
if (normalized === '' || normalized === '.') {
return this.basePath;
}
// 5. 拼接完整路径
const fullPath = path.join(this.basePath, normalized);
// 6. 解析真实路径(处理符号链接)后再次验证
const resolvedBasePath = path.resolve(this.basePath);
const resolvedFullPath = path.resolve(fullPath);
// 7. 安全检查:确保路径在用户目录内(防止目录遍历攻击)
if (!resolvedFullPath.startsWith(resolvedBasePath)) {
console.warn('[安全] 检测到路径遍历攻击:', {
input: relativePath,
resolved: resolvedFullPath,
base: resolvedBasePath
});
throw new Error('非法路径访问');
}
return fullPath;
}
/**
* 检查配额
*/
checkQuota(additionalSize) {
const newUsed = (this.user.local_storage_used || 0) + additionalSize;
if (newUsed > this.user.local_storage_quota) {
const used = this.formatSize(this.user.local_storage_used);
const quota = this.formatSize(this.user.local_storage_quota);
const need = this.formatSize(additionalSize);
throw new Error(`存储配额不足。已使用: ${used}, 配额: ${quota}, 需要: ${need}`);
}
}
/**
* 更新已使用空间
*/
updateUsedSpace(delta) {
const newUsed = Math.max(0, (this.user.local_storage_used || 0) + delta);
UserDB.update(this.user.id, { local_storage_used: newUsed });
// 更新内存中的值
this.user.local_storage_used = newUsed;
}
/**
* 格式化文件大小
*/
formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
}
// ===== SFTP存储客户端 =====
class SftpStorageClient {
constructor(user) {
this.user = user;
this.sftp = new SftpClient();
}
/**
* 连接SFTP服务器
*/
async connect() {
await this.sftp.connect({
host: this.user.ftp_host,
port: this.user.ftp_port || 22,
username: this.user.ftp_user,
password: this.user.ftp_password
});
return this;
}
/**
* 列出目录内容
*/
async list(dirPath) {
return await this.sftp.list(dirPath);
}
/**
* 上传文件
*/
async put(localPath, remotePath) {
// 使用临时文件+重命名模式与upload_tool保持一致
const tempRemotePath = `${remotePath}.uploading_${Date.now()}`;
// 第一步:上传到临时文件
await this.sftp.put(localPath, tempRemotePath);
// 第二步:检查目标文件是否存在,如果存在先删除
try {
await this.sftp.stat(remotePath);
await this.sftp.delete(remotePath);
} catch (err) {
// 文件不存在,无需删除
}
// 第三步:重命名临时文件为目标文件
await this.sftp.rename(tempRemotePath, remotePath);
}
/**
* 删除文件
*/
async delete(filePath) {
return await this.sftp.delete(filePath);
}
/**
* 重命名文件
*/
async rename(oldPath, newPath) {
return await this.sftp.rename(oldPath, newPath);
}
/**
* 获取文件信息
*/
async stat(filePath) {
return await this.sftp.stat(filePath);
}
/**
* 创建文件读取流
*/
createReadStream(filePath) {
return this.sftp.createReadStream(filePath);
}
/**
* 关闭连接
*/
async end() {
if (this.sftp) {
await this.sftp.end();
}
}
}
module.exports = {
StorageInterface,
LocalStorageClient,
SftpStorageClient
};