fix: harden cloud storage security

This commit is contained in:
237899745
2026-06-13 18:45:12 +08:00
parent 7943b04ee2
commit bb6ad01018
28 changed files with 2229 additions and 996 deletions

View File

@@ -48,6 +48,7 @@ const OSS_SINGLE_UPLOAD_THRESHOLD = Math.max(
OSS_MULTIPART_MIN_PART_SIZE,
parsePositiveInteger(process.env.OSS_SINGLE_UPLOAD_THRESHOLD_BYTES, 64 * 1024 * 1024)
);
const OSS_RENAME_RECOVERY_KEYS = new Set();
/**
* 将 OSS/网络错误转换为友好的错误信息
@@ -230,8 +231,10 @@ class LocalStorageClient {
// 检查配额:只检查净增量(新文件大小 - 旧文件大小)
const netIncrease = newFileSize - oldFileSize;
let reservedDelta = 0;
if (netIncrease > 0) {
this.checkQuota(netIncrease);
reservedDelta = netIncrease;
}
// 确保目标目录存在
@@ -242,30 +245,37 @@ class LocalStorageClient {
// 使用临时文件+重命名模式,避免文件被占用问题
const tempPath = `${destPath}.uploading_${Date.now()}`;
const backupPath = `${destPath}.backup_${Date.now()}`;
let backupCreated = false;
let destReplaced = false;
try {
// 如果目标文件存在,先删除
if (fs.existsSync(destPath)) {
fs.unlinkSync(destPath);
fs.renameSync(destPath, backupPath);
backupCreated = true;
}
// 优先尝试 rename同文件系统下瞬时完成大文件不再需要逐字节复制
let movedDirectly = false;
try {
fs.renameSync(localPath, destPath);
movedDirectly = true;
destReplaced = true;
} catch (renameErr) {
if (renameErr.code === 'EXDEV') {
// 跨文件系统,回退到 copy + rename
fs.copyFileSync(localPath, tempPath);
fs.renameSync(tempPath, destPath);
destReplaced = true;
} else {
throw renameErr;
}
}
// 更新已使用空间(使用净增量)
if (netIncrease !== 0) {
if (backupCreated) {
try { fs.unlinkSync(backupPath); } catch (_) {}
}
// 正向净增已在写入前原子预留,这里只处理覆盖变小时的扣减。
if (netIncrease < 0) {
this.updateUsedSpace(netIncrease);
}
} catch (error) {
@@ -273,6 +283,17 @@ class LocalStorageClient {
if (fs.existsSync(tempPath)) {
try { fs.unlinkSync(tempPath); } catch (_) {}
}
if (destReplaced && fs.existsSync(destPath)) {
try { fs.unlinkSync(destPath); } catch (_) {}
}
if (backupCreated && fs.existsSync(backupPath) && !fs.existsSync(destPath)) {
try { fs.renameSync(backupPath, destPath); } catch (restoreError) {
console.error(`[本地存储] 恢复旧文件失败: ${restoreError.message}`);
}
}
if (reservedDelta > 0) {
this.updateUsedSpace(-reservedDelta);
}
throw error;
}
}
@@ -507,12 +528,13 @@ class LocalStorageClient {
// 5. 拼接完整路径
const fullPath = path.join(this.basePath, normalized);
// 6. 解析真实路径(处理符号链接)后再次验证
// 6. 解析绝对路径后再次验证
const resolvedBasePath = path.resolve(this.basePath);
const resolvedFullPath = path.resolve(fullPath);
const relativeToBase = path.relative(resolvedBasePath, resolvedFullPath);
// 7. 安全检查:确保路径在用户目录内(防止目录遍历攻击)
if (!resolvedFullPath.startsWith(resolvedBasePath)) {
if (relativeToBase.startsWith('..') || path.isAbsolute(relativeToBase)) {
console.warn('[安全] 检测到路径遍历攻击:', {
input: relativePath,
resolved: resolvedFullPath,
@@ -528,225 +550,26 @@ class LocalStorageClient {
* 检查配额
*/
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 amount = Math.max(0, Math.trunc(Number(additionalSize) || 0));
if (amount === 0) return;
const updatedUser = UserDB.reserveLocalStorageSpace(this.user.id, amount);
if (!updatedUser) {
const latestUser = UserDB.findById(this.user.id) || this.user;
const used = this.formatSize(latestUser.local_storage_used || 0);
const quota = this.formatSize(this.user.local_storage_quota);
const need = this.formatSize(additionalSize);
const need = this.formatSize(amount);
throw new Error(`存储配额不足。已使用: ${used}, 配额: ${quota}, 需要: ${need}`);
}
this.user.local_storage_used = Number(updatedUser.local_storage_used || 0);
}
/**
* 更新已使用空间
*/
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;
}
/**
* 恢复未完成的重命名操作(启动时调用)
* 扫描OSS存储中的待处理重命名标记文件执行回滚或完成操作
*
* **重命名操作的两个阶段:**
* 1. copying 阶段:正在复制文件到新位置
* - 恢复策略:删除已复制的目标文件,保留原文件
* 2. deleting 阶段:正在删除原文件
* - 恢复策略:确保原文件被完全删除(补充删除逻辑)
*
* @private
*/
async recoverPendingRenames() {
try {
console.log('[OSS存储] 检查未完成的重命名操作...');
const bucket = this.getBucket();
const markerPrefix = this.prefix + '.rename_pending_';
// 列出所有待处理的标记文件
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: markerPrefix,
MaxKeys: 100
});
const response = await this.s3Client.send(listCommand);
if (!response.Contents || response.Contents.length === 0) {
console.log('[OSS存储] 没有未完成的重命名操作');
return;
}
console.log(`[OSS存储] 发现 ${response.Contents.length} 个未完成的重命名操作,开始恢复...`);
for (const marker of response.Contents) {
try {
// 从标记文件名中解析元数据
// 格式: .rename_pending_{timestamp}_{oldKeyHash}.json
const markerKey = marker.Key;
// 读取标记文件内容
const getMarkerCommand = new GetObjectCommand({
Bucket: bucket,
Key: markerKey
});
const markerResponse = await this.s3Client.send(getMarkerCommand);
const markerContent = await streamToBuffer(markerResponse.Body);
const metadata = JSON.parse(markerContent.toString());
const { oldPrefix, newPrefix, timestamp, phase } = metadata;
// 检查标记是否过期超过1小时视为失败需要恢复
const age = Date.now() - timestamp;
const TIMEOUT = 60 * 60 * 1000; // 1小时
if (age > TIMEOUT) {
console.warn(`[OSS存储] 检测到超时的重命名操作: ${oldPrefix} -> ${newPrefix}, 阶段: ${phase}`);
// 根据不同阶段执行不同的恢复策略
if (phase === 'copying') {
// ===== 第一阶段:复制阶段超时 =====
// 策略:删除已复制的目标文件,保留原文件
console.log(`[OSS存储] [copying阶段] 执行回滚: 删除已复制的文件 ${newPrefix}`);
await this._rollbackRename(oldPrefix, newPrefix);
} else if (phase === 'deleting') {
// ===== 第二阶段:删除阶段超时(第二轮修复) =====
// 策略:补充完整的删除逻辑,确保原文件被清理干净
console.log(`[OSS存储] [deleting阶段] 执行补充删除: 清理剩余原文件 ${oldPrefix}`);
try {
// 步骤1列出原位置的所有剩余文件
let continuationToken = null;
let remainingCount = 0;
const MAX_KEYS_PER_REQUEST = 1000;
do {
const listOldCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: oldPrefix,
MaxKeys: MAX_KEYS_PER_REQUEST,
ContinuationToken: continuationToken
});
const listOldResponse = await this.s3Client.send(listOldCommand);
continuationToken = listOldResponse.NextContinuationToken;
if (listOldResponse.Contents && listOldResponse.Contents.length > 0) {
// 步骤2批量删除剩余的原文件
const deleteCommand = new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: listOldResponse.Contents.map(obj => ({ Key: obj.Key })),
Quiet: true
}
});
const deleteResult = await this.s3Client.send(deleteCommand);
remainingCount += listOldResponse.Contents.length;
console.log(`[OSS存储] [deleting阶段] 已删除 ${listOldResponse.Contents.length} 个剩余原文件`);
// 检查删除结果
if (deleteResult.Errors && deleteResult.Errors.length > 0) {
console.warn(`[OSS存储] [deleting阶段] 部分文件删除失败:`, deleteResult.Errors);
}
}
} while (continuationToken);
if (remainingCount > 0) {
console.log(`[OSS存储] [deleting阶段] 补充删除完成: 清理了 ${remainingCount} 个原文件`);
} else {
console.log(`[OSS存储] [deleting阶段] 原位置 ${oldPrefix} 已是空的,无需清理`);
}
} catch (cleanupError) {
console.error(`[OSS存储] [deleting阶段] 补充删除失败: ${cleanupError.message}`);
// 继续执行,不中断流程
}
} else {
// 未知阶段,记录警告
console.warn(`[OSS存储] 未知阶段 ${phase},跳过恢复`);
}
// 删除标记文件(完成恢复后清理)
await this.s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: [{ Key: markerKey }],
Quiet: true
}
}));
console.log(`[OSS存储] 已清理超时的重命名标记: ${markerKey}`);
} else {
console.log(`[OSS存储] 重命名操作仍在进行中: ${oldPrefix} -> ${newPrefix} (阶段: ${phase}, 剩余: ${Math.floor((TIMEOUT - age) / 1000)}秒)`);
}
} catch (error) {
console.error(`[OSS存储] 恢复重命名操作失败: ${marker.Key}`, error.message);
// 继续处理下一个标记文件
}
}
console.log('[OSS存储] 重命名操作恢复完成');
} catch (error) {
console.error('[OSS存储] 恢复重命名操作时出错:', error.message);
}
}
/**
* 回滚重命名操作(删除已复制的目标文件)
* @param {string} oldPrefix - 原前缀
* @param {string} newPrefix - 新前缀
* @private
*/
async _rollbackRename(oldPrefix, newPrefix) {
const bucket = this.getBucket();
const newPrefixWithSlash = newPrefix.endsWith('/') ? newPrefix : `${newPrefix}/`;
try {
// 列出所有已复制的对象
let continuationToken = null;
let deletedCount = 0;
do {
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: newPrefixWithSlash,
ContinuationToken: continuationToken,
MaxKeys: 1000
});
const listResponse = await this.s3Client.send(listCommand);
continuationToken = listResponse.NextContinuationToken;
if (listResponse.Contents && listResponse.Contents.length > 0) {
// 批量删除
const deleteCommand = new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })),
Quiet: true
}
});
await this.s3Client.send(deleteCommand);
deletedCount += listResponse.Contents.length;
}
} while (continuationToken);
if (deletedCount > 0) {
console.log(`[OSS存储] 回滚完成: 删除了 ${deletedCount} 个对象`);
}
} catch (error) {
console.error(`[OSS存储] 回滚失败: ${error.message}`);
throw error;
}
const updatedUser = UserDB.adjustLocalStorageUsed(this.user.id, delta);
this.user.local_storage_used = Number(updatedUser?.local_storage_used || 0);
}
/**
@@ -928,6 +751,16 @@ class OssStorageClient {
this.s3Client = new S3Client(s3Config);
console.log(`[OSS存储] 已连接: ${ossConfig.oss_provider}, bucket: ${ossConfig.oss_bucket}, 使用统一配置: ${this.useUnifiedConfig}`);
const recoveryKey = `${ossConfig.oss_bucket}:${this.prefix}`;
if (!OSS_RENAME_RECOVERY_KEYS.has(recoveryKey)) {
OSS_RENAME_RECOVERY_KEYS.add(recoveryKey);
this.recoverPendingRenames().catch(error => {
OSS_RENAME_RECOVERY_KEYS.delete(recoveryKey);
console.error('[OSS存储] 重命名恢复任务失败:', error.message);
});
}
return this;
} catch (error) {
console.error(`[OSS存储] 连接失败:`, error.message);
@@ -935,6 +768,113 @@ class OssStorageClient {
}
}
/**
* 恢复未完成的重命名操作。
* 扫描 OSS 中的 .rename_pending_* 标记,回滚 copying 阶段或补删 deleting 阶段。
*/
async recoverPendingRenames() {
try {
console.log('[OSS存储] 检查未完成的重命名操作...');
const bucket = this.getBucket();
const markerPrefix = this.prefix + '.rename_pending_';
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: markerPrefix,
MaxKeys: 100
});
const response = await this.s3Client.send(listCommand);
if (!response.Contents || response.Contents.length === 0) {
console.log('[OSS存储] 没有未完成的重命名操作');
return;
}
console.log(`[OSS存储] 发现 ${response.Contents.length} 个未完成的重命名操作,开始恢复...`);
for (const marker of response.Contents) {
try {
const markerKey = marker.Key;
const markerResponse = await this.s3Client.send(new GetObjectCommand({
Bucket: bucket,
Key: markerKey
}));
const markerContent = await streamToBuffer(markerResponse.Body);
const metadata = JSON.parse(markerContent.toString());
const { oldPrefix, newPrefix, timestamp, phase } = metadata;
const age = Date.now() - Number(timestamp || 0);
const timeoutMs = 60 * 60 * 1000;
if (age <= timeoutMs) {
console.log(`[OSS存储] 重命名操作仍在进行中: ${oldPrefix} -> ${newPrefix} (阶段: ${phase})`);
continue;
}
console.warn(`[OSS存储] 检测到超时的重命名操作: ${oldPrefix} -> ${newPrefix}, 阶段: ${phase}`);
if (phase === 'copying') {
await this._rollbackRename(oldPrefix, newPrefix);
} else if (phase === 'deleting') {
await this._deletePrefixObjects(oldPrefix);
} else {
console.warn(`[OSS存储] 未知阶段 ${phase},仅清理标记文件`);
}
await this.s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: [{ Key: markerKey }],
Quiet: true
}
}));
console.log(`[OSS存储] 已清理超时的重命名标记: ${markerKey}`);
} catch (error) {
console.error(`[OSS存储] 恢复重命名操作失败: ${marker.Key}`, error.message);
}
}
console.log('[OSS存储] 重命名操作恢复完成');
} catch (error) {
console.error('[OSS存储] 恢复重命名操作时出错:', error.message);
}
}
async _rollbackRename(oldPrefix, newPrefix) {
const newPrefixWithSlash = newPrefix.endsWith('/') ? newPrefix : `${newPrefix}/`;
await this._deletePrefixObjects(newPrefixWithSlash);
}
async _deletePrefixObjects(prefix) {
const bucket = this.getBucket();
const safePrefix = prefix.endsWith('/') ? prefix : `${prefix}/`;
let continuationToken = null;
let deletedCount = 0;
do {
const listResponse = await this.s3Client.send(new ListObjectsV2Command({
Bucket: bucket,
Prefix: safePrefix,
ContinuationToken: continuationToken,
MaxKeys: 1000
}));
continuationToken = listResponse.NextContinuationToken;
if (listResponse.Contents && listResponse.Contents.length > 0) {
await this.s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })),
Quiet: true
}
}));
deletedCount += listResponse.Contents.length;
}
} while (continuationToken);
if (deletedCount > 0) {
console.log(`[OSS存储] 已删除前缀 ${safePrefix}${deletedCount} 个对象`);
}
}
/**
* 获取当前使用的 bucket 名称
* @returns {string}
@@ -979,22 +919,27 @@ class OssStorageClient {
throw new Error('路径包含非法字符');
}
// 2. 先进行 URL 解码(防止双重编码绕过)
let decoded = relativePath;
// 2. 仅将 URL 解码结果用于安全检查,不用于生成对象 key。
// 文件名中的字面量 "%2F" 应保留为普通字符,不能变成路径分隔符。
let decodedForSecurity = relativePath;
try {
decoded = decodeURIComponent(relativePath);
decodedForSecurity = decodeURIComponent(relativePath);
} catch (e) {
// 解码失败使用原始值
// 解码失败使用原始值做后续检查
}
// 3. 检查解码后的空字节
if (decoded.includes('\x00')) {
if (decodedForSecurity.includes('\x00')) {
console.warn('[OSS安全] 检测到编码的空字节注入:', relativePath);
throw new Error('路径包含非法字符');
}
if (decodedForSecurity.includes('..')) {
console.warn('[OSS安全] 检测到编码的目录遍历尝试:', relativePath);
throw new Error('路径包含非法字符');
}
// 4. 规范化路径统一使用正斜杠OSS 使用正斜杠作为分隔符)
let normalized = decoded
let normalized = relativePath
.replace(/\\/g, '/') // 将反斜杠转换为正斜杠
.replace(/\/+/g, '/'); // 合并多个连续斜杠
@@ -1282,7 +1227,7 @@ class OssStorageClient {
try {
statResult = await this.stat(filePath);
} catch (statError) {
if (statError.message && statResult?.message.includes('不存在')) {
if (statError.message && statError.message.includes('不存在')) {
console.warn(`[OSS存储] 文件不存在,跳过删除: ${key}`);
return { size: 0 }; // 文件不存在,返回大小为 0
}
@@ -1292,6 +1237,7 @@ class OssStorageClient {
let totalDeletedSize = 0;
if (statResult.isDirectory) {
const directoryPrefix = key.endsWith('/') ? key : `${key}/`;
// 删除目录:列出所有对象并批量删除
// 使用分页循环处理超过 1000 个对象的情况
let continuationToken = null;
@@ -1301,7 +1247,7 @@ class OssStorageClient {
do {
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: key,
Prefix: directoryPrefix,
MaxKeys: MAX_DELETE_BATCH,
ContinuationToken: continuationToken
});
@@ -1336,7 +1282,7 @@ class OssStorageClient {
} while (continuationToken);
if (totalDeletedCount > 0) {
console.log(`[OSS存储] 删除目录: ${key} (${totalDeletedCount} 个对象, ${totalDeletedSize} 字节)`);
console.log(`[OSS存储] 删除目录: ${directoryPrefix} (${totalDeletedCount} 个对象, ${totalDeletedSize} 字节)`);
}
return { size: totalDeletedSize };
@@ -1742,7 +1688,7 @@ class OssStorageClient {
const key = this.getObjectKey(filePath);
const bucket = this.getBucket();
const provider = this.getProvider();
const region = this.s3Client.config.region;
const region = String(this.currentConfig?.oss_region || this.user.oss_region || 'us-east-1');
let baseUrl;
if (provider === 'aliyun') {