Merge pull request #34 from luojiyin1987/feat/security-secure-file-service
Feat/security secure file service
This commit is contained in:
commit
20a7cb34fc
@ -89,6 +89,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.1.0",
|
"@types/node": "^24.1.0",
|
||||||
"bun-types": "latest",
|
"bun-types": "latest",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { ImageBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
|
import { ImageBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
|
||||||
import { existsSync, readFileSync, statSync } from 'fs'
|
import { statSync } from 'node:fs'
|
||||||
import { Box, Text } from 'ink'
|
import { Box, Text } from 'ink'
|
||||||
import * as path from 'path'
|
import * as path from 'node:path'
|
||||||
import { extname, relative } from 'path'
|
import { extname, relative } from 'node:path'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage'
|
import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage'
|
||||||
@ -24,6 +24,7 @@ import {
|
|||||||
} from '../../services/fileFreshness'
|
} from '../../services/fileFreshness'
|
||||||
import { DESCRIPTION, PROMPT } from './prompt'
|
import { DESCRIPTION, PROMPT } from './prompt'
|
||||||
import { hasReadPermission } from '../../utils/permissions/filesystem'
|
import { hasReadPermission } from '../../utils/permissions/filesystem'
|
||||||
|
import { secureFileService } from '../../utils/secureFile'
|
||||||
|
|
||||||
const MAX_LINES_TO_RENDER = 5
|
const MAX_LINES_TO_RENDER = 5
|
||||||
const MAX_OUTPUT_SIZE = 0.25 * 1024 * 1024 // 0.25MB in bytes
|
const MAX_OUTPUT_SIZE = 0.25 * 1024 * 1024 // 0.25MB in bytes
|
||||||
@ -144,7 +145,9 @@ export const FileReadTool = {
|
|||||||
async validateInput({ file_path, offset, limit }) {
|
async validateInput({ file_path, offset, limit }) {
|
||||||
const fullFilePath = normalizeFilePath(file_path)
|
const fullFilePath = normalizeFilePath(file_path)
|
||||||
|
|
||||||
if (!existsSync(fullFilePath)) {
|
// Use secure file service to check if file exists and get file info
|
||||||
|
const fileCheck = secureFileService.safeGetFileInfo(fullFilePath)
|
||||||
|
if (!fileCheck.success) {
|
||||||
// Try to find a similar file with a different extension
|
// Try to find a similar file with a different extension
|
||||||
const similarFilename = findSimilarFile(fullFilePath)
|
const similarFilename = findSimilarFile(fullFilePath)
|
||||||
let message = 'File does not exist.'
|
let message = 'File does not exist.'
|
||||||
@ -160,8 +163,7 @@ export const FileReadTool = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file stats to check size
|
const stats = fileCheck.stats!
|
||||||
const stats = statSync(fullFilePath)
|
|
||||||
const fileSize = stats.size
|
const fileSize = stats.size
|
||||||
const ext = path.extname(fullFilePath).toLowerCase()
|
const ext = path.extname(fullFilePath).toLowerCase()
|
||||||
|
|
||||||
@ -316,7 +318,18 @@ async function readImage(
|
|||||||
const sharp = (
|
const sharp = (
|
||||||
(await import('sharp')) as unknown as { default: typeof import('sharp') }
|
(await import('sharp')) as unknown as { default: typeof import('sharp') }
|
||||||
).default
|
).default
|
||||||
const image = sharp(readFileSync(filePath))
|
|
||||||
|
// Use secure file service to read the file
|
||||||
|
const fileReadResult = secureFileService.safeReadFile(filePath, {
|
||||||
|
encoding: 'buffer' as BufferEncoding,
|
||||||
|
maxFileSize: MAX_IMAGE_SIZE
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!fileReadResult.success) {
|
||||||
|
throw new Error(`Failed to read image file: ${fileReadResult.error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = sharp(fileReadResult.content as Buffer)
|
||||||
const metadata = await image.metadata()
|
const metadata = await image.metadata()
|
||||||
|
|
||||||
if (!metadata.width || !metadata.height) {
|
if (!metadata.width || !metadata.height) {
|
||||||
@ -336,7 +349,17 @@ async function readImage(
|
|||||||
width <= MAX_WIDTH &&
|
width <= MAX_WIDTH &&
|
||||||
height <= MAX_HEIGHT
|
height <= MAX_HEIGHT
|
||||||
) {
|
) {
|
||||||
return createImageResponse(readFileSync(filePath), ext)
|
// Use secure file service to read the file
|
||||||
|
const fileReadResult = secureFileService.safeReadFile(filePath, {
|
||||||
|
encoding: 'buffer' as BufferEncoding,
|
||||||
|
maxFileSize: MAX_IMAGE_SIZE
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!fileReadResult.success) {
|
||||||
|
throw new Error(`Failed to read image file: ${fileReadResult.error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createImageResponse(fileReadResult.content as Buffer, ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (width > MAX_WIDTH) {
|
if (width > MAX_WIDTH) {
|
||||||
@ -367,6 +390,15 @@ async function readImage(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(e)
|
logError(e)
|
||||||
// If any error occurs during processing, return original image
|
// If any error occurs during processing, return original image
|
||||||
return createImageResponse(readFileSync(filePath), ext)
|
const fileReadResult = secureFileService.safeReadFile(filePath, {
|
||||||
|
encoding: 'buffer' as BufferEncoding,
|
||||||
|
maxFileSize: MAX_IMAGE_SIZE
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!fileReadResult.success) {
|
||||||
|
throw new Error(`Failed to read image file: ${fileReadResult.error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createImageResponse(fileReadResult.content as Buffer, ext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
559
src/utils/secureFile.ts
Normal file
559
src/utils/secureFile.ts
Normal file
@ -0,0 +1,559 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync, renameSync } from 'node:fs'
|
||||||
|
import { join, dirname, normalize, resolve, extname } from 'node:path'
|
||||||
|
import { homedir } from 'node:os'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全文件系统操作服务
|
||||||
|
* 解决文件系统操作中缺少适当验证和错误处理的问题
|
||||||
|
*/
|
||||||
|
export class SecureFileService {
|
||||||
|
private static instance: SecureFileService
|
||||||
|
private allowedBasePaths: Set<string>
|
||||||
|
private maxFileSize: number
|
||||||
|
private allowedExtensions: Set<string>
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
// 允许的基础路径
|
||||||
|
this.allowedBasePaths = new Set([
|
||||||
|
process.cwd(),
|
||||||
|
homedir(),
|
||||||
|
'/tmp',
|
||||||
|
'/var/tmp'
|
||||||
|
])
|
||||||
|
|
||||||
|
// 默认最大文件大小 (10MB)
|
||||||
|
this.maxFileSize = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
// 允许的文件扩展名
|
||||||
|
this.allowedExtensions = new Set([
|
||||||
|
'.txt', '.md', '.json', '.js', '.ts', '.tsx', '.jsx',
|
||||||
|
'.yaml', '.yml', '.toml', '.ini', '.env', '.log',
|
||||||
|
'.html', '.css', '.scss', '.less', '.xml', '.csv',
|
||||||
|
'.py', '.go', '.rs', '.java', '.cpp', '.c', '.h',
|
||||||
|
'.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat',
|
||||||
|
'.dockerfile', '.gitignore', '.npmignore', '.eslintignore'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): SecureFileService {
|
||||||
|
if (!SecureFileService.instance) {
|
||||||
|
SecureFileService.instance = new SecureFileService()
|
||||||
|
}
|
||||||
|
return SecureFileService.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证文件路径是否安全
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @returns 验证结果
|
||||||
|
*/
|
||||||
|
public validateFilePath(filePath: string): { isValid: boolean; normalizedPath: string; error?: string } {
|
||||||
|
try {
|
||||||
|
// 规范化路径
|
||||||
|
const normalizedPath = normalize(filePath)
|
||||||
|
|
||||||
|
// 检查路径长度
|
||||||
|
if (normalizedPath.length > 4096) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
normalizedPath,
|
||||||
|
error: 'Path too long (max 4096 characters)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否包含路径遍历字符
|
||||||
|
if (normalizedPath.includes('..') || normalizedPath.includes('~')) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
normalizedPath,
|
||||||
|
error: 'Path contains traversal characters'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否包含可疑的字符序列
|
||||||
|
const suspiciousPatterns = [
|
||||||
|
/\.\./, // 父目录
|
||||||
|
/~/, // 用户目录
|
||||||
|
/\$\{/, // 环境变量
|
||||||
|
/`/, // 命令执行
|
||||||
|
/\|/, // 管道符
|
||||||
|
/;/, // 命令分隔符
|
||||||
|
/&/, // 后台执行
|
||||||
|
/>/, // 输出重定向
|
||||||
|
/</, // 输入重定向
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pattern of suspiciousPatterns) {
|
||||||
|
if (pattern.test(normalizedPath)) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
normalizedPath,
|
||||||
|
error: `Path contains suspicious pattern: ${pattern}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析为绝对路径
|
||||||
|
const absolutePath = resolve(normalizedPath)
|
||||||
|
|
||||||
|
// 检查是否在允许的基础路径中
|
||||||
|
const isInAllowedPath = Array.from(this.allowedBasePaths).some(basePath => {
|
||||||
|
return absolutePath.startsWith(basePath)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isInAllowedPath) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
normalizedPath,
|
||||||
|
error: 'Path is outside allowed directories'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true, normalizedPath: absolutePath }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
normalizedPath: filePath,
|
||||||
|
error: `Path validation failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地检查文件是否存在
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @returns 文件是否存在
|
||||||
|
*/
|
||||||
|
public safeExists(filePath: string): boolean {
|
||||||
|
const validation = this.validateFilePath(filePath)
|
||||||
|
if (!validation.isValid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return existsSync(validation.normalizedPath)
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地读取文件
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @param options 读取选项
|
||||||
|
* @returns 读取结果
|
||||||
|
*/
|
||||||
|
public safeReadFile(
|
||||||
|
filePath: string,
|
||||||
|
options: {
|
||||||
|
encoding?: BufferEncoding;
|
||||||
|
maxFileSize?: number;
|
||||||
|
allowedExtensions?: string[];
|
||||||
|
checkFileExtension?: boolean;
|
||||||
|
} = {}
|
||||||
|
): { success: boolean; content?: string | Buffer; error?: string; stats?: any } {
|
||||||
|
const validation = this.validateFilePath(filePath)
|
||||||
|
if (!validation.isValid) {
|
||||||
|
return { success: false, error: validation.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalizedPath = validation.normalizedPath
|
||||||
|
|
||||||
|
// 检查文件扩展名(如果启用)
|
||||||
|
if (options.checkFileExtension !== false) {
|
||||||
|
const ext = extname(normalizedPath).toLowerCase()
|
||||||
|
const allowedExts = options.allowedExtensions ||
|
||||||
|
Array.from(this.allowedExtensions)
|
||||||
|
|
||||||
|
if (allowedExts.length > 0 && !allowedExts.includes(ext)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `File extension '${ext}' is not allowed`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if (!existsSync(normalizedPath)) {
|
||||||
|
return { success: false, error: 'File does not exist' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件信息
|
||||||
|
const stats = statSync(normalizedPath)
|
||||||
|
const maxSize = options.maxFileSize || this.maxFileSize
|
||||||
|
|
||||||
|
// 检查文件大小
|
||||||
|
if (stats.size > maxSize) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `File too large (${stats.size} bytes, max ${maxSize} bytes)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件类型
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
return { success: false, error: 'Path is not a file' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件权限
|
||||||
|
if ((stats.mode & parseInt('400', 8)) === 0) { // 检查读权限
|
||||||
|
return { success: false, error: 'No read permission' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件内容
|
||||||
|
const content = readFileSync(normalizedPath, {
|
||||||
|
encoding: options.encoding || 'utf8'
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
content,
|
||||||
|
stats: {
|
||||||
|
size: stats.size,
|
||||||
|
mtime: stats.mtime,
|
||||||
|
atime: stats.atime,
|
||||||
|
mode: stats.mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地写入文件
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @param content 文件内容
|
||||||
|
* @param options 写入选项
|
||||||
|
* @returns 写入结果
|
||||||
|
*/
|
||||||
|
public safeWriteFile(
|
||||||
|
filePath: string,
|
||||||
|
content: string | Buffer,
|
||||||
|
options: {
|
||||||
|
encoding?: BufferEncoding;
|
||||||
|
createDirectory?: boolean;
|
||||||
|
atomic?: boolean;
|
||||||
|
mode?: number;
|
||||||
|
allowedExtensions?: string[];
|
||||||
|
checkFileExtension?: boolean;
|
||||||
|
maxSize?: number;
|
||||||
|
} = {}
|
||||||
|
): { success: boolean; error?: string } {
|
||||||
|
const validation = this.validateFilePath(filePath)
|
||||||
|
if (!validation.isValid) {
|
||||||
|
return { success: false, error: validation.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalizedPath = validation.normalizedPath
|
||||||
|
|
||||||
|
// 检查文件扩展名(如果启用)
|
||||||
|
if (options.checkFileExtension !== false) {
|
||||||
|
const ext = extname(normalizedPath).toLowerCase()
|
||||||
|
const allowedExts = options.allowedExtensions ||
|
||||||
|
Array.from(this.allowedExtensions)
|
||||||
|
|
||||||
|
if (allowedExts.length > 0 && !allowedExts.includes(ext)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `File extension '${ext}' is not allowed`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查内容大小
|
||||||
|
const contentSize = typeof content === 'string' ?
|
||||||
|
Buffer.byteLength(content, options.encoding as BufferEncoding || 'utf8') :
|
||||||
|
content.length
|
||||||
|
|
||||||
|
const maxSize = options.maxSize || this.maxFileSize
|
||||||
|
if (contentSize > maxSize) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Content too large (${contentSize} bytes, max ${maxSize} bytes)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建目录(如果需要)
|
||||||
|
if (options.createDirectory) {
|
||||||
|
const dir = dirname(normalizedPath)
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true, mode: 0o755 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原子写入(如果启用)
|
||||||
|
if (options.atomic) {
|
||||||
|
const tempPath = `${normalizedPath}.tmp.${Date.now()}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 写入临时文件
|
||||||
|
writeFileSync(tempPath, content, {
|
||||||
|
encoding: options.encoding as BufferEncoding || 'utf8',
|
||||||
|
mode: options.mode || 0o644
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重命名为目标文件
|
||||||
|
renameSync(tempPath, normalizedPath)
|
||||||
|
} catch (renameError) {
|
||||||
|
// 清理临时文件
|
||||||
|
try {
|
||||||
|
if (existsSync(tempPath)) {
|
||||||
|
unlinkSync(tempPath)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略清理错误
|
||||||
|
}
|
||||||
|
throw renameError
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 直接写入
|
||||||
|
writeFileSync(normalizedPath, content, {
|
||||||
|
encoding: options.encoding as BufferEncoding || 'utf8',
|
||||||
|
mode: options.mode || 0o644
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to write file: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地删除文件
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @returns 删除结果
|
||||||
|
*/
|
||||||
|
public safeDeleteFile(filePath: string): { success: boolean; error?: string } {
|
||||||
|
const validation = this.validateFilePath(filePath)
|
||||||
|
if (!validation.isValid) {
|
||||||
|
return { success: false, error: validation.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalizedPath = validation.normalizedPath
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if (!existsSync(normalizedPath)) {
|
||||||
|
return { success: false, error: 'File does not exist' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件类型
|
||||||
|
const stats = statSync(normalizedPath)
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
return { success: false, error: 'Path is not a file' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查写权限
|
||||||
|
if ((stats.mode & parseInt('200', 8)) === 0) {
|
||||||
|
return { success: false, error: 'No write permission' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全删除
|
||||||
|
unlinkSync(normalizedPath)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to delete file: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地创建目录
|
||||||
|
* @param dirPath 目录路径
|
||||||
|
* @param mode 目录权限
|
||||||
|
* @returns 创建结果
|
||||||
|
*/
|
||||||
|
public safeCreateDirectory(dirPath: string, mode: number = 0o755): { success: boolean; error?: string } {
|
||||||
|
const validation = this.validateFilePath(dirPath)
|
||||||
|
if (!validation.isValid) {
|
||||||
|
return { success: false, error: validation.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalizedPath = validation.normalizedPath
|
||||||
|
|
||||||
|
if (existsSync(normalizedPath)) {
|
||||||
|
const stats = statSync(normalizedPath)
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
return { success: false, error: 'Path already exists and is not a directory' }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(normalizedPath, { recursive: true, mode })
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to create directory: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地获取文件信息
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @returns 文件信息
|
||||||
|
*/
|
||||||
|
public safeGetFileInfo(filePath: string): {
|
||||||
|
success: boolean;
|
||||||
|
stats?: {
|
||||||
|
size: number;
|
||||||
|
isFile: boolean;
|
||||||
|
isDirectory: boolean;
|
||||||
|
mode: number;
|
||||||
|
atime: Date;
|
||||||
|
mtime: Date;
|
||||||
|
ctime: Date;
|
||||||
|
};
|
||||||
|
error?: string
|
||||||
|
} {
|
||||||
|
const validation = this.validateFilePath(filePath)
|
||||||
|
if (!validation.isValid) {
|
||||||
|
return { success: false, error: validation.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalizedPath = validation.normalizedPath
|
||||||
|
|
||||||
|
if (!existsSync(normalizedPath)) {
|
||||||
|
return { success: false, error: 'File does not exist' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = statSync(normalizedPath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
stats: {
|
||||||
|
size: stats.size,
|
||||||
|
isFile: stats.isFile(),
|
||||||
|
isDirectory: stats.isDirectory(),
|
||||||
|
mode: stats.mode,
|
||||||
|
atime: stats.atime,
|
||||||
|
mtime: stats.mtime,
|
||||||
|
ctime: stats.ctime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to get file info: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加允许的基础路径
|
||||||
|
* @param basePath 基础路径
|
||||||
|
*/
|
||||||
|
public addAllowedBasePath(basePath: string): { success: boolean; error?: string } {
|
||||||
|
try {
|
||||||
|
const normalized = normalize(resolve(basePath))
|
||||||
|
|
||||||
|
// 验证路径是否存在
|
||||||
|
if (!existsSync(normalized)) {
|
||||||
|
return { success: false, error: 'Base path does not exist' }
|
||||||
|
}
|
||||||
|
|
||||||
|
this.allowedBasePaths.add(normalized)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to add base path: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置最大文件大小
|
||||||
|
* @param maxSize 最大文件大小(字节)
|
||||||
|
*/
|
||||||
|
public setMaxFileSize(maxSize: number): void {
|
||||||
|
this.maxFileSize = maxSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加允许的文件扩展名
|
||||||
|
* @param extensions 文件扩展名数组
|
||||||
|
*/
|
||||||
|
public addAllowedExtensions(extensions: string[]): void {
|
||||||
|
extensions.forEach(ext => {
|
||||||
|
if (!ext.startsWith('.')) {
|
||||||
|
ext = '.' + ext
|
||||||
|
}
|
||||||
|
this.allowedExtensions.add(ext.toLowerCase())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否在允许的基础路径中
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @returns 是否允许
|
||||||
|
*/
|
||||||
|
public isPathAllowed(filePath: string): boolean {
|
||||||
|
const validation = this.validateFilePath(filePath)
|
||||||
|
return validation.isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证文件名安全性
|
||||||
|
* @param filename 文件名
|
||||||
|
* @returns 验证结果
|
||||||
|
*/
|
||||||
|
public validateFileName(filename: string): { isValid: boolean; error?: string } {
|
||||||
|
// 检查文件名长度
|
||||||
|
if (filename.length === 0) {
|
||||||
|
return { isValid: false, error: 'Filename cannot be empty' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename.length > 255) {
|
||||||
|
return { isValid: false, error: 'Filename too long (max 255 characters)' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件名字符
|
||||||
|
const invalidChars = /[<>:"/\\|?*\x00-\x1F]/
|
||||||
|
if (invalidChars.test(filename)) {
|
||||||
|
return { isValid: false, error: 'Filename contains invalid characters' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查保留文件名
|
||||||
|
const reservedNames = [
|
||||||
|
'CON', 'PRN', 'AUX', 'NUL',
|
||||||
|
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||||||
|
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
|
||||||
|
]
|
||||||
|
|
||||||
|
const baseName = filename.split('.')[0].toUpperCase()
|
||||||
|
if (reservedNames.includes(baseName)) {
|
||||||
|
return { isValid: false, error: 'Filename is reserved' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否以点开头或结尾
|
||||||
|
if (filename.startsWith('.') || filename.endsWith('.')) {
|
||||||
|
return { isValid: false, error: 'Filename cannot start or end with a dot' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否以空格开头或结尾
|
||||||
|
if (filename.startsWith(' ') || filename.endsWith(' ')) {
|
||||||
|
return { isValid: false, error: 'Filename cannot start or end with spaces' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例实例
|
||||||
|
export const secureFileService = SecureFileService.getInstance()
|
||||||
566
test/secureFile.test.ts
Normal file
566
test/secureFile.test.ts
Normal file
@ -0,0 +1,566 @@
|
|||||||
|
import { describe, expect, test, beforeEach, afterEach } from '@jest/globals'
|
||||||
|
import { SecureFileService } from '../src/utils/secureFile'
|
||||||
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, rmdirSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
describe('SecureFileService', () => {
|
||||||
|
let secureFileService: SecureFileService
|
||||||
|
let testDir: string
|
||||||
|
let tempDir: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
secureFileService = SecureFileService.getInstance()
|
||||||
|
testDir = join(process.cwd(), 'test-temp')
|
||||||
|
tempDir = '/tmp/secure-file-test'
|
||||||
|
|
||||||
|
// Create test directories
|
||||||
|
if (!existsSync(testDir)) {
|
||||||
|
mkdirSync(testDir, { recursive: true })
|
||||||
|
}
|
||||||
|
if (!existsSync(tempDir)) {
|
||||||
|
mkdirSync(tempDir, { recursive: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up test files
|
||||||
|
const cleanupDir = (dir: string) => {
|
||||||
|
if (existsSync(dir)) {
|
||||||
|
const files = require('node:fs').readdirSync(dir)
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = join(dir, file)
|
||||||
|
if (require('node:fs').statSync(filePath).isDirectory()) {
|
||||||
|
cleanupDir(filePath)
|
||||||
|
rmdirSync(filePath)
|
||||||
|
} else {
|
||||||
|
unlinkSync(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupDir(testDir)
|
||||||
|
cleanupDir(tempDir)
|
||||||
|
|
||||||
|
try {
|
||||||
|
rmdirSync(testDir)
|
||||||
|
rmdirSync(tempDir)
|
||||||
|
} catch {
|
||||||
|
// Ignore errors if directories don't exist
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateFilePath', () => {
|
||||||
|
test('should validate valid file paths', () => {
|
||||||
|
const validPaths = [
|
||||||
|
join(testDir, 'test.txt'),
|
||||||
|
join(process.cwd(), 'test.js'),
|
||||||
|
join(tempDir, 'test.json'),
|
||||||
|
join(require('node:os').homedir(), '.testrc')
|
||||||
|
]
|
||||||
|
|
||||||
|
validPaths.forEach(path => {
|
||||||
|
const result = secureFileService.validateFilePath(path)
|
||||||
|
expect(result.isValid).toBe(true)
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject paths with traversal characters', () => {
|
||||||
|
// Test with absolute paths that would traverse outside allowed directories
|
||||||
|
const invalidPaths = [
|
||||||
|
'/etc/passwd',
|
||||||
|
'/usr/bin/ls',
|
||||||
|
'/root/.ssh/id_rsa'
|
||||||
|
]
|
||||||
|
|
||||||
|
invalidPaths.forEach(path => {
|
||||||
|
const result = secureFileService.validateFilePath(path)
|
||||||
|
expect(result.isValid).toBe(false)
|
||||||
|
expect(result.error).toContain('outside allowed directories')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject paths with tilde character', () => {
|
||||||
|
const result = secureFileService.validateFilePath('~/some/file')
|
||||||
|
expect(result.isValid).toBe(false)
|
||||||
|
expect(result.error).toContain('traversal')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject paths with suspicious patterns', () => {
|
||||||
|
const suspiciousPaths = [
|
||||||
|
join(testDir, 'test${HOME}.txt'),
|
||||||
|
join(testDir, 'test`command`.txt'),
|
||||||
|
join(testDir, 'test|pipe.txt'),
|
||||||
|
join(testDir, 'test;command.txt'),
|
||||||
|
join(testDir, 'test&background.txt'),
|
||||||
|
join(testDir, 'test>redirect.txt'),
|
||||||
|
join(testDir, 'test<input.txt')
|
||||||
|
]
|
||||||
|
|
||||||
|
suspiciousPaths.forEach(path => {
|
||||||
|
const result = secureFileService.validateFilePath(path)
|
||||||
|
expect(result.isValid).toBe(false)
|
||||||
|
expect(result.error).toContain('suspicious pattern')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject paths outside allowed directories', () => {
|
||||||
|
const restrictedPaths = [
|
||||||
|
'/etc/passwd',
|
||||||
|
'/usr/bin/ls',
|
||||||
|
'/root/.ssh/id_rsa',
|
||||||
|
'/var/log/syslog'
|
||||||
|
]
|
||||||
|
|
||||||
|
restrictedPaths.forEach(path => {
|
||||||
|
const result = secureFileService.validateFilePath(path)
|
||||||
|
expect(result.isValid).toBe(false)
|
||||||
|
expect(result.error).toContain('outside allowed directories')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject paths that are too long', () => {
|
||||||
|
const longPath = 'a'.repeat(5000)
|
||||||
|
const result = secureFileService.validateFilePath(longPath)
|
||||||
|
expect(result.isValid).toBe(false)
|
||||||
|
expect(result.error).toContain('Path too long')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateFileName', () => {
|
||||||
|
test('should validate valid filenames', () => {
|
||||||
|
const validFilenames = [
|
||||||
|
'test.txt',
|
||||||
|
'my-file.js',
|
||||||
|
'data.json',
|
||||||
|
'config.yml',
|
||||||
|
'script.sh',
|
||||||
|
'file.with.multiple.dots',
|
||||||
|
'UPPERCASE.TXT',
|
||||||
|
'mixedCase.Js'
|
||||||
|
]
|
||||||
|
|
||||||
|
validFilenames.forEach(filename => {
|
||||||
|
const result = secureFileService.validateFileName(filename)
|
||||||
|
expect(result.isValid).toBe(true)
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject invalid filenames', () => {
|
||||||
|
const invalidFilenames = [
|
||||||
|
'', // empty
|
||||||
|
'a'.repeat(300), // too long
|
||||||
|
'test<file>.txt', // contains <
|
||||||
|
'test>file.txt', // contains >
|
||||||
|
'test:file.txt', // contains :
|
||||||
|
'test"file".txt', // contains "
|
||||||
|
'test/file.txt', // contains /
|
||||||
|
'test\\file.txt', // contains \
|
||||||
|
'test|file.txt', // contains |
|
||||||
|
'test?file.txt', // contains ?
|
||||||
|
'test*file.txt', // contains *
|
||||||
|
'test\x00file.txt', // contains null character
|
||||||
|
'CON', // reserved name
|
||||||
|
'PRN.txt', // reserved name
|
||||||
|
'AUX.js', // reserved name
|
||||||
|
'NUL.json', // reserved name
|
||||||
|
'COM1.bat', // reserved name
|
||||||
|
'LPT1.sh', // reserved name
|
||||||
|
'.hidden', // starts with dot
|
||||||
|
'file.', // ends with dot
|
||||||
|
' file.txt', // starts with space
|
||||||
|
'file.txt ' // ends with space
|
||||||
|
]
|
||||||
|
|
||||||
|
invalidFilenames.forEach(filename => {
|
||||||
|
const result = secureFileService.validateFileName(filename)
|
||||||
|
expect(result.isValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('safeExists', () => {
|
||||||
|
test('should return true for existing files in allowed directories', () => {
|
||||||
|
const testFile = join(testDir, 'existing.txt')
|
||||||
|
writeFileSync(testFile, 'test content')
|
||||||
|
|
||||||
|
const result = secureFileService.safeExists(testFile)
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false for non-existing files', () => {
|
||||||
|
const nonExistentFile = join(testDir, 'nonexistent.txt')
|
||||||
|
const result = secureFileService.safeExists(nonExistentFile)
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false for invalid paths', () => {
|
||||||
|
const invalidPath = join(testDir, '..', 'etc', 'passwd')
|
||||||
|
const result = secureFileService.safeExists(invalidPath)
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('safeReadFile', () => {
|
||||||
|
test('should read existing files successfully', () => {
|
||||||
|
const testFile = join(testDir, 'test.txt')
|
||||||
|
const content = 'Hello, World!'
|
||||||
|
writeFileSync(testFile, content)
|
||||||
|
|
||||||
|
const result = secureFileService.safeReadFile(testFile)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.content).toBe(content)
|
||||||
|
expect(result.stats).toBeDefined()
|
||||||
|
expect(result.stats?.size).toBe(content.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject non-existing files', () => {
|
||||||
|
const nonExistentFile = join(testDir, 'nonexistent.txt')
|
||||||
|
const result = secureFileService.safeReadFile(nonExistentFile)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('File does not exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject invalid paths', () => {
|
||||||
|
// Create a directory that is definitely not allowed
|
||||||
|
const invalidPath = '/root/secure-test.txt'
|
||||||
|
const result = secureFileService.safeReadFile(invalidPath)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('outside allowed directories')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject files with disallowed extensions', () => {
|
||||||
|
const testFile = join(testDir, 'test.exe')
|
||||||
|
writeFileSync(testFile, 'executable content')
|
||||||
|
|
||||||
|
const result = secureFileService.safeReadFile(testFile)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('File extension \'.exe\' is not allowed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should allow files with custom allowed extensions', () => {
|
||||||
|
const testFile = join(testDir, 'test.custom')
|
||||||
|
writeFileSync(testFile, 'custom content')
|
||||||
|
|
||||||
|
const result = secureFileService.safeReadFile(testFile, {
|
||||||
|
allowedExtensions: ['.custom']
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.content).toBe('custom content')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject files that are too large', () => {
|
||||||
|
const testFile = join(testDir, 'large.txt')
|
||||||
|
const largeContent = 'a'.repeat(1024 * 1024) // 1MB
|
||||||
|
writeFileSync(testFile, largeContent)
|
||||||
|
|
||||||
|
const result = secureFileService.safeReadFile(testFile, {
|
||||||
|
maxFileSize: 512 * 1024 // 512KB
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('File too large')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle directories', () => {
|
||||||
|
const result = secureFileService.safeReadFile(testDir, { checkFileExtension: false })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('Path is not a file')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('safeWriteFile', () => {
|
||||||
|
test('should write files successfully', () => {
|
||||||
|
const testFile = join(testDir, 'output.txt')
|
||||||
|
const content = 'Hello, World!'
|
||||||
|
|
||||||
|
const result = secureFileService.safeWriteFile(testFile, content)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
|
||||||
|
// Verify file was created
|
||||||
|
expect(existsSync(testFile)).toBe(true)
|
||||||
|
expect(readFileSync(testFile, 'utf8')).toBe(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject invalid paths', () => {
|
||||||
|
const invalidPath = '/root/secure-test.txt'
|
||||||
|
const result = secureFileService.safeWriteFile(invalidPath, 'malicious')
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('outside allowed directories')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject files with disallowed extensions', () => {
|
||||||
|
const testFile = join(testDir, 'test.exe')
|
||||||
|
const result = secureFileService.safeWriteFile(testFile, 'executable content')
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('File extension \'.exe\' is not allowed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject content that is too large', () => {
|
||||||
|
const testFile = join(testDir, 'large.txt')
|
||||||
|
const largeContent = 'a'.repeat(1024 * 1024) // 1MB
|
||||||
|
|
||||||
|
const result = secureFileService.safeWriteFile(testFile, largeContent, {
|
||||||
|
maxSize: 512 * 1024 // 512KB
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('Content too large')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should create directories when requested', () => {
|
||||||
|
const nestedFile = join(testDir, 'nested', 'subdir', 'file.txt')
|
||||||
|
const content = 'nested content'
|
||||||
|
|
||||||
|
const result = secureFileService.safeWriteFile(nestedFile, content, {
|
||||||
|
createDirectory: true
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(existsSync(nestedFile)).toBe(true)
|
||||||
|
expect(readFileSync(nestedFile, 'utf8')).toBe(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should perform atomic writes when requested', () => {
|
||||||
|
const testFile = join(testDir, 'atomic.txt')
|
||||||
|
const content = 'atomic content'
|
||||||
|
|
||||||
|
const result = secureFileService.safeWriteFile(testFile, content, {
|
||||||
|
atomic: true
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(existsSync(testFile)).toBe(true)
|
||||||
|
expect(readFileSync(testFile, 'utf8')).toBe(content)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('safeDeleteFile', () => {
|
||||||
|
test('should delete existing files successfully', () => {
|
||||||
|
const testFile = join(testDir, 'to-delete.txt')
|
||||||
|
writeFileSync(testFile, 'content to delete')
|
||||||
|
|
||||||
|
const result = secureFileService.safeDeleteFile(testFile)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(existsSync(testFile)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject non-existing files', () => {
|
||||||
|
const nonExistentFile = join(testDir, 'nonexistent.txt')
|
||||||
|
const result = secureFileService.safeDeleteFile(nonExistentFile)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('File does not exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject invalid paths', () => {
|
||||||
|
const invalidPath = '/root/secure-test.txt'
|
||||||
|
const result = secureFileService.safeDeleteFile(invalidPath)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('outside allowed directories')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle directories', () => {
|
||||||
|
const result = secureFileService.safeDeleteFile(testDir)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('Path is not a file')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('safeCreateDirectory', () => {
|
||||||
|
test('should create directories successfully', () => {
|
||||||
|
const newDir = join(testDir, 'new-dir')
|
||||||
|
|
||||||
|
const result = secureFileService.safeCreateDirectory(newDir)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(existsSync(newDir)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle existing directories', () => {
|
||||||
|
const result = secureFileService.safeCreateDirectory(testDir)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject invalid paths', () => {
|
||||||
|
const invalidPath = '/root/secure-test'
|
||||||
|
const result = secureFileService.safeCreateDirectory(invalidPath)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('outside allowed directories')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle existing files', () => {
|
||||||
|
const existingFile = join(testDir, 'existing.txt')
|
||||||
|
writeFileSync(existingFile, 'content')
|
||||||
|
|
||||||
|
const result = secureFileService.safeCreateDirectory(existingFile)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('Path already exists and is not a directory')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('safeGetFileInfo', () => {
|
||||||
|
test('should get file info successfully', () => {
|
||||||
|
const testFile = join(testDir, 'info.txt')
|
||||||
|
const content = 'file info test'
|
||||||
|
writeFileSync(testFile, content)
|
||||||
|
|
||||||
|
const result = secureFileService.safeGetFileInfo(testFile)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.stats).toBeDefined()
|
||||||
|
expect(result.stats?.isFile).toBe(true)
|
||||||
|
expect(result.stats?.size).toBe(content.length)
|
||||||
|
expect(result.stats?.isDirectory).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should get directory info successfully', () => {
|
||||||
|
const result = secureFileService.safeGetFileInfo(testDir)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.stats).toBeDefined()
|
||||||
|
expect(result.stats?.isFile).toBe(false)
|
||||||
|
expect(result.stats?.isDirectory).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject non-existing paths', () => {
|
||||||
|
const nonExistentPath = join(testDir, 'nonexistent.txt')
|
||||||
|
const result = secureFileService.safeGetFileInfo(nonExistentPath)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('File does not exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject invalid paths', () => {
|
||||||
|
const invalidPath = '/root/secure-test.txt'
|
||||||
|
const result = secureFileService.safeGetFileInfo(invalidPath)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('outside allowed directories')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('configuration methods', () => {
|
||||||
|
test('should add allowed base paths', () => {
|
||||||
|
const customDir = join(testDir, 'custom')
|
||||||
|
mkdirSync(customDir, { recursive: true })
|
||||||
|
|
||||||
|
const result = secureFileService.addAllowedBasePath(customDir)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
|
||||||
|
// Test that the new path is now allowed
|
||||||
|
const testFile = join(customDir, 'test.txt')
|
||||||
|
const validation = secureFileService.validateFilePath(testFile)
|
||||||
|
expect(validation.isValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should reject non-existing base paths', () => {
|
||||||
|
const nonExistentDir = join(testDir, 'nonexistent')
|
||||||
|
const result = secureFileService.addAllowedBasePath(nonExistentDir)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('Base path does not exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should set max file size', () => {
|
||||||
|
secureFileService.setMaxFileSize(2048)
|
||||||
|
|
||||||
|
const testFile = join(testDir, 'size-test.txt')
|
||||||
|
const largeContent = 'a'.repeat(3000) // 3KB
|
||||||
|
writeFileSync(testFile, largeContent)
|
||||||
|
|
||||||
|
const result = secureFileService.safeReadFile(testFile)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('File too large')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should add allowed extensions', () => {
|
||||||
|
secureFileService.addAllowedExtensions(['.custom', '.special'])
|
||||||
|
|
||||||
|
const testFile = join(testDir, 'test.custom')
|
||||||
|
writeFileSync(testFile, 'custom content')
|
||||||
|
|
||||||
|
const result = secureFileService.safeReadFile(testFile)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.content).toBe('custom content')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should check if path is allowed', () => {
|
||||||
|
const allowedPath = join(testDir, 'allowed.txt')
|
||||||
|
const disallowedPath = '/etc/passwd'
|
||||||
|
|
||||||
|
expect(secureFileService.isPathAllowed(allowedPath)).toBe(true)
|
||||||
|
expect(secureFileService.isPathAllowed(disallowedPath)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('singleton pattern', () => {
|
||||||
|
test('should return the same instance', () => {
|
||||||
|
const instance1 = SecureFileService.getInstance()
|
||||||
|
const instance2 = SecureFileService.getInstance()
|
||||||
|
const instance3 = secureFileService
|
||||||
|
|
||||||
|
expect(instance1).toBe(instance2)
|
||||||
|
expect(instance2).toBe(instance3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should maintain configuration across instances', () => {
|
||||||
|
const instance1 = SecureFileService.getInstance()
|
||||||
|
const instance2 = SecureFileService.getInstance()
|
||||||
|
|
||||||
|
instance1.setMaxFileSize(2048)
|
||||||
|
instance2.addAllowedExtensions(['.test'])
|
||||||
|
|
||||||
|
const testFile = join(testDir, 'test.test')
|
||||||
|
writeFileSync(testFile, 'test')
|
||||||
|
|
||||||
|
const result = instance1.safeReadFile(testFile)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
test('should handle permission errors gracefully', () => {
|
||||||
|
// This test simulates permission errors by trying to read a directory as a file
|
||||||
|
const result = secureFileService.safeReadFile(testDir, {
|
||||||
|
checkFileExtension: false,
|
||||||
|
maxFileSize: 10 * 1024 * 1024 // Use default size
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('Path is not a file')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle file system errors gracefully', () => {
|
||||||
|
// Test with a path that contains invalid characters for the file system
|
||||||
|
const invalidPath = join(testDir, 'invalid\0path.txt')
|
||||||
|
const result = secureFileService.validateFilePath(invalidPath)
|
||||||
|
// The validation might handle this differently, but it should still fail
|
||||||
|
if (!result.isValid) {
|
||||||
|
expect(result.error).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
test('should handle empty files', () => {
|
||||||
|
const testFile = join(testDir, 'empty.txt')
|
||||||
|
writeFileSync(testFile, '')
|
||||||
|
|
||||||
|
const result = secureFileService.safeReadFile(testFile)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.content).toBe('')
|
||||||
|
expect(result.stats?.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle files with special characters in name', () => {
|
||||||
|
const testFile = join(testDir, 'file-with-hyphens_and_underscores.txt')
|
||||||
|
const content = 'special characters test'
|
||||||
|
writeFileSync(testFile, content)
|
||||||
|
|
||||||
|
const result = secureFileService.safeReadFile(testFile)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.content).toBe(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle different encodings', () => {
|
||||||
|
const testFile = join(testDir, 'utf8.txt')
|
||||||
|
const content = 'Hello 世界 🌍'
|
||||||
|
writeFileSync(testFile, content, 'utf8')
|
||||||
|
|
||||||
|
const result = secureFileService.safeReadFile(testFile, { encoding: 'utf8' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.content).toBe(content)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user