SECURITY FIX: Restore import/export functionality with KEK-DEK architecture

Fix critical missing functionality identified in security audit:

## New Features Implemented:
 User-level data export (encrypted/plaintext formats)
 User-level data import with dry-run validation
 Export preview endpoint for size estimation
 OIDC configuration encryption for sensitive data
 Production environment security checks on startup

## API Endpoints Restored:
- POST /database/export - User data export with password protection
- POST /database/import - User data import with validation
- POST /database/export/preview - Export validation and stats

## Security Improvements:
- OIDC client_secret now encrypted when admin data unlocked
- Production startup checks for required environment variables
- Comprehensive import/export documentation and examples
- Proper error handling and cleanup for uploaded files

## Data Migration Support:
- Cross-instance user data migration
- Selective import (skip credentials/file manager data)
- ID collision handling with automatic regeneration
- Full validation of import data structure

Resolves the critical "503 Service Unavailable" status on import/export
endpoints that was blocking user data migration capabilities.

Maintains KEK-DEK user-level encryption while enabling data portability.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZacharyZcR
2025-09-22 00:13:56 +08:00
parent 37ef6c973d
commit cfebb690b0
7 changed files with 1537 additions and 33 deletions

View File

@@ -0,0 +1,216 @@
import { UserDataExport, type UserExportData } from "./user-data-export.js";
import { UserDataImport, type ImportResult } from "./user-data-import.js";
import { databaseLogger } from "./logger.js";
/**
* 导入导出功能测试
*
* Linus原则简单的冒烟测试确保基本功能工作
*/
class ImportExportTest {
/**
* 测试导出功能
*/
static async testExport(userId: string): Promise<boolean> {
try {
databaseLogger.info("Testing user data export functionality", {
operation: "import_export_test",
test: "export",
userId,
});
// 测试加密导出
const encryptedExport = await UserDataExport.exportUserData(userId, {
format: 'encrypted',
scope: 'user_data',
includeCredentials: true,
});
// 验证导出数据结构
const validation = UserDataExport.validateExportData(encryptedExport);
if (!validation.valid) {
databaseLogger.error("Export validation failed", {
operation: "import_export_test",
test: "export_validation",
errors: validation.errors,
});
return false;
}
// 获取统计信息
const stats = UserDataExport.getExportStats(encryptedExport);
databaseLogger.success("Export test completed successfully", {
operation: "import_export_test",
test: "export_success",
totalRecords: stats.totalRecords,
breakdown: stats.breakdown,
encrypted: stats.encrypted,
});
return true;
} catch (error) {
databaseLogger.error("Export test failed", error, {
operation: "import_export_test",
test: "export_failed",
userId,
});
return false;
}
}
/**
* 测试导入功能dry-run
*/
static async testImportDryRun(userId: string, exportData: UserExportData): Promise<boolean> {
try {
databaseLogger.info("Testing user data import functionality (dry-run)", {
operation: "import_export_test",
test: "import_dry_run",
userId,
});
// 执行dry-run导入
const result = await UserDataImport.importUserData(userId, exportData, {
dryRun: true,
replaceExisting: false,
skipCredentials: false,
skipFileManagerData: false,
});
if (result.success) {
databaseLogger.success("Import dry-run test completed successfully", {
operation: "import_export_test",
test: "import_dry_run_success",
summary: result.summary,
});
return true;
} else {
databaseLogger.error("Import dry-run test failed", {
operation: "import_export_test",
test: "import_dry_run_failed",
errors: result.summary.errors,
});
return false;
}
} catch (error) {
databaseLogger.error("Import dry-run test failed with exception", error, {
operation: "import_export_test",
test: "import_dry_run_exception",
userId,
});
return false;
}
}
/**
* 运行完整的导入导出测试
*/
static async runFullTest(userId: string): Promise<boolean> {
try {
databaseLogger.info("Starting full import/export test suite", {
operation: "import_export_test",
test: "full_suite",
userId,
});
// 1. 测试导出
const exportSuccess = await this.testExport(userId);
if (!exportSuccess) {
return false;
}
// 2. 获取导出数据用于导入测试
const exportData = await UserDataExport.exportUserData(userId, {
format: 'encrypted',
scope: 'user_data',
includeCredentials: true,
});
// 3. 测试导入dry-run
const importSuccess = await this.testImportDryRun(userId, exportData);
if (!importSuccess) {
return false;
}
databaseLogger.success("Full import/export test suite completed successfully", {
operation: "import_export_test",
test: "full_suite_success",
userId,
});
return true;
} catch (error) {
databaseLogger.error("Full import/export test suite failed", error, {
operation: "import_export_test",
test: "full_suite_failed",
userId,
});
return false;
}
}
/**
* 验证JSON序列化和反序列化
*/
static async testJSONSerialization(userId: string): Promise<boolean> {
try {
databaseLogger.info("Testing JSON serialization/deserialization", {
operation: "import_export_test",
test: "json_serialization",
userId,
});
// 导出为JSON字符串
const jsonString = await UserDataExport.exportUserDataToJSON(userId, {
format: 'encrypted',
pretty: true,
});
// 解析JSON
const parsedData = JSON.parse(jsonString);
// 验证解析后的数据
const validation = UserDataExport.validateExportData(parsedData);
if (!validation.valid) {
databaseLogger.error("JSON serialization validation failed", {
operation: "import_export_test",
test: "json_validation_failed",
errors: validation.errors,
});
return false;
}
// 测试从JSON导入dry-run
const importResult = await UserDataImport.importUserDataFromJSON(userId, jsonString, {
dryRun: true,
});
if (importResult.success) {
databaseLogger.success("JSON serialization test completed successfully", {
operation: "import_export_test",
test: "json_serialization_success",
jsonSize: jsonString.length,
});
return true;
} else {
databaseLogger.error("JSON import test failed", {
operation: "import_export_test",
test: "json_import_failed",
errors: importResult.summary.errors,
});
return false;
}
} catch (error) {
databaseLogger.error("JSON serialization test failed", error, {
operation: "import_export_test",
test: "json_serialization_exception",
userId,
});
return false;
}
}
}
export { ImportExportTest };

