137 lines
4.4 KiB
TypeScript
137 lines
4.4 KiB
TypeScript
/** @jest-environment node */
|
|
|
|
import path from 'path'
|
|
import os from 'os'
|
|
import {
|
|
mkdtemp,
|
|
mkdir,
|
|
readFile,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from 'fs/promises'
|
|
import {
|
|
getUploadedFile,
|
|
saveUploadSession,
|
|
uploadSessions,
|
|
type UploadSession,
|
|
} from '@/lib/upload-sessions'
|
|
|
|
function createSession(fileName: string, fileSize: number): UploadSession {
|
|
return {
|
|
fileName,
|
|
fileSize,
|
|
totalChunks: 1,
|
|
chunks: new Map(),
|
|
createdAt: Date.now(),
|
|
userId: 'user-1',
|
|
}
|
|
}
|
|
|
|
describe('upload-sessions 最终落盘安全', () => {
|
|
let uploadRoot = ''
|
|
const previousUploadDir = process.env.UPLOAD_DIR
|
|
|
|
beforeEach(async () => {
|
|
uploadSessions.clear()
|
|
uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'upload-session-security-'))
|
|
process.env.UPLOAD_DIR = uploadRoot
|
|
})
|
|
|
|
afterEach(async () => {
|
|
uploadSessions.clear()
|
|
if (uploadRoot) {
|
|
await rm(uploadRoot, { recursive: true, force: true })
|
|
}
|
|
if (previousUploadDir === undefined) {
|
|
delete process.env.UPLOAD_DIR
|
|
} else {
|
|
process.env.UPLOAD_DIR = previousUploadDir
|
|
}
|
|
})
|
|
|
|
async function writeSingleChunk(uploadId: string, content: Buffer) {
|
|
const sessionDir = path.join(uploadRoot, 'temp', uploadId)
|
|
await mkdir(sessionDir, { recursive: true })
|
|
await writeFile(path.join(sessionDir, 'chunk-0'), content)
|
|
}
|
|
|
|
test('合法会话只能在 assembled 目录生成普通文件', async () => {
|
|
const uploadId = 'upload-safe-file'
|
|
const content = Buffer.from('safe-content')
|
|
await writeSingleChunk(uploadId, content)
|
|
await saveUploadSession(uploadId, createSession('中文源文件.psd', content.length))
|
|
|
|
const outputPath = await getUploadedFile(uploadId)
|
|
|
|
expect(outputPath).toBe(
|
|
path.join(uploadRoot, 'assembled', `${uploadId}-中文源文件.psd`),
|
|
)
|
|
await expect(readFile(outputPath!)).resolves.toEqual(content)
|
|
})
|
|
|
|
test('物理分片超过会话声明大小时在读入内存前拒绝组装', async () => {
|
|
const uploadId = 'upload-oversized-physical-chunk'
|
|
const declaredContent = Buffer.from('safe')
|
|
const oversizedChunk = Buffer.concat([declaredContent, Buffer.alloc(1024 * 1024, 1)])
|
|
await writeSingleChunk(uploadId, oversizedChunk)
|
|
await saveUploadSession(uploadId, createSession('source.jpg', declaredContent.length))
|
|
|
|
await expect(getUploadedFile(uploadId)).resolves.toBeNull()
|
|
await expect(
|
|
readFile(path.join(uploadRoot, 'assembled', `${uploadId}-source.jpg`)),
|
|
).rejects.toMatchObject({ code: 'ENOENT' })
|
|
})
|
|
|
|
test.each([
|
|
'../../../sentinel.txt',
|
|
'..\\..\\sentinel.txt',
|
|
'/app/server.js',
|
|
'C:\\Windows\\system.ini',
|
|
'\\\\server\\share\\outside.psd',
|
|
'bad\u0000name.psd',
|
|
])('历史恶意会话在最终落盘时仍被拒绝:%s', async (fileName) => {
|
|
const uploadId = `upload-malicious-${Math.random().toString(16).slice(2)}`
|
|
const sentinelPath = path.join(uploadRoot, 'sentinel.txt')
|
|
await writeFile(sentinelPath, 'sentinel')
|
|
await writeSingleChunk(uploadId, Buffer.from('attack'))
|
|
|
|
// 模拟修复前已经进入内存或 Redis 的旧会话,绕过新的 saveUploadSession 入口校验。
|
|
uploadSessions.set(uploadId, createSession(fileName, 6))
|
|
|
|
await expect(getUploadedFile(uploadId)).resolves.toBeNull()
|
|
await expect(readFile(sentinelPath, 'utf8')).resolves.toBe('sentinel')
|
|
})
|
|
|
|
test('saveUploadSession 自身拒绝不安全文件名', async () => {
|
|
await expect(
|
|
saveUploadSession(
|
|
'upload-invalid-session',
|
|
createSession('../outside.psd', 1),
|
|
),
|
|
).rejects.toThrow('上传会话包含不安全的标识或文件名')
|
|
})
|
|
|
|
test('最终目标是符号链接时不会跟随或覆盖链接目标', async () => {
|
|
const uploadId = 'upload-symlink-target'
|
|
const content = Buffer.from('attack')
|
|
await writeSingleChunk(uploadId, content)
|
|
await saveUploadSession(uploadId, createSession('source.psd', content.length))
|
|
|
|
const assembledDir = path.join(uploadRoot, 'assembled')
|
|
const sentinelDir = path.join(uploadRoot, 'sentinel-directory')
|
|
const sentinelPath = path.join(sentinelDir, 'sentinel.txt')
|
|
await mkdir(assembledDir)
|
|
await mkdir(sentinelDir)
|
|
await writeFile(sentinelPath, 'sentinel')
|
|
await symlink(
|
|
sentinelDir,
|
|
path.join(assembledDir, `${uploadId}-source.psd`),
|
|
'junction',
|
|
)
|
|
|
|
await expect(getUploadedFile(uploadId)).resolves.toBeNull()
|
|
await expect(readFile(sentinelPath, 'utf8')).resolves.toBe('sentinel')
|
|
})
|
|
})
|