fix: 优化本地路径构造

This commit is contained in:
chaoszhu
2026-08-29 10:19:31 +08:00
parent 3045682f15
commit 4f7a8462ab
6 changed files with 237 additions and 43 deletions
@@ -0,0 +1,59 @@
{
"pins" : [
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 2
}
+10 -10
View File
@@ -386,10 +386,10 @@ packages:
dependency: "direct main"
description:
name: intl
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.20.3"
version: "0.20.2"
isolate_contactor:
dependency: transitive
description:
@@ -490,10 +490,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.12.20"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
@@ -506,10 +506,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.0"
version: "1.18.0"
mime:
dependency: transitive
description:
@@ -886,10 +886,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.12"
version: "0.7.11"
typed_data:
dependency: transitive
description:
@@ -1016,10 +1016,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2"
version: "2.2.0"
vm_service:
dependency: transitive
description:
+72 -32
View File
@@ -7,6 +7,12 @@ import { v4 as uuidv4 } from 'uuid'
import { sftpCacheDir } from '../config/index.js'
import { createSecureWs } from '../utils/ws-tool.js'
import { HostListDB, FavoriteSftpDB } from '../utils/db-class.js'
import {
assertPathInside,
resolvePathInside,
validateRemoteFileName,
validateTaskId
} from '../utils/sftp-cache-path.js'
import { getConnectionOptions, handleProxyAndJumpHostConnection } from './terminal.js'
const hostListDB = new HostListDB().getInstance()
const favoriteSftpDB = new FavoriteSftpDB().getInstance()
@@ -18,6 +24,22 @@ function shellEscape(s) {
return "'" + s.replace(/'/g, "'\\''") + "'"
}
function removeCacheFileSync(filePath) {
if (!filePath) return
const safeFilePath = assertPathInside(sftpCacheDir, filePath)
if (fs.existsSync(safeFilePath)) fs.unlinkSync(safeFilePath)
}
async function removeCacheFile(filePath) {
if (!filePath) return
const safeFilePath = assertPathInside(sftpCacheDir, filePath)
try {
await fs.unlink(safeFilePath)
} catch (error) {
if (error.code !== 'ENOENT') throw error
}
}
/**
* 将 Buffer 解码为字符串
* @param {Buffer} buffer - 要解码的 Buffer
@@ -581,17 +603,22 @@ const listenAction = (sftpClient, socket) => {
})
// 下载功能
socket.on('download_request', async ({ dirPath, targets }) => {
socket.on('download_request', async ({ dirPath, targets } = {}) => {
let remoteTarPath = null // 跟踪远程临时文件路径
let taskId = null // 声明在外层以便错误处理时访问
try {
if (!targets || targets.length === 0) {
if (typeof dirPath !== 'string' || !Array.isArray(targets) || targets.length === 0) {
throw new Error('未选择要下载的文件')
}
taskId = Date.now() + '-' + Math.random().toString(36).slice(2)
const taskDir = rawPath.join(sftpCacheDir, taskId)
await fs.ensureDir(taskDir)
targets.forEach(target => {
if (!target || typeof target !== 'object') throw new Error('下载目标非法')
validateRemoteFileName(target.name)
})
taskId = uuidv4()
const taskDir = resolvePathInside(sftpCacheDir, taskId)
await fs.ensureDir(taskDir, { mode: 0o700 })
const abortController = new AbortController()
downloadTasks.set(taskId, {
@@ -612,7 +639,7 @@ const listenAction = (sftpClient, socket) => {
// 文件夹:先在远端打包
const tarFileName = `${ target.name }.tar.gz`
remoteTarPath = `/tmp/${ taskId }.tar.gz`
const localTarPath = rawPath.join(taskDir, tarFileName)
const localTarPath = resolvePathInside(taskDir, tarFileName)
// 保存远程文件路径到任务中
downloadTasks.get(taskId).remoteTarPath = remoteTarPath
@@ -648,7 +675,7 @@ const listenAction = (sftpClient, socket) => {
socket.emit('download_ready', { taskId, fileName: tarFileName })
} else {
// 单文件:直接下载
const localFilePath = rawPath.join(taskDir, target.name)
const localFilePath = resolvePathInside(taskDir, target.name)
// 获取文件大小
const statResult = await sftpClient.stat(srcPath)
@@ -662,7 +689,7 @@ const listenAction = (sftpClient, socket) => {
// 多文件逻辑:打包所有选中的文件/文件夹
const archiveName = `selected-files-${ Date.now() }.tar.gz`
remoteTarPath = `/tmp/${ taskId }.tar.gz`
const localTarPath = rawPath.join(taskDir, archiveName)
const localTarPath = resolvePathInside(taskDir, archiveName)
// 保存远程文件路径到任务中
downloadTasks.get(taskId).remoteTarPath = remoteTarPath
@@ -991,9 +1018,9 @@ const listenAction = (sftpClient, socket) => {
}
// 生成缓存文件名(使用UUID避免猜测和冲突)
const fileName = rawPath.basename(filePath)
const cacheFileName = `${ uuidv4() }_${ fileName }`
const localImagePath = rawPath.join(sftpCacheDir, cacheFileName)
const fileName = rawPath.posix.basename(filePath)
const cacheFileName = `${ uuidv4() }.${ ext }`
const localImagePath = resolvePathInside(sftpCacheDir, cacheFileName)
logger.info(`开始下载图片到缓存: ${ filePath } -> ${ localImagePath }`)
@@ -1055,17 +1082,20 @@ const listenAction = (sftpClient, socket) => {
// -------- 上传相关功能 --------
// 开始上传
socket.on('upload_start', async ({ taskId, fileName, fileSize, targetPath }) => {
socket.on('upload_start', async ({ taskId, fileName, fileSize, targetPath } = {}) => {
try {
logger.info(`收到上传请求: ${ fileName }, 大小: ${ (fileSize / 1024 / 1024 / 1024).toFixed(2) }GB`)
if (!taskId || !fileName || !fileSize || !targetPath) {
validateTaskId(taskId)
validateRemoteFileName(fileName)
if (!Number.isSafeInteger(fileSize) || fileSize <= 0 || typeof targetPath !== 'string' || !targetPath) {
throw new Error('上传参数不完整')
}
if (uploadTasks.has(taskId)) throw new Error('上传任务已存在')
// 创建临时文件路径(清理文件名中的特殊字符)
const safeFileName = fileName.replace(/[<>:"|?*]/g, '_')
const tempFilePath = rawPath.join(sftpCacheDir, `temp_${ taskId }_${ safeFileName }`)
// 本地缓存名完全由服务端生成,客户端参数不得参与本地路径构造
fs.ensureDirSync(sftpCacheDir, { mode: 0o700 })
const tempFilePath = resolvePathInside(sftpCacheDir, `.upload-${ uuidv4() }.part`)
// 创建上传任务
const uploadTask = {
taskId,
@@ -1093,8 +1123,9 @@ const listenAction = (sftpClient, socket) => {
})
// 上传文件分片
socket.on('upload_chunk', async ({ taskId, chunkIndex, chunkData, totalChunks, isLastChunk }) => {
socket.on('upload_chunk', async ({ taskId, chunkIndex, chunkData, totalChunks, isLastChunk } = {}) => {
try {
validateTaskId(taskId)
const task = uploadTasks.get(taskId)
if (!task) {
@@ -1110,7 +1141,11 @@ const listenAction = (sftpClient, socket) => {
// 确保缓存目录存在
fs.ensureDirSync(sftpCacheDir)
task.writeStream = fs.createWriteStream(task.tempFilePath)
const safeTempFilePath = assertPathInside(sftpCacheDir, task.tempFilePath)
task.writeStream = fs.createWriteStream(safeTempFilePath, {
flags: 'wx',
mode: 0o600
})
task.totalChunks = totalChunks
// 处理写入流错误(将错误标记到任务中)
@@ -1196,6 +1231,7 @@ const listenAction = (sftpClient, socket) => {
async function completeUpload(task) {
try {
logger.info(`文件接收完成,准备传输: ${ task.fileName }`)
const safeTempFilePath = assertPathInside(sftpCacheDir, task.tempFilePath)
// 关闭写入流
if (task.writeStream) {
@@ -1209,7 +1245,7 @@ const listenAction = (sftpClient, socket) => {
}
// 验证文件大小
const stats = fs.statSync(task.tempFilePath)
const stats = fs.statSync(safeTempFilePath)
if (stats.size !== task.fileSize) {
throw new Error(`文件大小不匹配: 期望 ${ task.fileSize }, 实际 ${ stats.size }`)
}
@@ -1235,7 +1271,7 @@ const listenAction = (sftpClient, socket) => {
let lastSftpUpdateTime = sftpStartTime
let lastSftpUploadedSize = 0
await sftpClient.fastPut(task.tempFilePath, task.targetPath, {
await sftpClient.fastPut(safeTempFilePath, task.targetPath, {
step: (transferredBytes) => {
const now = Date.now()
const sftpProgress = Math.min((transferredBytes / task.fileSize) * 100, 100)
@@ -1283,9 +1319,9 @@ const listenAction = (sftpClient, socket) => {
})
} finally {
// 确保清理临时文件
if (task.tempFilePath && fs.existsSync(task.tempFilePath)) {
if (task.tempFilePath) {
try {
fs.unlinkSync(task.tempFilePath)
removeCacheFileSync(task.tempFilePath)
logger.info(`已清理临时文件: ${ task.tempFilePath }`)
} catch (cleanupErr) {
logger.warn('清理临时文件失败:', cleanupErr.message)
@@ -1303,7 +1339,7 @@ const listenAction = (sftpClient, socket) => {
}
// 取消上传
socket.on('upload_cancel', ({ taskId }) => {
socket.on('upload_cancel', ({ taskId } = {}) => {
const task = uploadTasks.get(taskId)
if (task) {
task.abortController.abort()
@@ -1312,9 +1348,9 @@ const listenAction = (sftpClient, socket) => {
task.writeStream.destroy()
}
// 清理临时文件
if (task.tempFilePath && fs.existsSync(task.tempFilePath)) {
if (task.tempFilePath) {
try {
fs.unlinkSync(task.tempFilePath)
removeCacheFileSync(task.tempFilePath)
logger.info(`取消上传,已清理临时文件: ${ task.tempFilePath }`)
} catch (cleanupErr) {
logger.warn('清理临时文件失败:', cleanupErr.message)
@@ -1376,9 +1412,9 @@ const listenAction = (sftpClient, socket) => {
task.writeStream.destroy()
}
// 清理临时文件
if (task.tempFilePath && fs.existsSync(task.tempFilePath)) {
if (task.tempFilePath) {
try {
fs.unlinkSync(task.tempFilePath)
removeCacheFileSync(task.tempFilePath)
logger.info(`连接断开,已清理临时文件: ${ task.tempFilePath }`)
} catch (cleanupErr) {
logger.warn('清理临时文件失败:', cleanupErr.message)
@@ -1423,9 +1459,9 @@ const listenAction = (sftpClient, socket) => {
task.writeStream.destroy()
}
// 清理临时文件
if (task.tempFilePath && fs.existsSync(task.tempFilePath)) {
if (task.tempFilePath) {
try {
fs.unlinkSync(task.tempFilePath)
removeCacheFileSync(task.tempFilePath)
logger.info(`清理超时任务临时文件: ${ task.tempFilePath }`)
} catch (cleanupErr) {
logger.warn('清理临时文件失败:', cleanupErr.message)
@@ -1481,7 +1517,7 @@ const listenAction = (sftpClient, socket) => {
// 错误处理函数
const handleError = (err) => {
cleanup()
fs.unlink(localPath).catch(() => {})
removeCacheFile(localPath).catch(() => {})
reject(err)
}
@@ -1528,12 +1564,16 @@ const listenAction = (sftpClient, socket) => {
try {
readStream = sftpClient.createReadStream(remotePath)
writeStream = fs.createWriteStream(localPath)
const safeLocalPath = assertPathInside(sftpCacheDir, localPath)
writeStream = fs.createWriteStream(safeLocalPath, {
flags: 'wx',
mode: 0o600
})
readStream.on('data', (chunk) => {
if (abortController.signal.aborted) {
cleanup()
fs.unlink(localPath).catch(() => {}) // 删除部分下载的文件
removeCacheFile(localPath).catch(() => {}) // 删除部分下载的文件
reject(new Error('下载已取消'))
return
}
+54
View File
@@ -0,0 +1,54 @@
import path from 'node:path'
const TASK_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/
function assertPathInside(rootPath, candidatePath, { allowRoot = false } = {}) {
const resolvedRoot = path.resolve(rootPath)
const resolvedCandidate = path.resolve(candidatePath)
const relativePath = path.relative(resolvedRoot, resolvedCandidate)
const escapesRoot = relativePath === '..' ||
relativePath.startsWith(`..${ path.sep }`) ||
path.isAbsolute(relativePath)
if (escapesRoot || (!allowRoot && relativePath === '')) {
throw new Error('缓存文件路径非法')
}
return resolvedCandidate
}
function resolvePathInside(rootPath, ...pathSegments) {
const candidatePath = path.resolve(rootPath, ...pathSegments)
return assertPathInside(rootPath, candidatePath)
}
function validateTaskId(taskId) {
if (typeof taskId !== 'string' || !TASK_ID_PATTERN.test(taskId)) {
throw new Error('上传任务ID非法')
}
return taskId
}
function validateRemoteFileName(fileName) {
if (
typeof fileName !== 'string' ||
fileName.length === 0 ||
fileName.length > 255 ||
fileName === '.' ||
fileName === '..' ||
fileName.includes('/') ||
fileName.includes('\\') ||
CONTROL_CHARACTER_PATTERN.test(fileName)
) {
throw new Error('文件名非法')
}
return fileName
}
export {
assertPathInside,
resolvePathInside,
validateTaskId,
validateRemoteFileName
}
+2 -1
View File
@@ -10,7 +10,8 @@
"start": "node ./index.js",
"lint": "eslint . --ext .js,.vue",
"lint:fix": "eslint . --ext .js,.jsx,.cjs,.mjs --fix",
"test": "node test/test-rest-api-auth.js && node test/test-ws-comprehensive.js",
"test": "node test/test-sftp-cache-path.js && node test/test-rest-api-auth.js && node test/test-ws-comprehensive.js",
"test:security": "node test/test-sftp-cache-path.js",
"test:api": "node test/test-rest-api-auth.js",
"test:ws": "node test/test-ws-comprehensive.js",
"test:mobile": "node test/test-mobile-crypto.js && node test/test-mobile-ssh-payload.js",
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict'
import path from 'node:path'
import {
assertPathInside,
resolvePathInside,
validateRemoteFileName,
validateTaskId
} from '../app/utils/sftp-cache-path.js'
const cacheRoot = path.resolve('/tmp/easynode-sftp-cache-test')
assert.equal(validateTaskId('1720000000000-abc_123'), '1720000000000-abc_123')
for (const taskId of ['', '../task', '../../../../utils/audit', 'task/name', 'task.name', 'a'.repeat(129)]) {
assert.throws(() => validateTaskId(taskId), /上传任务ID非法/)
}
assert.equal(validateRemoteFileName('report 2026.tar.gz'), 'report 2026.tar.gz')
for (const fileName of ['', '.', '..', '../audit.js', 'folder/file.txt', 'folder\\file.txt', 'bad\nname']) {
assert.throws(() => validateRemoteFileName(fileName), /文件名非法/)
}
const safePath = resolvePathInside(cacheRoot, 'task-id', 'report.txt')
assert.equal(safePath, path.join(cacheRoot, 'task-id', 'report.txt'))
assert.equal(assertPathInside(cacheRoot, safePath), safePath)
for (const pathSegments of [
['../outside.txt'],
['temp_../../../../utils/audit.js'],
['/etc/passwd']
]) {
assert.throws(() => resolvePathInside(cacheRoot, ...pathSegments), /缓存文件路径非法/)
}
assert.throws(
() => assertPathInside(cacheRoot, `${ cacheRoot }-other/file.txt`),
/缓存文件路径非法/
)
assert.throws(() => assertPathInside(cacheRoot, cacheRoot), /缓存文件路径非法/)
console.log('SFTP 缓存路径安全测试通过')