View File

@@ -0,0 +1,250 @@
import { db } from "../database/db/index.js";
import { users, sshData, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts } from "../database/db/schema.js";
import { eq } from "drizzle-orm";
import { DataCrypto } from "./data-crypto.js";
import { databaseLogger } from "./logger.js";
import crypto from "crypto";
interface UserExportData {
version: string;
exportedAt: string;
userId: string;
username: string;
userData: {
sshHosts: any[];
sshCredentials: any[];
fileManagerData: {
recent: any[];
pinned: any[];
shortcuts: any[];
};
dismissedAlerts: any[];
};
metadata: {
totalRecords: number;
encrypted: boolean;
exportType: 'user_data' | 'system_config' | 'all';
};
}
/**
* UserDataExport - 用户级数据导入导出
*
* Linus原则
* - 用户拥有自己的数据,应该能自由导出
* - 简单直接,没有复杂的权限检查
* - 支持加密和明文两种格式
* - 不破坏现有系统架构
*/
class UserDataExport {
private static readonly EXPORT_VERSION = "v2.0";
/**
* 导出用户数据
*/
static async exportUserData(
userId: string,
options: {
format?: 'encrypted' | 'plaintext';
scope?: 'user_data' | 'all';
includeCredentials?: boolean;
} = {}
): Promise<UserExportData> {
const { format = 'encrypted', scope = 'user_data', includeCredentials = true } = options;
try {
databaseLogger.info("Starting user data export", {
operation: "user_data_export",
userId,
format,
scope,
includeCredentials,
});
// 验证用户存在
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
throw new Error(`User not found: ${userId}`);
}
const userRecord = user[0];
// 获取用户数据密钥(如果需要解密)
let userDataKey: Buffer | null = null;
if (format === 'plaintext') {
userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
throw new Error("User data not unlocked - password required for plaintext export");
}
}
// 导出SSH主机配置
const sshHosts = await db.select().from(sshData).where(eq(sshData.userId, userId));
const processedSshHosts = format === 'plaintext' && userDataKey
? sshHosts.map(host => DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!))
: sshHosts;
// 导出SSH凭据如果包含
let sshCredentialsData: any[] = [];
if (includeCredentials) {
const credentials = await db.select().from(sshCredentials).where(eq(sshCredentials.userId, userId));
sshCredentialsData = format === 'plaintext' && userDataKey
? credentials.map(cred => DataCrypto.decryptRecord("ssh_credentials", cred, userId, userDataKey!))
: credentials;
}
// 导出文件管理器数据
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
db.select().from(fileManagerRecent).where(eq(fileManagerRecent.userId, userId)),
db.select().from(fileManagerPinned).where(eq(fileManagerPinned.userId, userId)),
db.select().from(fileManagerShortcuts).where(eq(fileManagerShortcuts.userId, userId)),
]);
// 导出已忽略的警告
const alerts = await db.select().from(dismissedAlerts).where(eq(dismissedAlerts.userId, userId));
// 构建导出数据
const exportData: UserExportData = {
version: this.EXPORT_VERSION,
exportedAt: new Date().toISOString(),
userId: userRecord.id,
username: userRecord.username,
userData: {
sshHosts: processedSshHosts,
sshCredentials: sshCredentialsData,
fileManagerData: {
recent: recentFiles,
pinned: pinnedFiles,
shortcuts: shortcuts,
},
dismissedAlerts: alerts,
},
metadata: {
totalRecords: processedSshHosts.length + sshCredentialsData.length + recentFiles.length + pinnedFiles.length + shortcuts.length + alerts.length,
encrypted: format === 'encrypted',
exportType: scope,
},
};
databaseLogger.success("User data export completed", {
operation: "user_data_export_complete",
userId,
totalRecords: exportData.metadata.totalRecords,
format,
sshHosts: processedSshHosts.length,
sshCredentials: sshCredentialsData.length,
});
return exportData;
} catch (error) {
databaseLogger.error("User data export failed", error, {
operation: "user_data_export_failed",
userId,
format,
scope,
});
throw error;
}
}
/**
* 导出为JSON字符串
*/
static async exportUserDataToJSON(
userId: string,
options: {
format?: 'encrypted' | 'plaintext';
scope?: 'user_data' | 'all';
includeCredentials?: boolean;
pretty?: boolean;
} = {}
): Promise<string> {
const { pretty = true } = options;
const exportData = await this.exportUserData(userId, options);
return JSON.stringify(exportData, null, pretty ? 2 : 0);
}
/**
* 验证导出数据格式
*/
static validateExportData(data: any): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (!data || typeof data !== 'object') {
errors.push("Export data must be an object");
return { valid: false, errors };
}
if (!data.version) {
errors.push("Missing version field");
}
if (!data.userId) {
errors.push("Missing userId field");
}
if (!data.userData || typeof data.userData !== 'object') {
errors.push("Missing or invalid userData field");
}
if (!data.metadata || typeof data.metadata !== 'object') {
errors.push("Missing or invalid metadata field");
}
// 检查必需的数据字段
if (data.userData) {
const requiredFields = ['sshHosts', 'sshCredentials', 'fileManagerData', 'dismissedAlerts'];
for (const field of requiredFields) {
if (!Array.isArray(data.userData[field]) && !(field === 'fileManagerData' && typeof data.userData[field] === 'object')) {
errors.push(`Missing or invalid userData.${field} field`);
}
}
if (data.userData.fileManagerData && typeof data.userData.fileManagerData === 'object') {
const fmFields = ['recent', 'pinned', 'shortcuts'];
for (const field of fmFields) {
if (!Array.isArray(data.userData.fileManagerData[field])) {
errors.push(`Missing or invalid userData.fileManagerData.${field} field`);
}
}
}
}
return { valid: errors.length === 0, errors };
}
/**
* 获取导出数据统计信息
*/
static getExportStats(data: UserExportData): {
version: string;
exportedAt: string;
username: string;
totalRecords: number;
breakdown: {
sshHosts: number;
sshCredentials: number;
fileManagerItems: number;
dismissedAlerts: number;
};
encrypted: boolean;
} {
return {
version: data.version,
exportedAt: data.exportedAt,
username: data.username,
totalRecords: data.metadata.totalRecords,
breakdown: {
sshHosts: data.userData.sshHosts.length,
sshCredentials: data.userData.sshCredentials.length,
fileManagerItems: data.userData.fileManagerData.recent.length +
data.userData.fileManagerData.pinned.length +
data.userData.fileManagerData.shortcuts.length,
dismissedAlerts: data.userData.dismissedAlerts.length,
},
encrypted: data.metadata.encrypted,
};
}
}
export { UserDataExport, type UserExportData };

View File

@@ -0,0 +1,424 @@
import { db } from "../database/db/index.js";
import { users, sshData, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { DataCrypto } from "./data-crypto.js";
import { UserDataExport, type UserExportData } from "./user-data-export.js";
import { databaseLogger } from "./logger.js";
import { nanoid } from "nanoid";
interface ImportOptions {
replaceExisting?: boolean;
skipCredentials?: boolean;
skipFileManagerData?: boolean;
dryRun?: boolean;
}
interface ImportResult {
success: boolean;
summary: {
sshHostsImported: number;
sshCredentialsImported: number;
fileManagerItemsImported: number;
dismissedAlertsImported: number;
skippedItems: number;
errors: string[];
};
dryRun: boolean;
}
/**
* UserDataImport - 用户数据导入
*
* Linus原则
* - 导入不应该破坏现有数据(除非明确要求)
* - 支持dry-run模式验证
* - 处理ID冲突的简单策略重新生成
* - 错误处理要明确,不能静默失败
*/
class UserDataImport {
/**
* 导入用户数据
*/
static async importUserData(
targetUserId: string,
exportData: UserExportData,
options: ImportOptions = {}
): Promise<ImportResult> {
const {
replaceExisting = false,
skipCredentials = false,
skipFileManagerData = false,
dryRun = false
} = options;
try {
databaseLogger.info("Starting user data import", {
operation: "user_data_import",
targetUserId,
sourceUserId: exportData.userId,
sourceUsername: exportData.username,
dryRun,
replaceExisting,
skipCredentials,
skipFileManagerData,
});
// 验证目标用户存在
const targetUser = await db.select().from(users).where(eq(users.id, targetUserId));
if (!targetUser || targetUser.length === 0) {
throw new Error(`Target user not found: ${targetUserId}`);
}
// 验证导出数据格式
const validation = UserDataExport.validateExportData(exportData);
if (!validation.valid) {
throw new Error(`Invalid export data: ${validation.errors.join(', ')}`);
}
// 验证用户数据已解锁(如果数据是加密的)
let userDataKey: Buffer | null = null;
if (exportData.metadata.encrypted) {
userDataKey = DataCrypto.getUserDataKey(targetUserId);
if (!userDataKey) {
throw new Error("Target user data not unlocked - password required for encrypted import");
}
}
const result: ImportResult = {
success: false,
summary: {
sshHostsImported: 0,
sshCredentialsImported: 0,
fileManagerItemsImported: 0,
dismissedAlertsImported: 0,
skippedItems: 0,
errors: [],
},
dryRun,
};
// 导入SSH主机配置
if (exportData.userData.sshHosts && exportData.userData.sshHosts.length > 0) {
const importStats = await this.importSshHosts(
targetUserId,
exportData.userData.sshHosts,
{ replaceExisting, dryRun, userDataKey }
);
result.summary.sshHostsImported = importStats.imported;
result.summary.skippedItems += importStats.skipped;
result.summary.errors.push(...importStats.errors);
}
// 导入SSH凭据
if (!skipCredentials && exportData.userData.sshCredentials && exportData.userData.sshCredentials.length > 0) {
const importStats = await this.importSshCredentials(
targetUserId,
exportData.userData.sshCredentials,
{ replaceExisting, dryRun, userDataKey }
);
result.summary.sshCredentialsImported = importStats.imported;
result.summary.skippedItems += importStats.skipped;
result.summary.errors.push(...importStats.errors);
}
// 导入文件管理器数据
if (!skipFileManagerData && exportData.userData.fileManagerData) {
const importStats = await this.importFileManagerData(
targetUserId,
exportData.userData.fileManagerData,
{ replaceExisting, dryRun }
);
result.summary.fileManagerItemsImported = importStats.imported;
result.summary.skippedItems += importStats.skipped;
result.summary.errors.push(...importStats.errors);
}
// 导入忽略的警告
if (exportData.userData.dismissedAlerts && exportData.userData.dismissedAlerts.length > 0) {
const importStats = await this.importDismissedAlerts(
targetUserId,
exportData.userData.dismissedAlerts,
{ replaceExisting, dryRun }
);
result.summary.dismissedAlertsImported = importStats.imported;
result.summary.skippedItems += importStats.skipped;
result.summary.errors.push(...importStats.errors);
}
result.success = result.summary.errors.length === 0;
databaseLogger.success("User data import completed", {
operation: "user_data_import_complete",
targetUserId,
dryRun,
...result.summary,
});
return result;
} catch (error) {
databaseLogger.error("User data import failed", error, {
operation: "user_data_import_failed",
targetUserId,
dryRun,
});
throw error;
}
}
/**
* 导入SSH主机配置
*/
private static async importSshHosts(
targetUserId: string,
sshHosts: any[],
options: { replaceExisting: boolean; dryRun: boolean; userDataKey: Buffer | null }
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (const host of sshHosts) {
try {
if (options.dryRun) {
imported++;
continue;
}
// 重新生成ID避免冲突
const newHostData = {
...host,
id: undefined, // 让数据库自动生成
userId: targetUserId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// 如果数据需要重新加密
let processedHostData = newHostData;
if (options.userDataKey) {
processedHostData = DataCrypto.encryptRecord("ssh_data", newHostData, targetUserId, options.userDataKey);
}
await db.insert(sshData).values(processedHostData);
imported++;
} catch (error) {
errors.push(`SSH host import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
skipped++;
}
}
return { imported, skipped, errors };
}
/**
* 导入SSH凭据
*/
private static async importSshCredentials(
targetUserId: string,
credentials: any[],
options: { replaceExisting: boolean; dryRun: boolean; userDataKey: Buffer | null }
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (const credential of credentials) {
try {
if (options.dryRun) {
imported++;
continue;
}
// 重新生成ID避免冲突
const newCredentialData = {
...credential,
id: undefined, // 让数据库自动生成
userId: targetUserId,
usageCount: 0, // 重置使用计数
lastUsed: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// 如果数据需要重新加密
let processedCredentialData = newCredentialData;
if (options.userDataKey) {
processedCredentialData = DataCrypto.encryptRecord("ssh_credentials", newCredentialData, targetUserId, options.userDataKey);
}
await db.insert(sshCredentials).values(processedCredentialData);
imported++;
} catch (error) {
errors.push(`SSH credential import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
skipped++;
}
}
return { imported, skipped, errors };
}
/**
* 导入文件管理器数据
*/
private static async importFileManagerData(
targetUserId: string,
fileManagerData: any,
options: { replaceExisting: boolean; dryRun: boolean }
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
try {
// 导入最近文件
if (fileManagerData.recent && Array.isArray(fileManagerData.recent)) {
for (const item of fileManagerData.recent) {
try {
if (!options.dryRun) {
const newItem = {
...item,
id: undefined,
userId: targetUserId,
lastOpened: new Date().toISOString(),
};
await db.insert(fileManagerRecent).values(newItem);
}
imported++;
} catch (error) {
errors.push(`Recent file import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
skipped++;
}
}
}
// 导入固定文件
if (fileManagerData.pinned && Array.isArray(fileManagerData.pinned)) {
for (const item of fileManagerData.pinned) {
try {
if (!options.dryRun) {
const newItem = {
...item,
id: undefined,
userId: targetUserId,
pinnedAt: new Date().toISOString(),
};
await db.insert(fileManagerPinned).values(newItem);
}
imported++;
} catch (error) {
errors.push(`Pinned file import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
skipped++;
}
}
}
// 导入快捷方式
if (fileManagerData.shortcuts && Array.isArray(fileManagerData.shortcuts)) {
for (const item of fileManagerData.shortcuts) {
try {
if (!options.dryRun) {
const newItem = {
...item,
id: undefined,
userId: targetUserId,
createdAt: new Date().toISOString(),
};
await db.insert(fileManagerShortcuts).values(newItem);
}
imported++;
} catch (error) {
errors.push(`Shortcut import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
skipped++;
}
}
}
} catch (error) {
errors.push(`File manager data import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
return { imported, skipped, errors };
}
/**
* 导入忽略的警告
*/
private static async importDismissedAlerts(
targetUserId: string,
alerts: any[],
options: { replaceExisting: boolean; dryRun: boolean }
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (const alert of alerts) {
try {
if (options.dryRun) {
imported++;
continue;
}
// 检查是否已存在相同的警告
const existing = await db
.select()
.from(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, targetUserId),
eq(dismissedAlerts.alertId, alert.alertId)
)
);
if (existing.length > 0 && !options.replaceExisting) {
skipped++;
continue;
}
const newAlert = {
...alert,
id: undefined,
userId: targetUserId,
dismissedAt: new Date().toISOString(),
};
if (existing.length > 0 && options.replaceExisting) {
await db
.update(dismissedAlerts)
.set(newAlert)
.where(eq(dismissedAlerts.id, existing[0].id));
} else {
await db.insert(dismissedAlerts).values(newAlert);
}
imported++;
} catch (error) {
errors.push(`Dismissed alert import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
skipped++;
}
}
return { imported, skipped, errors };
}
/**
* 从JSON字符串导入
*/
static async importUserDataFromJSON(
targetUserId: string,
jsonData: string,
options: ImportOptions = {}
): Promise<ImportResult> {
try {
const exportData: UserExportData = JSON.parse(jsonData);
return await this.importUserData(targetUserId, exportData, options);
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error("Invalid JSON format in import data");
}
throw error;
}
}
}
export { UserDataImport, type ImportOptions, type ImportResult };