fix(security): 收紧上传与素材文件操作
This commit is contained in:
parent
7c913b6de9
commit
b48872d83b
@ -9,32 +9,23 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getAssetsByMaterialId, addAsset } from '@/lib/product-package';
|
||||
import { getAssetsByMaterialId } from '@/lib/product-package';
|
||||
import { deleteFileIfUnreferenced } from '@/lib/file-cleanup';
|
||||
import { createMaterialLog } from '@/lib/material-log';
|
||||
import { normalizeDisplayImages } from '@/lib/material-cleanup';
|
||||
import {
|
||||
canUserViewMaterial,
|
||||
isSuperAdmin,
|
||||
isSystemAdmin,
|
||||
} from '@/lib/department-permission';
|
||||
import {
|
||||
canManageMaterial,
|
||||
type MaterialAuthorizationUser,
|
||||
} from '@/lib/material-authorization';
|
||||
|
||||
type AssetRouteSessionUser = {
|
||||
id: string;
|
||||
role?: string | null;
|
||||
type AssetRouteSessionUser = MaterialAuthorizationUser & {
|
||||
department?: string | null;
|
||||
managedDepartment?: string | null;
|
||||
isSuperAdmin?: boolean | null;
|
||||
};
|
||||
|
||||
async function canManageMaterial(user: AssetRouteSessionUser, materialUserId: string) {
|
||||
if (materialUserId === user.id || isSystemAdmin(user.role) || user.isSuperAdmin === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isSuperAdmin(user.id);
|
||||
}
|
||||
|
||||
async function canViewMaterial(
|
||||
user: AssetRouteSessionUser,
|
||||
material: { userId: string; materialType: string },
|
||||
@ -131,77 +122,15 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const materialId = params.id;
|
||||
const body = await request.json();
|
||||
const { assetType, fileUrl, fileName, fileSize, sortOrder } = body;
|
||||
|
||||
// 验证必填字段
|
||||
if (!assetType || !fileUrl) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '缺少必填参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证素材是否存在且属于当前用户
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id: materialId, deletedAt: null },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
|
||||
if (!material) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '素材不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await canManageMaterial(session.user as AssetRouteSessionUser, material.userId))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无权限操作此素材' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加资产
|
||||
const asset = await addAsset({
|
||||
materialId,
|
||||
assetType,
|
||||
fileUrl,
|
||||
fileName,
|
||||
fileSize: fileSize ? BigInt(fileSize) : undefined,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
await createMaterialLog({
|
||||
materialId,
|
||||
userId,
|
||||
action: 'UPDATE',
|
||||
changes: {
|
||||
assetOperation: {
|
||||
oldValue: null,
|
||||
newValue: 'ADD',
|
||||
},
|
||||
assetType: {
|
||||
oldValue: null,
|
||||
newValue: asset.assetType,
|
||||
},
|
||||
assetFileName: {
|
||||
oldValue: null,
|
||||
newValue: asset.fileName || null,
|
||||
},
|
||||
assetFileUrl: {
|
||||
oldValue: null,
|
||||
newValue: asset.fileUrl,
|
||||
},
|
||||
// P0 止血:客户端提交的 fileUrl 不能作为服务端资产记录或删除依据。
|
||||
// 资产只能由受控上传完成链路在服务端生成;恢复此入口前必须改为服务端签发的存储引用。
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '当前不支持直接注册素材文件地址,请通过受控上传流程添加资产',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: asset,
|
||||
});
|
||||
{ status: 403 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[materials/assets] POST error:', error);
|
||||
return NextResponse.json(
|
||||
@ -260,7 +189,7 @@ export async function DELETE(
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await canManageMaterial(session.user as AssetRouteSessionUser, material.userId))) {
|
||||
if (!(await canManageMaterial(session.user as AssetRouteSessionUser, material))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无权限操作此素材' },
|
||||
{ status: 403 }
|
||||
@ -332,6 +261,8 @@ export async function DELETE(
|
||||
|
||||
const cleanupResult = await deleteFileIfUnreferenced(asset.fileUrl, {
|
||||
excludeAssetIds: [assetId],
|
||||
// 历史资产 fileUrl 可能来自客户端,P0 阶段只删除数据库关联,不执行物理删除。
|
||||
allowPhysicalDelete: false,
|
||||
});
|
||||
|
||||
await createMaterialLog({
|
||||
|
||||
@ -297,7 +297,10 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
const whiteBackgroundDirectory = getWhiteBackgroundRelativeDirectory(existingSku.materialId, skuId)
|
||||
const whiteBackgroundDirectoryResult = await deleteLocalFile(whiteBackgroundDirectory)
|
||||
const whiteBackgroundDirectoryResult = await deleteLocalFile(
|
||||
whiteBackgroundDirectory,
|
||||
{ allowDirectoryDelete: true },
|
||||
)
|
||||
|
||||
const deletedFileCount = cleanupResults.filter((item) => item.success && !item.skipped).length
|
||||
const skippedFileCount = cleanupResults.filter((item) => item.skipped).length
|
||||
|
||||
@ -9,8 +9,19 @@ import {
|
||||
} from '@/lib/standard-model-white-background'
|
||||
import { normalizeStandardModelWhiteBackgroundSourceType } from '@/lib/standard-model-white-background-metadata'
|
||||
import { uploadStandardModelWhiteBackground } from '@/lib/standard-model-white-background-service'
|
||||
import {
|
||||
assertWhiteBackgroundUploadFiles,
|
||||
STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS,
|
||||
WhiteBackgroundUploadValidationError,
|
||||
} from '@/lib/standard-model-white-background-upload-guard'
|
||||
import {
|
||||
canManageMaterial,
|
||||
type MaterialAuthorizationUser,
|
||||
} from '@/lib/material-authorization'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
const MAX_WHITE_BACKGROUND_REQUEST_BYTES =
|
||||
STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxTotalBytes + 5 * 1024 * 1024
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
@ -26,7 +37,7 @@ export async function POST(
|
||||
const userId = (session.user as any).id
|
||||
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id: materialId },
|
||||
where: { id: materialId, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
materialType: true,
|
||||
@ -44,6 +55,18 @@ export async function POST(
|
||||
return NextResponse.json({ error: '只有标准模型类型支持上传白底图' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!(await canManageMaterial(session.user as MaterialAuthorizationUser, material))) {
|
||||
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 })
|
||||
}
|
||||
|
||||
const contentLength = Number(request.headers.get('content-length'))
|
||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||
return NextResponse.json({ error: '缺少有效的请求大小' }, { status: 411 })
|
||||
}
|
||||
if (contentLength > MAX_WHITE_BACKGROUND_REQUEST_BYTES) {
|
||||
return NextResponse.json({ error: '白底图上传请求过大' }, { status: 413 })
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const skuId = ((formData.get('skuId') as string) || '').trim()
|
||||
const skuName = ((formData.get('skuName') as string) || '').trim()
|
||||
@ -72,28 +95,45 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'SKU不存在或已被删除' }, { status: 404 })
|
||||
}
|
||||
|
||||
for (const view of STANDARD_MODEL_WHITE_BG_VIEWS) {
|
||||
const entries = formData.getAll(view.fieldName)
|
||||
if (entries.length > 1) {
|
||||
return NextResponse.json(
|
||||
{ error: `${view.label}只能上传 1 张图片` },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const entry = entries[0]
|
||||
if (entry !== undefined && typeof entry === 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: `${view.label}不是有效图片文件` },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const whiteBackgroundImages = STANDARD_MODEL_WHITE_BG_VIEWS.reduce((acc, view) => {
|
||||
acc[view.key] = formData.get(view.fieldName) as File | null
|
||||
const entry = formData.get(view.fieldName)
|
||||
acc[view.key] = (entry as File | undefined) || null
|
||||
return acc
|
||||
}, {} as Partial<Record<StandardModelWhiteBgKey, File | null>>)
|
||||
|
||||
const otherImages = formData
|
||||
.getAll(STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME)
|
||||
.filter((file): file is File => {
|
||||
return (
|
||||
!!file &&
|
||||
typeof (file as File).name === 'string' &&
|
||||
typeof (file as File).arrayBuffer === 'function' &&
|
||||
Number((file as File).size || 0) > 0
|
||||
)
|
||||
})
|
||||
const otherImageEntries = formData.getAll(STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME)
|
||||
if (otherImageEntries.some((entry) => typeof entry === 'string')) {
|
||||
return NextResponse.json({ error: '其他图片包含无效文件' }, { status: 400 })
|
||||
}
|
||||
const otherImages = otherImageEntries as File[]
|
||||
|
||||
const uploadedFixedViews = STANDARD_MODEL_WHITE_BG_VIEWS.filter((view) => !!whiteBackgroundImages[view.key])
|
||||
if (uploadedFixedViews.length === 0 && otherImages.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: '请至少上传 1 张白底图' },
|
||||
{ status: 400 },
|
||||
)
|
||||
try {
|
||||
await assertWhiteBackgroundUploadFiles({
|
||||
fixedImages: whiteBackgroundImages,
|
||||
otherImages,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof WhiteBackgroundUploadValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await uploadStandardModelWhiteBackground({
|
||||
|
||||
@ -85,7 +85,9 @@ async function cleanupUploadSession(uploadId: string, uploadDir: string): Promis
|
||||
|
||||
// 使用统一的文件删除工具递归删除整个目录
|
||||
const { deleteLocalFile } = await import('@/lib/file-cleanup')
|
||||
const result = await deleteLocalFile(tempDir)
|
||||
const result = await deleteLocalFile(tempDir, {
|
||||
allowDirectoryDelete: true,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
// 如果之前没有统计到文件,至少算删除了目录本身
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
import { POST } from '@/app/api/materials/upload/init/route'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { saveUploadSession } from '@/lib/upload-sessions'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { ensureSafeDirectoryWithinRoot } from '@/lib/path-security'
|
||||
import { writeFile } from 'fs/promises'
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
@ -17,8 +18,15 @@ jest.mock('@/lib/upload-sessions', () => ({
|
||||
saveUploadSession: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/path-security', () => {
|
||||
const actual = jest.requireActual('@/lib/path-security')
|
||||
return {
|
||||
...actual,
|
||||
ensureSafeDirectoryWithinRoot: jest.fn().mockResolvedValue('D:\\temp\\uploads\\temp\\upload-fixed'),
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('fs/promises', () => ({
|
||||
mkdir: jest.fn().mockResolvedValue(undefined),
|
||||
writeFile: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
@ -94,7 +102,11 @@ describe('/api/materials/upload/init', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body).toEqual({ uploadId: expect.stringContaining('upload-') })
|
||||
expect(mkdir).toHaveBeenCalled()
|
||||
expect(ensureSafeDirectoryWithinRoot).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'temp',
|
||||
expect.stringContaining('upload-'),
|
||||
)
|
||||
expect(writeFile).toHaveBeenCalled()
|
||||
expect(saveUploadSession).toHaveBeenCalledWith(
|
||||
expect.stringContaining('upload-'),
|
||||
@ -106,4 +118,40 @@ describe('/api/materials/upload/init', () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
'../outside.psd',
|
||||
'..\\outside.psd',
|
||||
'/app/server.js',
|
||||
'C:\\Windows\\system.ini',
|
||||
'\\\\server\\share\\outside.psd',
|
||||
'%2e%2e%2foutside.psd',
|
||||
'%252e%252e%255coutside.psd',
|
||||
'bad\u0000name.psd',
|
||||
'folder/name.psd',
|
||||
])('拒绝不安全文件名:%s', async (fileName) => {
|
||||
const response = await POST(createRequest({
|
||||
fileName,
|
||||
fileSize: 1024,
|
||||
totalChunks: 1,
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: '文件名包含不安全的路径或字符',
|
||||
})
|
||||
expect(ensureSafeDirectoryWithinRoot).not.toHaveBeenCalled()
|
||||
expect(saveUploadSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('拒绝超过文件系统叶子名称上限的文件名', async () => {
|
||||
const response = await POST(createRequest({
|
||||
fileName: `${'文'.repeat(90)}.psd`,
|
||||
fileSize: 1024,
|
||||
totalChunks: 1,
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(saveUploadSession).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getAuthenticatedUploadUserId } from '@/lib/upload-auth'
|
||||
import { saveUploadSession } from '@/lib/upload-sessions'
|
||||
import { getUploadRootDir } from '@/lib/upload-paths'
|
||||
import {
|
||||
ensureSafeDirectoryWithinRoot,
|
||||
normalizeSafeFileName,
|
||||
} from '@/lib/path-security'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@ -23,13 +28,17 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const normalizedFileName = typeof body?.fileName === 'string' ? body.fileName.trim() : ''
|
||||
const rawFileName = typeof body?.fileName === 'string' ? body.fileName : ''
|
||||
const normalizedFileName = normalizeSafeFileName(rawFileName)
|
||||
const normalizedFileSize = Number(body?.fileSize)
|
||||
const normalizedTotalChunks = Number(body?.totalChunks)
|
||||
|
||||
if (!normalizedFileName) {
|
||||
if (!rawFileName.trim()) {
|
||||
return NextResponse.json({ error: '文件名不能为空' }, { status: 400 })
|
||||
}
|
||||
if (!normalizedFileName) {
|
||||
return NextResponse.json({ error: '文件名包含不安全的路径或字符' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!Number.isFinite(normalizedFileSize) || normalizedFileSize <= 0) {
|
||||
return NextResponse.json({ error: '上传文件不能为空' }, { status: 400 })
|
||||
@ -43,9 +52,11 @@ export async function POST(req: NextRequest) {
|
||||
const uploadId = `upload-${Date.now()}-${randomBytes(16).toString('hex')}`
|
||||
|
||||
// 创建临时目录
|
||||
const uploadDir = process.env.UPLOAD_DIR || '/app/uploads'
|
||||
const tempDir = path.join(uploadDir, 'temp', uploadId)
|
||||
await mkdir(tempDir, { recursive: true })
|
||||
const uploadDir = getUploadRootDir()
|
||||
const tempDir = await ensureSafeDirectoryWithinRoot(uploadDir, 'temp', uploadId)
|
||||
if (!tempDir) {
|
||||
throw new Error('上传临时目录不安全')
|
||||
}
|
||||
// 写入元数据,方便在Redis丢失时回溯
|
||||
const metaPath = path.join(tempDir, 'meta.json')
|
||||
const metaPayload = {
|
||||
|
||||
@ -2,8 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { deleteUploadSession, getUploadSession } from '@/lib/upload-sessions'
|
||||
import path from 'path'
|
||||
import {
|
||||
deleteUploadSession,
|
||||
getOwnedUploadSession,
|
||||
isSafeUploadId,
|
||||
} from '@/lib/upload-sessions'
|
||||
import { getUploadRootDir } from '@/lib/upload-paths'
|
||||
import {
|
||||
isPathFreeOfSymbolicLinks,
|
||||
resolveStrictChildPath,
|
||||
} from '@/lib/path-security'
|
||||
import { rm, unlink } from 'fs/promises'
|
||||
|
||||
interface AbortRequestBody {
|
||||
@ -11,6 +19,58 @@ interface AbortRequestBody {
|
||||
uploadIds?: string[]
|
||||
}
|
||||
|
||||
interface ValidatedAbortUpload {
|
||||
uploadId: string
|
||||
assembledPath: string
|
||||
tempDir: string
|
||||
}
|
||||
|
||||
async function validateAbortUploads(
|
||||
uploadIds: string[],
|
||||
userId: string,
|
||||
): Promise<ValidatedAbortUpload[]> {
|
||||
const uploadRoot = getUploadRootDir()
|
||||
const assembledRoot = resolveStrictChildPath(uploadRoot, 'assembled')
|
||||
const tempRoot = resolveStrictChildPath(uploadRoot, 'temp')
|
||||
if (!assembledRoot || !tempRoot) {
|
||||
throw new Error('上传目录配置不安全')
|
||||
}
|
||||
const validated: ValidatedAbortUpload[] = []
|
||||
|
||||
for (const uploadId of uploadIds) {
|
||||
const uploadSession = await getOwnedUploadSession(uploadId, userId)
|
||||
if (!uploadSession) {
|
||||
throw new Error(`上传会话不存在或不属于当前用户:${uploadId}`)
|
||||
}
|
||||
|
||||
const assembledPath = resolveStrictChildPath(
|
||||
assembledRoot,
|
||||
`${uploadId}-${uploadSession.fileName}`,
|
||||
)
|
||||
const tempDir = resolveStrictChildPath(tempRoot, uploadId)
|
||||
if (
|
||||
!assembledPath ||
|
||||
!tempDir ||
|
||||
!(await isPathFreeOfSymbolicLinks(uploadRoot, assembledPath, {
|
||||
allowMissingDescendants: true,
|
||||
})) ||
|
||||
!(await isPathFreeOfSymbolicLinks(uploadRoot, tempDir, {
|
||||
allowMissingDescendants: true,
|
||||
}))
|
||||
) {
|
||||
throw new Error(`上传会话路径不安全:${uploadId}`)
|
||||
}
|
||||
|
||||
validated.push({
|
||||
uploadId,
|
||||
assembledPath,
|
||||
tempDir,
|
||||
})
|
||||
}
|
||||
|
||||
return validated
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
@ -21,9 +81,20 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const body = (await req.json()) as AbortRequestBody
|
||||
const materialId = body.materialId?.trim()
|
||||
const uploadIds = Array.isArray(body.uploadIds)
|
||||
? body.uploadIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
: []
|
||||
const rawUploadIds = Array.isArray(body.uploadIds) ? body.uploadIds : []
|
||||
if (rawUploadIds.some((id) => typeof id !== 'string' || !isSafeUploadId(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'uploadIds 包含非法上传会话 ID' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const uploadIds = Array.from(new Set(rawUploadIds.map((id) => id.trim())))
|
||||
if (uploadIds.length > 200) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '单次最多清理 200 个上传会话' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!materialId) {
|
||||
return NextResponse.json({ success: false, error: '缺少materialId' }, { status: 400 })
|
||||
@ -57,19 +128,29 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ success: true, deleted: false, reason: 'already_processed' })
|
||||
}
|
||||
|
||||
for (const uploadId of uploadIds) {
|
||||
const uploadSession = await getUploadSession(uploadId)
|
||||
if (uploadSession?.fileName) {
|
||||
const assembledPath = path.join(process.cwd(), 'uploads', 'assembled', `${uploadId}-${uploadSession.fileName}`)
|
||||
await unlink(assembledPath).catch(() => undefined)
|
||||
}
|
||||
// 在执行任何文件、会话或素材删除之前,先一次性校验全部 uploadId、
|
||||
// 会话存在性、所有权和物理路径边界,避免部分清理后才发现越权输入。
|
||||
let validatedUploads: ValidatedAbortUpload[]
|
||||
try {
|
||||
validatedUploads = await validateAbortUploads(uploadIds, userId)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '上传会话校验失败'
|
||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
||||
}
|
||||
|
||||
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId)
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
await deleteUploadSession(uploadId).catch(() => undefined)
|
||||
for (const upload of validatedUploads) {
|
||||
await unlink(upload.assembledPath).catch((error: any) => {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
await rm(upload.tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
await prisma.material.delete({ where: { id: materialId } })
|
||||
for (const upload of validatedUploads) {
|
||||
await deleteUploadSession(upload.uploadId)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
215
app/api/upload/psd/route.test.ts
Normal file
215
app/api/upload/psd/route.test.ts
Normal file
@ -0,0 +1,215 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { POST } from '@/app/api/upload/psd/route'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
ensureSafeDirectoryWithinRoot,
|
||||
isPathFreeOfSymbolicLinks,
|
||||
} from '@/lib/path-security'
|
||||
import { canManageMaterial } from '@/lib/material-authorization'
|
||||
import { writeFile } from 'fs/promises'
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/material-authorization', () => ({
|
||||
canManageMaterial: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/upload-paths', () => {
|
||||
const actual = jest.requireActual('@/lib/upload-paths')
|
||||
return {
|
||||
...actual,
|
||||
getUploadRootDir: jest.fn(() => 'D:\\safe-uploads'),
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/lib/path-security', () => {
|
||||
const actual = jest.requireActual('@/lib/path-security')
|
||||
return {
|
||||
...actual,
|
||||
ensureSafeDirectoryWithinRoot: jest.fn().mockResolvedValue(
|
||||
'D:\\safe-uploads\\product-package\\material-safe\\psd',
|
||||
),
|
||||
isPathFreeOfSymbolicLinks: jest.fn().mockResolvedValue(true),
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('fs/promises', () => ({
|
||||
writeFile: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
jest.mock('crypto', () => ({
|
||||
randomBytes: jest.fn(() => ({
|
||||
toString: () => 'fixed-random',
|
||||
})),
|
||||
}))
|
||||
|
||||
function createPhotoshopFile(
|
||||
name = 'source.psd',
|
||||
options?: {
|
||||
version?: 1 | 2
|
||||
type?: string
|
||||
reportedSize?: number
|
||||
signature?: string
|
||||
},
|
||||
) {
|
||||
const buffer = Buffer.alloc(16)
|
||||
buffer.write(options?.signature ?? '8BPS', 0, 'ascii')
|
||||
buffer.writeUInt16BE(options?.version ?? 1, 4)
|
||||
|
||||
return {
|
||||
name,
|
||||
type: options?.type ?? 'application/octet-stream',
|
||||
size: options?.reportedSize ?? buffer.length,
|
||||
arrayBuffer: jest.fn().mockResolvedValue(
|
||||
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function createRequest(input?: {
|
||||
file?: ReturnType<typeof createPhotoshopFile> | null
|
||||
materialId?: string
|
||||
contentLength?: string | null
|
||||
}) {
|
||||
const values = new Map<string, unknown>([
|
||||
['file', input?.file === undefined ? createPhotoshopFile() : input.file],
|
||||
['materialId', input?.materialId ?? 'material-request'],
|
||||
])
|
||||
|
||||
return {
|
||||
headers: {
|
||||
get: jest.fn((name: string) => (
|
||||
name.toLowerCase() === 'content-length'
|
||||
? (input?.contentLength === undefined ? '1024' : input.contentLength)
|
||||
: null
|
||||
)),
|
||||
},
|
||||
formData: jest.fn().mockResolvedValue({
|
||||
get: (key: string) => values.get(key) ?? null,
|
||||
}),
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('/api/upload/psd', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
})
|
||||
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
||||
id: 'material-safe',
|
||||
userId: 'user-1',
|
||||
deletedAt: null,
|
||||
})
|
||||
;(canManageMaterial as jest.Mock).mockResolvedValue(true)
|
||||
;(ensureSafeDirectoryWithinRoot as jest.Mock).mockResolvedValue(
|
||||
'D:\\safe-uploads\\product-package\\material-safe\\psd',
|
||||
)
|
||||
;(isPathFreeOfSymbolicLinks as jest.Mock).mockResolvedValue(true)
|
||||
})
|
||||
|
||||
test('缺少 Content-Length 时在解析 multipart 前拒绝请求', async () => {
|
||||
const request = createRequest({ contentLength: null })
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(411)
|
||||
expect(request.formData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('超大请求在解析 multipart 前返回 413', async () => {
|
||||
const request = createRequest({ contentLength: String(131 * 1024 * 1024) })
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
expect(request.formData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('不得向其他用户的素材目录上传 PSD', async () => {
|
||||
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
||||
id: 'material-safe',
|
||||
userId: 'user-2',
|
||||
deletedAt: null,
|
||||
})
|
||||
;(canManageMaterial as jest.Mock).mockResolvedValue(false)
|
||||
|
||||
const response = await POST(createRequest())
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(ensureSafeDirectoryWithinRoot).not.toHaveBeenCalled()
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('使用数据库返回的 material.id 构造受控目录', async () => {
|
||||
const response = await POST(createRequest({ materialId: '../../client-path' }))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(prisma.material.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: '../../client-path' } }),
|
||||
)
|
||||
expect(ensureSafeDirectoryWithinRoot).toHaveBeenCalledWith(
|
||||
'D:\\safe-uploads',
|
||||
'product-package',
|
||||
'material-safe',
|
||||
'psd',
|
||||
)
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('material-safe'),
|
||||
expect.any(Buffer),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
path: expect.stringMatching(
|
||||
/^uploads\/product-package\/material-safe\/psd\/psd-\d+-fixed-random\.psd$/,
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
test('拒绝伪造扩展名或无效文件头', async () => {
|
||||
const response = await POST(createRequest({
|
||||
file: createPhotoshopFile('source.psd', { signature: 'FAKE' }),
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('PSD 与 PSB 版本必须和扩展名一致', async () => {
|
||||
const response = await POST(createRequest({
|
||||
file: createPhotoshopFile('source.psd', { version: 2 }),
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('拒绝不允许的 MIME 类型和超大单文件', async () => {
|
||||
const badMimeResponse = await POST(createRequest({
|
||||
file: createPhotoshopFile('source.psd', { type: 'text/plain' }),
|
||||
}))
|
||||
expect(badMimeResponse.status).toBe(400)
|
||||
|
||||
const oversizedResponse = await POST(createRequest({
|
||||
file: createPhotoshopFile('source.psd', {
|
||||
reportedSize: 129 * 1024 * 1024,
|
||||
}),
|
||||
}))
|
||||
expect(oversizedResponse.status).toBe(413)
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -1,41 +1,140 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import {
|
||||
canManageMaterial,
|
||||
type MaterialAuthorizationUser,
|
||||
} from '@/lib/material-authorization';
|
||||
import { buildStoredUploadPath, getUploadRootDir } from '@/lib/upload-paths';
|
||||
import {
|
||||
ensureSafeDirectoryWithinRoot,
|
||||
isPathFreeOfSymbolicLinks,
|
||||
normalizeSafeFileName,
|
||||
resolveStrictChildPath,
|
||||
} from '@/lib/path-security';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const MAX_PSD_FILE_BYTES = 128 * 1024 * 1024;
|
||||
const MAX_PSD_REQUEST_BYTES = MAX_PSD_FILE_BYTES + 2 * 1024 * 1024;
|
||||
const ALLOWED_PSD_MIME_TYPES = new Set([
|
||||
'',
|
||||
'application/octet-stream',
|
||||
'application/vnd.adobe.photoshop',
|
||||
'application/x-photoshop',
|
||||
'image/vnd.adobe.photoshop',
|
||||
'image/x-photoshop',
|
||||
]);
|
||||
|
||||
function detectPhotoshopDocumentType(buffer: Buffer): '.psd' | '.psb' | null {
|
||||
if (buffer.length < 6 || buffer.subarray(0, 4).toString('ascii') !== '8BPS') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const version = buffer.readUInt16BE(4);
|
||||
if (version === 1) return '.psd';
|
||||
if (version === 2) return '.psb';
|
||||
return null;
|
||||
}
|
||||
|
||||
// POST - 上传 PSD 文件到本地
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
const userId = String((session?.user as { id?: string } | undefined)?.id || '').trim();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const materialId = formData.get('materialId') as string;
|
||||
const contentLength = Number(request.headers.get('content-length'));
|
||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||
return NextResponse.json({ error: '缺少有效的请求大小' }, { status: 411 });
|
||||
}
|
||||
if (contentLength > MAX_PSD_REQUEST_BYTES) {
|
||||
return NextResponse.json({ error: 'PSD 文件过大' }, { status: 413 });
|
||||
}
|
||||
|
||||
if (!file || !materialId) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file');
|
||||
const materialId = String(formData.get('materialId') || '').trim();
|
||||
|
||||
if (
|
||||
!file ||
|
||||
typeof (file as File).name !== 'string' ||
|
||||
typeof (file as File).arrayBuffer !== 'function' ||
|
||||
!materialId
|
||||
) {
|
||||
return NextResponse.json({ error: '缺少文件或素材ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 生成本地存储路径
|
||||
const timestamp = Date.now();
|
||||
const ext = file.name.split('.').pop();
|
||||
const fileName = `psd-${timestamp}.${ext}`;
|
||||
const uploadDir = path.join(process.cwd(), 'uploads', 'product-package', materialId, 'psd');
|
||||
const filePath = path.join(uploadDir, fileName);
|
||||
const uploadFile = file as File;
|
||||
const safeOriginalFileName = normalizeSafeFileName(uploadFile.name);
|
||||
const extension = path.extname(safeOriginalFileName || '').toLowerCase();
|
||||
if (
|
||||
!safeOriginalFileName ||
|
||||
(extension !== '.psd' && extension !== '.psb') ||
|
||||
!ALLOWED_PSD_MIME_TYPES.has(String(uploadFile.type || '').toLowerCase())
|
||||
) {
|
||||
return NextResponse.json({ error: '只支持有效的 PSD 或 PSB 文件' }, { status: 400 });
|
||||
}
|
||||
if (uploadFile.size <= 0 || uploadFile.size > MAX_PSD_FILE_BYTES) {
|
||||
return NextResponse.json({ error: 'PSD 文件大小无效或超过限制' }, { status: 413 });
|
||||
}
|
||||
|
||||
// 确保目录存在
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id: materialId },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
if (!material || material.deletedAt) {
|
||||
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
|
||||
}
|
||||
if (
|
||||
!(await canManageMaterial(
|
||||
session!.user as MaterialAuthorizationUser,
|
||||
material,
|
||||
))
|
||||
) {
|
||||
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 });
|
||||
}
|
||||
if (normalizeSafeFileName(material.id) !== material.id) {
|
||||
throw new Error('素材存储标识不安全');
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
const buffer = Buffer.from(await uploadFile.arrayBuffer());
|
||||
const detectedExtension = detectPhotoshopDocumentType(buffer);
|
||||
if (!detectedExtension || detectedExtension !== extension) {
|
||||
return NextResponse.json({ error: 'PSD 文件头或版本无效' }, { status: 400 });
|
||||
}
|
||||
|
||||
const uploadRoot = getUploadRootDir();
|
||||
const uploadDir = await ensureSafeDirectoryWithinRoot(
|
||||
uploadRoot,
|
||||
'product-package',
|
||||
material.id,
|
||||
'psd',
|
||||
);
|
||||
if (!uploadDir) {
|
||||
throw new Error('PSD 上传目录不安全');
|
||||
}
|
||||
|
||||
const fileName = `psd-${Date.now()}-${randomBytes(8).toString('hex')}${detectedExtension}`;
|
||||
const filePath = resolveStrictChildPath(uploadDir, fileName);
|
||||
if (
|
||||
!filePath ||
|
||||
!(await isPathFreeOfSymbolicLinks(uploadRoot, filePath, { allowMissingLeaf: true }))
|
||||
) {
|
||||
throw new Error('PSD 最终文件路径不安全');
|
||||
}
|
||||
await writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 });
|
||||
|
||||
// 返回相对路径
|
||||
const relativePath = path.join('uploads', 'product-package', materialId, 'psd', fileName);
|
||||
const relativePath = buildStoredUploadPath('product-package', material.id, 'psd', fileName);
|
||||
|
||||
return NextResponse.json({ path: relativePath });
|
||||
} catch (error) {
|
||||
|
||||
@ -116,7 +116,7 @@ describe('ProductPackageUploader', () => {
|
||||
const input = await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||||
await user.click(input)
|
||||
await user.type(input, '运营甲')
|
||||
await user.click(screen.getByRole('button', { name: /运营甲/ }))
|
||||
await user.click(screen.getByRole('option', { name: /运营甲/ }))
|
||||
|
||||
expect(input).toHaveValue('运营甲 (operator-a)')
|
||||
expect(screen.getByText('记录发起本次出图需求的运营人员,与素材上传人可以不同。')).toBeInTheDocument()
|
||||
|
||||
112
lib/__tests__/file-cleanup-security.test.ts
Normal file
112
lib/__tests__/file-cleanup-security.test.ts
Normal file
@ -0,0 +1,112 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import {
|
||||
access,
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'fs/promises'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import {
|
||||
deleteFileIfUnreferenced,
|
||||
deleteLocalFile,
|
||||
} from '@/lib/file-cleanup'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
materialAsset: { count: jest.fn() },
|
||||
materialSku: { count: jest.fn() },
|
||||
material: { count: jest.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/s3', () => ({
|
||||
deleteFromS3: jest.fn(),
|
||||
isS3Url: jest.fn(() => false),
|
||||
}))
|
||||
|
||||
describe('file cleanup security boundaries', () => {
|
||||
let sandboxRoot: string
|
||||
let uploadRoot: string
|
||||
let previousUploadDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
previousUploadDir = process.env.UPLOAD_DIR
|
||||
sandboxRoot = await mkdtemp(path.join(os.tmpdir(), 'gongxiangp2-cleanup-'))
|
||||
uploadRoot = path.join(sandboxRoot, 'uploads')
|
||||
await mkdir(uploadRoot, { recursive: true })
|
||||
process.env.UPLOAD_DIR = uploadRoot
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (previousUploadDir === undefined) {
|
||||
delete process.env.UPLOAD_DIR
|
||||
} else {
|
||||
process.env.UPLOAD_DIR = previousUploadDir
|
||||
}
|
||||
await rm(sandboxRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('拒绝删除受控上传目录之外的文件', async () => {
|
||||
const sentinelPath = path.join(sandboxRoot, 'sentinel.txt')
|
||||
await writeFile(sentinelPath, 'keep')
|
||||
|
||||
const result = await deleteLocalFile(sentinelPath)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: '拒绝删除受控上传目录之外的路径',
|
||||
})
|
||||
await expect(access(sentinelPath)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('允许删除受控上传目录内的普通文件', async () => {
|
||||
const filePath = path.join(uploadRoot, 'material-1', 'preview.webp')
|
||||
await mkdir(path.dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, 'preview')
|
||||
|
||||
const result = await deleteLocalFile(filePath)
|
||||
|
||||
expect(result).toEqual({ success: true, isDirectory: false })
|
||||
await expect(access(filePath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
test('目录默认不能递归删除,受信任调用方必须明确允许', async () => {
|
||||
const directoryPath = path.join(uploadRoot, 'material-1')
|
||||
const sentinelPath = path.join(directoryPath, 'sentinel.txt')
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
await writeFile(sentinelPath, 'keep')
|
||||
|
||||
const deniedResult = await deleteLocalFile(directoryPath)
|
||||
expect(deniedResult).toEqual({
|
||||
success: false,
|
||||
isDirectory: true,
|
||||
error: '目录递归删除必须由受信任调用方明确允许',
|
||||
})
|
||||
await expect(access(sentinelPath)).resolves.toBeUndefined()
|
||||
|
||||
const allowedResult = await deleteLocalFile(directoryPath, {
|
||||
allowDirectoryDelete: true,
|
||||
})
|
||||
expect(allowedResult).toEqual({ success: true, isDirectory: true })
|
||||
await expect(access(directoryPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
test('显式禁用物理删除时不触碰历史不可信 fileUrl', async () => {
|
||||
const sentinelPath = path.join(sandboxRoot, 'legacy-target.txt')
|
||||
await writeFile(sentinelPath, 'keep')
|
||||
|
||||
const result = await deleteFileIfUnreferenced(sentinelPath, {
|
||||
allowPhysicalDelete: false,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: 'physical_delete_disabled',
|
||||
})
|
||||
await expect(access(sentinelPath)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@ -3,6 +3,7 @@
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { addAsset, getAssetsByMaterialId } from '@/lib/product-package'
|
||||
import { deleteFileIfUnreferenced } from '@/lib/file-cleanup'
|
||||
import {
|
||||
DELETE,
|
||||
GET,
|
||||
@ -53,6 +54,11 @@ describe('material assets route authorization', () => {
|
||||
zipFileSize: BigInt(0),
|
||||
displayImages: [],
|
||||
})
|
||||
;(deleteFileIfUnreferenced as jest.Mock).mockResolvedValue({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: 'physical_delete_disabled',
|
||||
})
|
||||
})
|
||||
|
||||
test('无查看权限的用户不得读取资产列表', async () => {
|
||||
@ -79,4 +85,66 @@ describe('material assets route authorization', () => {
|
||||
expect(response.status).toBe(403)
|
||||
expect(prisma.materialAsset.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('素材所有者也不能通过客户端 fileUrl 直接注册资产', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1', role: 'USER', department: '设计部' },
|
||||
})
|
||||
|
||||
const response = await POST({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
assetType: 'preview',
|
||||
fileUrl: 'D:\\app\\sentinel.txt',
|
||||
}),
|
||||
} as any, { params: { id: 'material-1' } })
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(body.error).toContain('受控上传流程')
|
||||
expect(addAsset).not.toHaveBeenCalled()
|
||||
expect(prisma.material.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('删除历史资产记录时不得根据不可信 fileUrl 执行物理删除', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1', role: 'USER', department: '设计部' },
|
||||
})
|
||||
;(prisma.materialAsset.findUnique as jest.Mock).mockResolvedValue({
|
||||
id: 'asset-1',
|
||||
materialId: 'material-1',
|
||||
assetType: 'preview',
|
||||
fileUrl: 'D:\\app\\sentinel.txt',
|
||||
fileName: 'sentinel.txt',
|
||||
})
|
||||
;(prisma.materialAsset.findFirst as jest.Mock).mockResolvedValue(null)
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) =>
|
||||
callback({
|
||||
materialAsset: {
|
||||
delete: jest.fn().mockResolvedValue({ id: 'asset-1' }),
|
||||
},
|
||||
material: {
|
||||
update: jest.fn().mockResolvedValue({ id: 'material-1' }),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const response = await DELETE({
|
||||
url: 'http://localhost/api/materials/material-1/assets?assetId=asset-1',
|
||||
} as any, { params: { id: 'material-1' } })
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(deleteFileIfUnreferenced).toHaveBeenCalledWith(
|
||||
'D:\\app\\sentinel.txt',
|
||||
{
|
||||
excludeAssetIds: ['asset-1'],
|
||||
allowPhysicalDelete: false,
|
||||
},
|
||||
)
|
||||
expect(body.details).toEqual({
|
||||
fileDeleted: false,
|
||||
fileSkipped: true,
|
||||
fileCleanupError: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
63
lib/__tests__/material-authorization.test.ts
Normal file
63
lib/__tests__/material-authorization.test.ts
Normal file
@ -0,0 +1,63 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { canManageMaterial } from '@/lib/material-authorization'
|
||||
import {
|
||||
isSuperAdmin,
|
||||
isSystemAdmin,
|
||||
} from '@/lib/department-permission'
|
||||
|
||||
jest.mock('@/lib/department-permission', () => ({
|
||||
isSuperAdmin: jest.fn(),
|
||||
isSystemAdmin: jest.fn(),
|
||||
}))
|
||||
|
||||
describe('material authorization', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(isSystemAdmin as jest.Mock).mockReturnValue(false)
|
||||
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
|
||||
})
|
||||
|
||||
test('素材所有者可以管理自己的素材', async () => {
|
||||
await expect(
|
||||
canManageMaterial(
|
||||
{ id: 'user-1', role: 'USER' },
|
||||
{ userId: 'user-1' },
|
||||
),
|
||||
).resolves.toBe(true)
|
||||
expect(isSuperAdmin).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('系统管理员可以管理其他用户素材', async () => {
|
||||
;(isSystemAdmin as jest.Mock).mockReturnValue(true)
|
||||
|
||||
await expect(
|
||||
canManageMaterial(
|
||||
{ id: 'admin-1', role: 'SYSTEM_ADMIN' },
|
||||
{ userId: 'user-1' },
|
||||
),
|
||||
).resolves.toBe(true)
|
||||
expect(isSuperAdmin).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('数据库中的超级管理员可以管理其他用户素材', async () => {
|
||||
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
|
||||
|
||||
await expect(
|
||||
canManageMaterial(
|
||||
{ id: 'super-1', role: 'USER' },
|
||||
{ userId: 'user-1' },
|
||||
),
|
||||
).resolves.toBe(true)
|
||||
expect(isSuperAdmin).toHaveBeenCalledWith('super-1')
|
||||
})
|
||||
|
||||
test('普通非所有者不得管理素材', async () => {
|
||||
await expect(
|
||||
canManageMaterial(
|
||||
{ id: 'user-2', role: 'USER' },
|
||||
{ userId: 'user-1' },
|
||||
),
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@ -13,7 +13,9 @@ import {
|
||||
resolveSafePath,
|
||||
isPathInAllowedDirs,
|
||||
sanitizeFileName,
|
||||
getAllowedFileDirectories
|
||||
getAllowedFileDirectories,
|
||||
normalizeSafeFileName,
|
||||
resolveStrictChildPath,
|
||||
} from '../path-security'
|
||||
|
||||
describe('R29: 路径安全工具', () => {
|
||||
@ -143,4 +145,47 @@ describe('R29: 路径安全工具', () => {
|
||||
expect(hasUploads).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeSafeFileName - 上传叶子文件名校验', () => {
|
||||
it.each([
|
||||
'../outside.psd',
|
||||
'..\\outside.psd',
|
||||
'/app/server.js',
|
||||
'C:\\Windows\\system.ini',
|
||||
'\\\\server\\share\\outside.psd',
|
||||
'%2e%2e%2foutside.psd',
|
||||
'%252e%252e%255coutside.psd',
|
||||
'%25252e%25252e%25252foutside.psd',
|
||||
'folder/name.psd',
|
||||
'bad\u0000name.psd',
|
||||
])('应该拒绝路径形式的文件名:%s', (fileName) => {
|
||||
expect(normalizeSafeFileName(fileName)).toBeNull()
|
||||
})
|
||||
|
||||
it('应该保留合法中文文件名并执行 trim', () => {
|
||||
expect(normalizeSafeFileName(' 中文源文件.psb ')).toBe('中文源文件.psb')
|
||||
})
|
||||
|
||||
it('应该按 UTF-8 字节数拒绝超长文件名', () => {
|
||||
expect(normalizeSafeFileName(`${'文'.repeat(90)}.psd`)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveStrictChildPath - 严格子路径解析', () => {
|
||||
const root = path.resolve('safe-upload-root')
|
||||
|
||||
it('应该只返回根目录下的严格子项', () => {
|
||||
expect(resolveStrictChildPath(root, 'assembled', 'file.psd')).toBe(
|
||||
path.join(root, 'assembled', 'file.psd'),
|
||||
)
|
||||
})
|
||||
|
||||
it('应该拒绝根目录本身和越界路径', () => {
|
||||
expect(resolveStrictChildPath(root, '.')).toBeNull()
|
||||
expect(resolveStrictChildPath(root, '..', 'outside.psd')).toBeNull()
|
||||
expect(resolveStrictChildPath(root, path.parse(root).root, 'outside.psd')).toBeNull()
|
||||
expect(resolveStrictChildPath(root, 'C:\\Windows\\system.ini')).toBeNull()
|
||||
expect(resolveStrictChildPath(root, '\\\\server\\share\\outside.psd')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
189
lib/__tests__/product-package-abort-route-security.test.ts
Normal file
189
lib/__tests__/product-package-abort-route-security.test.ts
Normal file
@ -0,0 +1,189 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { POST } from '@/app/api/materials/upload/product-package/abort/route'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
deleteUploadSession,
|
||||
getOwnedUploadSession,
|
||||
} from '@/lib/upload-sessions'
|
||||
import { isPathFreeOfSymbolicLinks } from '@/lib/path-security'
|
||||
import {
|
||||
rm,
|
||||
unlink,
|
||||
} from 'fs/promises'
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
findUnique: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
materialAsset: {
|
||||
count: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/upload-sessions', () => ({
|
||||
deleteUploadSession: jest.fn(),
|
||||
getOwnedUploadSession: jest.fn(),
|
||||
isSafeUploadId: jest.fn((uploadId: string) =>
|
||||
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(String(uploadId || '').trim()),
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/path-security', () => {
|
||||
const actual = jest.requireActual('@/lib/path-security')
|
||||
return {
|
||||
...actual,
|
||||
isPathFreeOfSymbolicLinks: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('fs/promises', () => ({
|
||||
rm: jest.fn(),
|
||||
unlink: jest.fn(),
|
||||
}))
|
||||
|
||||
describe('product-package abort route security', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
})
|
||||
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
||||
id: 'material-1',
|
||||
userId: 'user-1',
|
||||
materialType: '任务-详情',
|
||||
previewUrl: '',
|
||||
zipFilePath: '',
|
||||
})
|
||||
;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0)
|
||||
;(prisma.material.delete as jest.Mock).mockResolvedValue({ id: 'material-1' })
|
||||
;(getOwnedUploadSession as jest.Mock).mockResolvedValue({
|
||||
fileName: 'detail.zip',
|
||||
fileSize: 1024,
|
||||
totalChunks: 1,
|
||||
chunks: new Map(),
|
||||
createdAt: Date.now(),
|
||||
userId: 'user-1',
|
||||
})
|
||||
;(deleteUploadSession as jest.Mock).mockResolvedValue(undefined)
|
||||
;(isPathFreeOfSymbolicLinks as jest.Mock).mockResolvedValue(true)
|
||||
;(unlink as jest.Mock).mockRejectedValue(
|
||||
Object.assign(new Error('not found'), { code: 'ENOENT' }),
|
||||
)
|
||||
;(rm as jest.Mock).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
function createRequest(body: unknown) {
|
||||
return {
|
||||
json: jest.fn().mockResolvedValue(body),
|
||||
} as any
|
||||
}
|
||||
|
||||
test('非法 uploadId 应在查询会话和执行删除前整体拒绝', async () => {
|
||||
const response = await POST(createRequest({
|
||||
materialId: 'material-1',
|
||||
uploadIds: ['../escape'],
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(getOwnedUploadSession).not.toHaveBeenCalled()
|
||||
expect(unlink).not.toHaveBeenCalled()
|
||||
expect(rm).not.toHaveBeenCalled()
|
||||
expect(prisma.material.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('任一会话不存在或不属于当前用户时不得部分清理', async () => {
|
||||
;(getOwnedUploadSession as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
fileName: 'main.zip',
|
||||
fileSize: 1024,
|
||||
totalChunks: 1,
|
||||
chunks: new Map(),
|
||||
createdAt: Date.now(),
|
||||
userId: 'user-1',
|
||||
})
|
||||
.mockResolvedValueOnce(null)
|
||||
|
||||
const response = await POST(createRequest({
|
||||
materialId: 'material-1',
|
||||
uploadIds: ['upload-1', 'upload-2'],
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(getOwnedUploadSession).toHaveBeenCalledTimes(2)
|
||||
expect(unlink).not.toHaveBeenCalled()
|
||||
expect(rm).not.toHaveBeenCalled()
|
||||
expect(deleteUploadSession).not.toHaveBeenCalled()
|
||||
expect(prisma.material.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('单次上传会话数量超过上限时不得进入清理', async () => {
|
||||
const response = await POST(createRequest({
|
||||
materialId: 'material-1',
|
||||
uploadIds: Array.from({ length: 201 }, (_, index) => `upload-${index}`),
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(getOwnedUploadSession).not.toHaveBeenCalled()
|
||||
expect(prisma.material.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('会话文件名导致组装路径越界时不得执行任何删除', async () => {
|
||||
;(getOwnedUploadSession as jest.Mock).mockResolvedValueOnce({
|
||||
fileName: `x${'\\..'.repeat(6)}\\sentinel.txt`,
|
||||
fileSize: 1024,
|
||||
totalChunks: 1,
|
||||
chunks: new Map(),
|
||||
createdAt: Date.now(),
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const response = await POST(createRequest({
|
||||
materialId: 'material-1',
|
||||
uploadIds: ['upload-1'],
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(unlink).not.toHaveBeenCalled()
|
||||
expect(rm).not.toHaveBeenCalled()
|
||||
expect(prisma.material.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('全部会话预校验通过后才清理受控目录并删除素材', async () => {
|
||||
const response = await POST(createRequest({
|
||||
materialId: 'material-1',
|
||||
uploadIds: ['upload-1', 'upload-2'],
|
||||
}))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body).toEqual({
|
||||
success: true,
|
||||
deleted: true,
|
||||
materialId: 'material-1',
|
||||
cleanedUploadIds: 2,
|
||||
})
|
||||
expect(getOwnedUploadSession).toHaveBeenNthCalledWith(1, 'upload-1', 'user-1')
|
||||
expect(getOwnedUploadSession).toHaveBeenNthCalledWith(2, 'upload-2', 'user-1')
|
||||
expect(unlink).toHaveBeenCalledTimes(2)
|
||||
expect(rm).toHaveBeenCalledTimes(2)
|
||||
expect(prisma.material.delete).toHaveBeenCalledWith({ where: { id: 'material-1' } })
|
||||
expect(deleteUploadSession).toHaveBeenCalledTimes(2)
|
||||
|
||||
for (const [targetPath] of (rm as jest.Mock).mock.calls) {
|
||||
expect(targetPath).toContain(`uploads${require('path').sep}temp${require('path').sep}upload-`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -13,6 +13,8 @@ jest.mock('@aws-sdk/client-s3', () => ({
|
||||
}))
|
||||
|
||||
describe('deleteFromS3', () => {
|
||||
const originalS3FilePath = process.env.S3_FILE_PATH
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
process.env.S3_BUCKET = 'jsxiangche'
|
||||
@ -21,6 +23,15 @@ describe('deleteFromS3', () => {
|
||||
process.env.S3_REGION = 'us-east-1'
|
||||
process.env.S3_ACCESS_KEY_ID = 'test-key'
|
||||
process.env.S3_SECRET_ACCESS_KEY = 'test-secret'
|
||||
delete process.env.S3_FILE_PATH
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (originalS3FilePath === undefined) {
|
||||
delete process.env.S3_FILE_PATH
|
||||
} else {
|
||||
process.env.S3_FILE_PATH = originalS3FilePath
|
||||
}
|
||||
})
|
||||
|
||||
test('删除公网 URL 时应解码中文路径并去掉 bucket 前缀', async () => {
|
||||
@ -37,4 +48,27 @@ describe('deleteFromS3', () => {
|
||||
})
|
||||
expect(sendMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('拒绝把任意 HTTP 域名解释为当前存储桶对象', async () => {
|
||||
jest.resetModules()
|
||||
const { deleteFromS3 } = await import('@/lib/s3')
|
||||
|
||||
await expect(
|
||||
deleteFromS3('https://attacker.example.com/jsxiangche/other-user/file.png'),
|
||||
).rejects.toThrow('拒绝不受信任的对象存储地址')
|
||||
expect(deleteObjectCommandMock).not.toHaveBeenCalled()
|
||||
expect(sendMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('配置业务前缀后拒绝删除前缀之外的对象', async () => {
|
||||
process.env.S3_FILE_PATH = '由壹点AI/'
|
||||
jest.resetModules()
|
||||
const { deleteFromS3 } = await import('@/lib/s3')
|
||||
|
||||
await expect(
|
||||
deleteFromS3('https://s3.1080p.hk/jsxiangche/other-user/file.png'),
|
||||
).rejects.toThrow('拒绝删除当前业务前缀之外的对象')
|
||||
expect(deleteObjectCommandMock).not.toHaveBeenCalled()
|
||||
expect(sendMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,111 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import sharp from 'sharp'
|
||||
import {
|
||||
assertWhiteBackgroundImageDimensions,
|
||||
assertWhiteBackgroundUploadFiles,
|
||||
STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS,
|
||||
WhiteBackgroundUploadValidationError,
|
||||
} from '@/lib/standard-model-white-background-upload-guard'
|
||||
|
||||
describe('standard model white-background upload guard', () => {
|
||||
async function createPngFile(name = 'white-bg.png') {
|
||||
const buffer = await sharp({
|
||||
create: {
|
||||
width: 4,
|
||||
height: 4,
|
||||
channels: 3,
|
||||
background: '#ffffff',
|
||||
},
|
||||
}).png().toBuffer()
|
||||
return new File(
|
||||
[buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer],
|
||||
name,
|
||||
{ type: 'image/png' },
|
||||
)
|
||||
}
|
||||
|
||||
test('接受单页且在大小和像素预算内的图片', async () => {
|
||||
const file = await createPngFile()
|
||||
|
||||
await expect(
|
||||
assertWhiteBackgroundUploadFiles({
|
||||
fixedImages: { rightFront45: file },
|
||||
otherImages: [],
|
||||
}),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('拒绝伪装成图片的不可解码内容', async () => {
|
||||
const file = new File(
|
||||
['not-an-image'],
|
||||
'fake.png',
|
||||
{ type: 'image/png' },
|
||||
)
|
||||
|
||||
await expect(
|
||||
assertWhiteBackgroundUploadFiles({
|
||||
fixedImages: { rightFront45: file },
|
||||
otherImages: [],
|
||||
}),
|
||||
).rejects.toThrow('无法读取或像素总量过大')
|
||||
})
|
||||
|
||||
test('拒绝非白名单 MIME 类型', async () => {
|
||||
const file = new File(
|
||||
['image'],
|
||||
'unsafe.svg',
|
||||
{ type: 'image/svg+xml' },
|
||||
)
|
||||
|
||||
await expect(
|
||||
assertWhiteBackgroundUploadFiles({
|
||||
fixedImages: { rightFront45: file },
|
||||
otherImages: [],
|
||||
}),
|
||||
).rejects.toThrow('格式不支持')
|
||||
})
|
||||
|
||||
test('拒绝超过其他图片数量上限的请求', async () => {
|
||||
const files = Array.from(
|
||||
{ length: STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxOtherImages + 1 },
|
||||
(_, index) => new File(
|
||||
['x'],
|
||||
`other-${index}.png`,
|
||||
{ type: 'image/png' },
|
||||
),
|
||||
)
|
||||
|
||||
await expect(
|
||||
assertWhiteBackgroundUploadFiles({
|
||||
fixedImages: {},
|
||||
otherImages: files,
|
||||
}),
|
||||
).rejects.toThrow('其他图片最多上传')
|
||||
})
|
||||
|
||||
test('在解码前拒绝超过单文件体积上限的图片', async () => {
|
||||
const file = new File(
|
||||
[new Uint8Array(STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxBytesPerImage + 1)],
|
||||
'too-large.png',
|
||||
{ type: 'image/png' },
|
||||
)
|
||||
|
||||
await expect(
|
||||
assertWhiteBackgroundUploadFiles({
|
||||
fixedImages: { rightFront45: file },
|
||||
otherImages: [],
|
||||
}),
|
||||
).rejects.toThrow('不能超过 20MB')
|
||||
})
|
||||
|
||||
test('拒绝超过单图像素上限的尺寸', () => {
|
||||
expect(() =>
|
||||
assertWhiteBackgroundImageDimensions(
|
||||
STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxPixelsPerImage + 1,
|
||||
1,
|
||||
'测试图片',
|
||||
),
|
||||
).toThrow(WhiteBackgroundUploadValidationError)
|
||||
})
|
||||
})
|
||||
123
lib/__tests__/upload-sessions-security.test.ts
Normal file
123
lib/__tests__/upload-sessions-security.test.ts
Normal file
@ -0,0 +1,123 @@
|
||||
/** @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.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')
|
||||
})
|
||||
})
|
||||
@ -7,6 +7,11 @@ import { deleteFileIfUnreferenced } from '@/lib/file-cleanup'
|
||||
import { buildWhiteBackgroundWebpVariants } from '@/lib/white-background-preview'
|
||||
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
|
||||
import { deleteFromS3 } from '@/lib/s3'
|
||||
import { canManageMaterial } from '@/lib/material-authorization'
|
||||
import {
|
||||
assertWhiteBackgroundUploadFiles,
|
||||
WhiteBackgroundUploadValidationError,
|
||||
} from '@/lib/standard-model-white-background-upload-guard'
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
@ -16,6 +21,18 @@ jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/material-authorization', () => ({
|
||||
canManageMaterial: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/standard-model-white-background-upload-guard', () => {
|
||||
const actual = jest.requireActual('@/lib/standard-model-white-background-upload-guard')
|
||||
return {
|
||||
...actual,
|
||||
assertWhiteBackgroundUploadFiles: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
promises: {
|
||||
mkdir: jest.fn().mockResolvedValue(undefined),
|
||||
@ -95,6 +112,8 @@ describe('white-background upload route', () => {
|
||||
categoryName: '旗舰店',
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ role: 'USER' })
|
||||
;(canManageMaterial as jest.Mock).mockResolvedValue(true)
|
||||
;(assertWhiteBackgroundUploadFiles as jest.Mock).mockResolvedValue(undefined)
|
||||
;(prisma.materialSku.findFirst as jest.Mock).mockResolvedValue({
|
||||
id: 'sku-1',
|
||||
skuName: 'XG014799',
|
||||
@ -136,6 +155,7 @@ describe('white-background upload route', () => {
|
||||
|
||||
function createRequest(formData: FormData) {
|
||||
return {
|
||||
headers: new Headers({ 'content-length': '1024' }),
|
||||
formData: jest.fn().mockResolvedValue(formData),
|
||||
} as any
|
||||
}
|
||||
@ -200,6 +220,58 @@ describe('white-background upload route', () => {
|
||||
expect(body).toEqual({ error: '白底图出图方式必填,且只能是 实拍、渲染、AI' })
|
||||
})
|
||||
|
||||
test('非素材所有者或管理员应在读取 multipart 文件前返回 403', async () => {
|
||||
;(canManageMaterial as jest.Mock).mockResolvedValue(false)
|
||||
const request = {
|
||||
formData: jest.fn(),
|
||||
} as any
|
||||
|
||||
const response = await POST(request, {
|
||||
params: { id: 'material-1' },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(request.formData).not.toHaveBeenCalled()
|
||||
expect(assertWhiteBackgroundUploadFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('图片资源门禁失败时应返回 400 且不进入存储服务', async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('skuId', 'sku-1')
|
||||
formData.append('skuName', 'XG014799')
|
||||
formData.append('sourceType', '渲染')
|
||||
formData.append(
|
||||
'whiteBgRightFront45',
|
||||
new File([Buffer.from('oversized')], 'oversized.png', { type: 'image/png' }),
|
||||
)
|
||||
;(assertWhiteBackgroundUploadFiles as jest.Mock).mockRejectedValueOnce(
|
||||
new WhiteBackgroundUploadValidationError('图片「oversized.png」不能超过 20MB'),
|
||||
)
|
||||
|
||||
const response = await POST(createRequest(formData), {
|
||||
params: { id: 'material-1' },
|
||||
})
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body).toEqual({ error: '图片「oversized.png」不能超过 20MB' })
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('缺少请求大小时应在解析 multipart 前拒绝', async () => {
|
||||
const request = {
|
||||
headers: new Headers(),
|
||||
formData: jest.fn(),
|
||||
} as any
|
||||
|
||||
const response = await POST(request, {
|
||||
params: { id: 'material-1' },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(411)
|
||||
expect(request.formData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('sourceType 非法时应返回 400', async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('skuId', 'sku-1')
|
||||
|
||||
@ -14,6 +14,11 @@ export interface DeleteFileResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DeleteLocalFileOptions {
|
||||
basePath?: string;
|
||||
allowDirectoryDelete?: boolean;
|
||||
}
|
||||
|
||||
function getFileUrlVariants(fileUrl: string): string[] {
|
||||
const trimmed = fileUrl.trim();
|
||||
if (!trimmed) {
|
||||
@ -29,6 +34,43 @@ function getFileUrlVariants(fileUrl: string): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
function getUploadRoot(): string {
|
||||
return path.resolve(process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads'));
|
||||
}
|
||||
|
||||
function isPathStrictlyInside(basePath: string, targetPath: string): boolean {
|
||||
const relativePath = path.relative(basePath, targetPath);
|
||||
return (
|
||||
relativePath.length > 0 &&
|
||||
!relativePath.startsWith(`..${path.sep}`) &&
|
||||
relativePath !== '..' &&
|
||||
!path.isAbsolute(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLocalDeleteTarget(filePath: string, basePath?: string): {
|
||||
allowedRoot: string;
|
||||
absolutePath: string;
|
||||
} {
|
||||
const allowedRoot = path.resolve(basePath || getUploadRoot());
|
||||
const normalizedPath = String(filePath || '').trim().replace(/[\\/]+/g, path.sep);
|
||||
const uploadsPrefix = `${path.sep}uploads${path.sep}`;
|
||||
const relativeUploadsPrefix = `uploads${path.sep}`;
|
||||
|
||||
let absolutePath: string;
|
||||
if (!basePath && normalizedPath.startsWith(uploadsPrefix)) {
|
||||
absolutePath = path.resolve(allowedRoot, normalizedPath.slice(uploadsPrefix.length));
|
||||
} else if (!basePath && normalizedPath.startsWith(relativeUploadsPrefix)) {
|
||||
absolutePath = path.resolve(allowedRoot, normalizedPath.slice(relativeUploadsPrefix.length));
|
||||
} else if (path.isAbsolute(normalizedPath)) {
|
||||
absolutePath = path.resolve(normalizedPath);
|
||||
} else {
|
||||
absolutePath = path.resolve(basePath || process.cwd(), normalizedPath);
|
||||
}
|
||||
|
||||
return { allowedRoot, absolutePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单个文件或目录(统一入口)
|
||||
* @param fileUrl 文件 URL(S3 URL 或本地路径)
|
||||
@ -68,23 +110,24 @@ export async function deleteFile(
|
||||
*/
|
||||
export async function deleteLocalFile(
|
||||
filePath: string,
|
||||
basePath?: string
|
||||
options?: string | DeleteLocalFileOptions,
|
||||
): Promise<DeleteFileResult> {
|
||||
try {
|
||||
// 处理路径
|
||||
let absolutePath: string;
|
||||
if (path.isAbsolute(filePath)) {
|
||||
absolutePath = filePath;
|
||||
} else if (filePath.startsWith('/')) {
|
||||
absolutePath = filePath;
|
||||
} else {
|
||||
absolutePath = path.join(basePath || process.cwd(), filePath);
|
||||
const basePath = typeof options === 'string' ? options : options?.basePath;
|
||||
const allowDirectoryDelete =
|
||||
typeof options === 'object' && options?.allowDirectoryDelete === true;
|
||||
const { allowedRoot, absolutePath } = resolveLocalDeleteTarget(filePath, basePath);
|
||||
if (!isPathStrictlyInside(allowedRoot, absolutePath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: '拒绝删除受控上传目录之外的路径',
|
||||
};
|
||||
}
|
||||
|
||||
// 检查文件/目录是否存在
|
||||
let stats;
|
||||
try {
|
||||
stats = await fs.stat(absolutePath);
|
||||
stats = await fs.lstat(absolutePath);
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
// 文件/目录不存在,不算错误
|
||||
@ -93,9 +136,33 @@ export async function deleteLocalFile(
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (stats.isSymbolicLink()) {
|
||||
return {
|
||||
success: false,
|
||||
error: '拒绝删除符号链接',
|
||||
};
|
||||
}
|
||||
|
||||
const [realAllowedRoot, realTargetPath] = await Promise.all([
|
||||
fs.realpath(allowedRoot),
|
||||
fs.realpath(absolutePath),
|
||||
]);
|
||||
if (!isPathStrictlyInside(realAllowedRoot, realTargetPath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: '拒绝删除受控上传目录之外的路径',
|
||||
};
|
||||
}
|
||||
|
||||
// 根据类型删除
|
||||
if (stats.isDirectory()) {
|
||||
// 目录:递归删除
|
||||
if (!allowDirectoryDelete) {
|
||||
return {
|
||||
success: false,
|
||||
isDirectory: true,
|
||||
error: '目录递归删除必须由受信任调用方明确允许',
|
||||
};
|
||||
}
|
||||
await fs.rm(absolutePath, { recursive: true, force: true });
|
||||
return { success: true, isDirectory: true };
|
||||
} else {
|
||||
@ -221,8 +288,17 @@ export async function deleteFileIfUnreferenced(
|
||||
excludeSkuIds?: string[];
|
||||
excludeMaterialIds?: string[];
|
||||
basePath?: string;
|
||||
allowPhysicalDelete?: boolean;
|
||||
}
|
||||
): Promise<DeleteFileResult & { skipped?: boolean; reason?: string }> {
|
||||
if (options?.allowPhysicalDelete === false) {
|
||||
return {
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: 'physical_delete_disabled',
|
||||
};
|
||||
}
|
||||
|
||||
const referenced = await hasActiveFileReferences(fileUrl, options);
|
||||
if (referenced) {
|
||||
return {
|
||||
|
||||
37
lib/material-authorization.ts
Normal file
37
lib/material-authorization.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import {
|
||||
isSuperAdmin,
|
||||
isSystemAdmin,
|
||||
} from '@/lib/department-permission'
|
||||
|
||||
export interface MaterialAuthorizationUser {
|
||||
id: string
|
||||
role?: string | null
|
||||
isSuperAdmin?: boolean | null
|
||||
}
|
||||
|
||||
export interface MaterialAuthorizationTarget {
|
||||
userId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材写操作的最小统一授权:
|
||||
* - 素材所有者
|
||||
* - 系统管理员(兼容 ADMIN / SYSTEM_ADMIN)
|
||||
* - 超级管理员
|
||||
*
|
||||
* 部门查看权限不能自动升级为素材写权限。
|
||||
*/
|
||||
export async function canManageMaterial(
|
||||
user: MaterialAuthorizationUser,
|
||||
material: MaterialAuthorizationTarget,
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
material.userId === user.id ||
|
||||
isSystemAdmin(user.role) ||
|
||||
user.isSuperAdmin === true
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isSuperAdmin(user.id)
|
||||
}
|
||||
@ -10,6 +10,212 @@
|
||||
*/
|
||||
|
||||
import path from 'path'
|
||||
import { lstat, mkdir } from 'fs/promises'
|
||||
|
||||
export const MAX_SAFE_FILE_NAME_BYTES = 255
|
||||
|
||||
function decodePathForValidation(value: string): string {
|
||||
let decoded = value
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
try {
|
||||
const next = decodeURIComponent(decoded)
|
||||
if (next === decoded) {
|
||||
break
|
||||
}
|
||||
decoded = next
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证客户端文件名是单一叶子文件名,而不是路径。
|
||||
* 返回 trim + NFC 后的文件名;不安全时返回 null。
|
||||
*/
|
||||
export function normalizeSafeFileName(
|
||||
fileName: string,
|
||||
maxBytes: number = MAX_SAFE_FILE_NAME_BYTES,
|
||||
): string | null {
|
||||
if (typeof fileName !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalized = fileName.trim().normalize('NFC')
|
||||
if (
|
||||
!normalized ||
|
||||
normalized === '.' ||
|
||||
normalized === '..' ||
|
||||
Buffer.byteLength(normalized, 'utf8') > maxBytes
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const decoded = decodePathForValidation(normalized)
|
||||
const candidates = decoded === normalized ? [normalized] : [normalized, decoded]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
path.posix.isAbsolute(candidate) ||
|
||||
path.win32.isAbsolute(candidate) ||
|
||||
/^[A-Za-z]:/.test(candidate) ||
|
||||
candidate.startsWith('\\\\') ||
|
||||
candidate.includes('/') ||
|
||||
candidate.includes('\\') ||
|
||||
candidate.includes('..') ||
|
||||
/[\x00-\x1f\x7f]/.test(candidate) ||
|
||||
/[<>:"|?*]/.test(candidate)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* 将路径解析为 basePath 的严格子项。不能返回 basePath 本身。
|
||||
*/
|
||||
export function resolveStrictChildPath(
|
||||
basePath: string,
|
||||
...segments: string[]
|
||||
): string | null {
|
||||
if (
|
||||
!basePath ||
|
||||
segments.length === 0 ||
|
||||
segments.some((segment) => (
|
||||
!segment ||
|
||||
segment === '.' ||
|
||||
segment === '..' ||
|
||||
segment.includes('/') ||
|
||||
segment.includes('\\') ||
|
||||
path.posix.isAbsolute(segment) ||
|
||||
path.win32.isAbsolute(segment) ||
|
||||
/^[A-Za-z]:/.test(segment)
|
||||
))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedBase = path.resolve(basePath)
|
||||
const targetPath = path.resolve(normalizedBase, ...segments)
|
||||
const relativePath = path.relative(normalizedBase, targetPath)
|
||||
|
||||
if (
|
||||
!relativePath ||
|
||||
relativePath === '..' ||
|
||||
relativePath.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relativePath)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return targetPath
|
||||
}
|
||||
|
||||
/**
|
||||
* 在可信根目录下逐级创建目录,并拒绝任何已存在的符号链接或非目录节点。
|
||||
*/
|
||||
export async function ensureSafeDirectoryWithinRoot(
|
||||
basePath: string,
|
||||
...segments: string[]
|
||||
): Promise<string | null> {
|
||||
const targetPath = resolveStrictChildPath(basePath, ...segments)
|
||||
if (!targetPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedBase = path.resolve(basePath)
|
||||
await mkdir(normalizedBase, { recursive: true })
|
||||
|
||||
const baseStats = await lstat(normalizedBase)
|
||||
if (baseStats.isSymbolicLink() || !baseStats.isDirectory()) {
|
||||
return null
|
||||
}
|
||||
|
||||
let currentPath = normalizedBase
|
||||
for (const segment of segments) {
|
||||
const nextPath = resolveStrictChildPath(currentPath, segment)
|
||||
if (!nextPath || path.relative(normalizedBase, nextPath).startsWith(`..${path.sep}`)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await lstat(nextPath)
|
||||
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
||||
return null
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
await mkdir(nextPath)
|
||||
const createdStats = await lstat(nextPath)
|
||||
if (createdStats.isSymbolicLink() || !createdStats.isDirectory()) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
currentPath = nextPath
|
||||
}
|
||||
|
||||
return currentPath === targetPath ? currentPath : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 basePath 到 targetPath 的所有现存节点是否都不是符号链接。
|
||||
*/
|
||||
export async function isPathFreeOfSymbolicLinks(
|
||||
basePath: string,
|
||||
targetPath: string,
|
||||
options?: {
|
||||
allowMissingLeaf?: boolean
|
||||
allowMissingDescendants?: boolean
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const normalizedBase = path.resolve(basePath)
|
||||
const normalizedTarget = path.resolve(targetPath)
|
||||
const relativePath = path.relative(normalizedBase, normalizedTarget)
|
||||
|
||||
if (
|
||||
!relativePath ||
|
||||
relativePath === '..' ||
|
||||
relativePath.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relativePath)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const baseStats = await lstat(normalizedBase).catch(() => null)
|
||||
if (!baseStats || baseStats.isSymbolicLink() || !baseStats.isDirectory()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parts = relativePath.split(path.sep).filter(Boolean)
|
||||
let currentPath = normalizedBase
|
||||
for (let index = 0; index < parts.length; index += 1) {
|
||||
currentPath = path.join(currentPath, parts[index])
|
||||
const stats = await lstat(currentPath).catch((error: any) => {
|
||||
if (error?.code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
})
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
options?.allowMissingDescendants === true ||
|
||||
(options?.allowMissingLeaf === true && index === parts.length - 1)
|
||||
)
|
||||
}
|
||||
if (stats.isSymbolicLink()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路径是否包含路径遍历字符
|
||||
|
||||
94
lib/s3.ts
94
lib/s3.ts
@ -18,6 +18,90 @@ export function getS3KeyFromUrl(fileUrl: string): string {
|
||||
return key
|
||||
}
|
||||
|
||||
function decodeObjectKeyForValidation(value: string): string {
|
||||
let decoded = value
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
try {
|
||||
const next = decodeURIComponent(decoded)
|
||||
if (next === decoded) break
|
||||
decoded = next
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
function normalizeObjectPrefix(value: string | undefined): string {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除属于高风险写操作,只接受当前配置的公网域名或 S3 Endpoint。
|
||||
* 同时限制到当前业务前缀,拒绝把任意 HTTP URL 当作本桶对象 Key。
|
||||
*/
|
||||
export function getTrustedS3MutationKey(fileUrl: string): string {
|
||||
const target = new URL(fileUrl)
|
||||
if (
|
||||
!['http:', 'https:'].includes(target.protocol) ||
|
||||
target.username ||
|
||||
target.password
|
||||
) {
|
||||
throw new Error('拒绝不受信任的对象存储地址')
|
||||
}
|
||||
|
||||
const configuredUrls = [
|
||||
process.env.S3_PUBLIC_DOMAIN,
|
||||
cleanS3Endpoint(process.env.S3_ENDPOINT),
|
||||
]
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)
|
||||
.map((value) => new URL(value))
|
||||
|
||||
const trustedBase = configuredUrls.find((configured) => {
|
||||
if (configured.origin !== target.origin) {
|
||||
return false
|
||||
}
|
||||
|
||||
const basePath = configured.pathname.replace(/\/+$/g, '')
|
||||
return (
|
||||
!basePath ||
|
||||
basePath === '/' ||
|
||||
target.pathname === basePath ||
|
||||
target.pathname.startsWith(`${basePath}/`)
|
||||
)
|
||||
})
|
||||
if (!trustedBase) {
|
||||
throw new Error('拒绝不受信任的对象存储地址')
|
||||
}
|
||||
|
||||
const key = decodeObjectKeyForValidation(getS3KeyFromUrl(fileUrl))
|
||||
.replace(/^\/+/g, '')
|
||||
const keySegments = key.split('/')
|
||||
if (
|
||||
!key ||
|
||||
key.includes('\\') ||
|
||||
/[\x00-\x1f\x7f]/.test(key) ||
|
||||
keySegments.some((segment) => !segment || segment === '.' || segment === '..')
|
||||
) {
|
||||
throw new Error('拒绝不安全的对象存储 Key')
|
||||
}
|
||||
|
||||
const requiredPrefix = normalizeObjectPrefix(process.env.S3_FILE_PATH)
|
||||
if (
|
||||
requiredPrefix &&
|
||||
key !== requiredPrefix &&
|
||||
!key.startsWith(`${requiredPrefix}/`)
|
||||
) {
|
||||
throw new Error('拒绝删除当前业务前缀之外的对象')
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 URL 是否为 HTTP/HTTPS URL
|
||||
*/
|
||||
@ -30,7 +114,13 @@ export function isHttpUrl(url: string): boolean {
|
||||
* 判断 URL 是否为 S3 URL
|
||||
*/
|
||||
export function isS3Url(url: string): boolean {
|
||||
return isHttpUrl(url)
|
||||
if (!isHttpUrl(url)) return false
|
||||
try {
|
||||
getTrustedS3MutationKey(url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -195,7 +285,7 @@ export async function downloadFromS3(fileUrl: string): Promise<Buffer> {
|
||||
|
||||
export async function deleteFromS3(fileUrl: string): Promise<void> {
|
||||
try {
|
||||
const key = getS3KeyFromUrl(fileUrl)
|
||||
const key = getTrustedS3MutationKey(fileUrl)
|
||||
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: process.env.S3_BUCKET!,
|
||||
|
||||
157
lib/standard-model-white-background-upload-guard.ts
Normal file
157
lib/standard-model-white-background-upload-guard.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import sharp from 'sharp'
|
||||
import type {
|
||||
StandardModelWhiteBgKey,
|
||||
} from '@/lib/standard-model-white-background'
|
||||
|
||||
export const STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS = {
|
||||
maxOtherImages: 12,
|
||||
maxTotalImages: 20,
|
||||
maxBytesPerImage: 20 * 1024 * 1024,
|
||||
maxTotalBytes: 100 * 1024 * 1024,
|
||||
maxPixelsPerImage: 40_000_000,
|
||||
maxTotalPixels: 160_000_000,
|
||||
} as const
|
||||
|
||||
const ALLOWED_IMAGE_MIME_TYPES = new Set([
|
||||
'image/avif',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
])
|
||||
|
||||
const ALLOWED_IMAGE_FORMATS = new Set([
|
||||
'avif',
|
||||
'jpeg',
|
||||
'png',
|
||||
'webp',
|
||||
])
|
||||
|
||||
export class WhiteBackgroundUploadValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'WhiteBackgroundUploadValidationError'
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWhiteBackgroundImageDimensions(
|
||||
width: number,
|
||||
height: number,
|
||||
label = '图片',
|
||||
): number {
|
||||
if (
|
||||
!Number.isInteger(width) ||
|
||||
!Number.isInteger(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
throw new WhiteBackgroundUploadValidationError(`${label}尺寸无效`)
|
||||
}
|
||||
|
||||
const pixels = width * height
|
||||
if (pixels > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxPixelsPerImage) {
|
||||
throw new WhiteBackgroundUploadValidationError(`${label}像素总量过大`)
|
||||
}
|
||||
return pixels
|
||||
}
|
||||
|
||||
function assertUploadFile(value: File, label: string): void {
|
||||
if (
|
||||
!value ||
|
||||
typeof value.name !== 'string' ||
|
||||
typeof value.arrayBuffer !== 'function' ||
|
||||
!Number.isFinite(value.size) ||
|
||||
value.size <= 0
|
||||
) {
|
||||
throw new WhiteBackgroundUploadValidationError(`${label}不是有效图片文件`)
|
||||
}
|
||||
|
||||
if (!ALLOWED_IMAGE_MIME_TYPES.has(String(value.type || '').toLowerCase())) {
|
||||
throw new WhiteBackgroundUploadValidationError(
|
||||
`${label}格式不支持,仅允许 JPG、PNG、WebP 或 AVIF`,
|
||||
)
|
||||
}
|
||||
|
||||
if (value.size > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxBytesPerImage) {
|
||||
throw new WhiteBackgroundUploadValidationError(`${label}不能超过 20MB`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertWhiteBackgroundUploadFiles(input: {
|
||||
fixedImages: Partial<Record<StandardModelWhiteBgKey, File | null>>
|
||||
otherImages: File[]
|
||||
}): Promise<void> {
|
||||
if (input.otherImages.length > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxOtherImages) {
|
||||
throw new WhiteBackgroundUploadValidationError(
|
||||
`其他图片最多上传 ${STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxOtherImages} 张`,
|
||||
)
|
||||
}
|
||||
|
||||
const files = [
|
||||
...Object.values(input.fixedImages).filter((file): file is File => Boolean(file)),
|
||||
...input.otherImages,
|
||||
]
|
||||
if (files.length === 0) {
|
||||
throw new WhiteBackgroundUploadValidationError('请至少上传 1 张白底图')
|
||||
}
|
||||
if (files.length > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxTotalImages) {
|
||||
throw new WhiteBackgroundUploadValidationError(
|
||||
`单次最多上传 ${STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxTotalImages} 张白底图`,
|
||||
)
|
||||
}
|
||||
|
||||
let totalBytes = 0
|
||||
for (let index = 0; index < files.length; index++) {
|
||||
const file = files[index]
|
||||
const label = `图片「${file.name || index + 1}」`
|
||||
assertUploadFile(file, label)
|
||||
totalBytes += file.size
|
||||
}
|
||||
if (totalBytes > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxTotalBytes) {
|
||||
throw new WhiteBackgroundUploadValidationError('单次上传白底图总体积不能超过 100MB')
|
||||
}
|
||||
|
||||
let totalPixels = 0
|
||||
for (let index = 0; index < files.length; index++) {
|
||||
const file = files[index]
|
||||
const label = `图片「${file.name || index + 1}」`
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const metadata = await sharp(buffer, {
|
||||
limitInputPixels: STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxPixelsPerImage,
|
||||
sequentialRead: true,
|
||||
failOn: 'error',
|
||||
}).metadata()
|
||||
|
||||
if (
|
||||
!metadata.width ||
|
||||
!metadata.height ||
|
||||
!metadata.format ||
|
||||
!ALLOWED_IMAGE_FORMATS.has(metadata.format)
|
||||
) {
|
||||
throw new WhiteBackgroundUploadValidationError(`${label}不是支持的图片`)
|
||||
}
|
||||
if (Number(metadata.pages || 1) > 1) {
|
||||
throw new WhiteBackgroundUploadValidationError(`${label}不支持动图或多页图片`)
|
||||
}
|
||||
|
||||
const pixels = assertWhiteBackgroundImageDimensions(
|
||||
metadata.width,
|
||||
metadata.height,
|
||||
label,
|
||||
)
|
||||
totalPixels += pixels
|
||||
if (totalPixels > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxTotalPixels) {
|
||||
throw new WhiteBackgroundUploadValidationError('单次上传白底图总像素量过大')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WhiteBackgroundUploadValidationError) {
|
||||
throw error
|
||||
}
|
||||
throw new WhiteBackgroundUploadValidationError(
|
||||
`${label}无法读取或像素总量过大`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,13 @@
|
||||
|
||||
import path from 'path'
|
||||
import { lstat, readFile } from 'fs/promises'
|
||||
import {
|
||||
ensureSafeDirectoryWithinRoot,
|
||||
isPathFreeOfSymbolicLinks,
|
||||
normalizeSafeFileName,
|
||||
resolveStrictChildPath,
|
||||
} from './path-security'
|
||||
import { getUploadRootDir } from './upload-paths'
|
||||
|
||||
export interface UploadSession {
|
||||
fileName: string
|
||||
@ -157,7 +164,7 @@ export function isSafeUploadId(uploadId: string): boolean {
|
||||
}
|
||||
|
||||
function getUploadRoot(): string {
|
||||
return path.resolve(process.env.UPLOAD_DIR || './uploads')
|
||||
return getUploadRootDir()
|
||||
}
|
||||
|
||||
function isPathInside(basePath: string, targetPath: string): boolean {
|
||||
@ -181,7 +188,9 @@ async function getUploadSessionFromMetadata(uploadId: string): Promise<UploadSes
|
||||
const createdAt = Number(metadata?.createdAt)
|
||||
const fileSize = Number(metadata?.fileSize)
|
||||
const totalChunks = Number(metadata?.totalChunks)
|
||||
const fileName = typeof metadata?.fileName === 'string' ? metadata.fileName : ''
|
||||
const fileName = typeof metadata?.fileName === 'string'
|
||||
? normalizeSafeFileName(metadata.fileName)
|
||||
: null
|
||||
const userId = typeof metadata?.userId === 'string' ? metadata.userId : ''
|
||||
const ageMs = Date.now() - createdAt
|
||||
|
||||
@ -265,10 +274,20 @@ export async function saveUploadSession(
|
||||
uploadId: string,
|
||||
session: UploadSession
|
||||
): Promise<void> {
|
||||
setMemorySession(uploadId, session)
|
||||
const normalizedUploadId = String(uploadId || '').trim()
|
||||
const normalizedFileName = normalizeSafeFileName(session.fileName)
|
||||
if (!isSafeUploadId(normalizedUploadId) || !normalizedFileName) {
|
||||
throw new Error('上传会话包含不安全的标识或文件名')
|
||||
}
|
||||
|
||||
const normalizedSession = {
|
||||
...session,
|
||||
fileName: normalizedFileName,
|
||||
}
|
||||
setMemorySession(normalizedUploadId, normalizedSession)
|
||||
await withRedisFallback(
|
||||
'saveUploadSession',
|
||||
() => redisModule!.saveUploadSession(uploadId, session),
|
||||
() => redisModule!.saveUploadSession(normalizedUploadId, normalizedSession),
|
||||
async () => {}
|
||||
)
|
||||
}
|
||||
@ -311,7 +330,12 @@ export async function getOwnedUploadSession(
|
||||
(await getUploadSession(normalizedUploadId)) ||
|
||||
(await getUploadSessionFromMetadata(normalizedUploadId))
|
||||
|
||||
return session?.userId === normalizedUserId ? session : null
|
||||
return (
|
||||
session?.userId === normalizedUserId &&
|
||||
normalizeSafeFileName(session.fileName) !== null
|
||||
)
|
||||
? session
|
||||
: null
|
||||
}
|
||||
|
||||
export async function getOwnedUploadedFile(
|
||||
@ -393,38 +417,69 @@ export async function resolveOwnedMergedUpload(input: {
|
||||
* 返回组装后的完整文件路径
|
||||
*/
|
||||
export async function getUploadedFile(uploadId: string): Promise<string | null> {
|
||||
const session = await getUploadSession(uploadId);
|
||||
const normalizedUploadId = String(uploadId || '').trim()
|
||||
if (!isSafeUploadId(normalizedUploadId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const session = await getUploadSession(normalizedUploadId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = await import('path');
|
||||
const { readdir, readFile, mkdir, access, open, stat } = await import('fs/promises');
|
||||
|
||||
// Chunk文件所在目录
|
||||
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId);
|
||||
|
||||
// 输出目录和路径
|
||||
const outputDir = path.join(process.env.UPLOAD_DIR || './uploads', 'assembled');
|
||||
const outputPath = path.join(outputDir, `${uploadId}-${session.fileName}`);
|
||||
const safeFileName = normalizeSafeFileName(session.fileName)
|
||||
if (!safeFileName) {
|
||||
console.warn(`[Upload Session] Unsafe file name rejected: ${normalizedUploadId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const { readdir, readFile, open, lstat, unlink } = await import('fs/promises');
|
||||
const uploadRoot = getUploadRoot()
|
||||
const tempDir = resolveStrictChildPath(uploadRoot, 'temp', normalizedUploadId)
|
||||
const outputDir = await ensureSafeDirectoryWithinRoot(uploadRoot, 'assembled')
|
||||
if (!tempDir || !outputDir) {
|
||||
console.warn(`[Upload Session] Unsafe upload directory rejected: ${normalizedUploadId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const outputPath = resolveStrictChildPath(
|
||||
outputDir,
|
||||
`${normalizedUploadId}-${safeFileName}`,
|
||||
)
|
||||
if (
|
||||
!outputPath ||
|
||||
!(await isPathFreeOfSymbolicLinks(uploadRoot, tempDir)) ||
|
||||
!(await isPathFreeOfSymbolicLinks(uploadRoot, outputPath, { allowMissingLeaf: true }))
|
||||
) {
|
||||
console.warn(`[Upload Session] Unsafe upload path rejected: ${normalizedUploadId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
let outputCreated = false
|
||||
|
||||
try {
|
||||
// 先检查文件是否已经存在(避免重复组装)
|
||||
try {
|
||||
await access(outputPath);
|
||||
const existingStats = await stat(outputPath);
|
||||
const existingStats = await lstat(outputPath);
|
||||
if (existingStats.isSymbolicLink() || !existingStats.isFile()) {
|
||||
console.warn(`[Upload Session] Existing output is not a regular file: ${outputPath}`)
|
||||
return null
|
||||
}
|
||||
if (existingStats.size === session.fileSize) {
|
||||
console.log(`[Upload Session] File already exists, skip assembly: ${outputPath}`);
|
||||
return outputPath;
|
||||
}
|
||||
} catch (e) {
|
||||
// 文件不存在,需要组装
|
||||
await unlink(outputPath)
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 读取所有chunk文件
|
||||
const files = await readdir(tempDir);
|
||||
const chunkFiles = files
|
||||
.filter(f => f.startsWith('chunk-'))
|
||||
.filter(f => /^chunk-\d+$/.test(f))
|
||||
.sort((a, b) => {
|
||||
const indexA = parseInt(a.replace('chunk-', ''));
|
||||
const indexB = parseInt(b.replace('chunk-', ''));
|
||||
@ -441,14 +496,16 @@ export async function getUploadedFile(uploadId: string): Promise<string | null>
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建输出目录
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
// 分片逐块写入,避免大文件合并时把所有分片同时堆进内存。
|
||||
const outputHandle = await open(outputPath, 'w');
|
||||
const outputHandle = await open(outputPath, 'wx');
|
||||
outputCreated = true
|
||||
try {
|
||||
for (const chunkFile of chunkFiles) {
|
||||
const chunkPath = path.join(tempDir, chunkFile);
|
||||
const chunkStats = await lstat(chunkPath)
|
||||
if (chunkStats.isSymbolicLink() || !chunkStats.isFile()) {
|
||||
throw new Error(`上传分片不是普通文件: ${chunkFile}`)
|
||||
}
|
||||
const chunkBuffer = await readFile(chunkPath);
|
||||
await outputHandle.write(chunkBuffer);
|
||||
}
|
||||
@ -456,18 +513,27 @@ export async function getUploadedFile(uploadId: string): Promise<string | null>
|
||||
await outputHandle.close();
|
||||
}
|
||||
|
||||
const outputStats = await stat(outputPath);
|
||||
if (outputStats.size !== session.fileSize) {
|
||||
const outputStats = await lstat(outputPath);
|
||||
if (
|
||||
outputStats.isSymbolicLink() ||
|
||||
!outputStats.isFile() ||
|
||||
outputStats.size !== session.fileSize
|
||||
) {
|
||||
console.warn(
|
||||
`[Upload Session] Assembled size mismatch: ${uploadId}, expected=${session.fileSize}, actual=${outputStats.size}`,
|
||||
`[Upload Session] Assembled size mismatch: ${normalizedUploadId}, expected=${session.fileSize}, actual=${outputStats.size}`,
|
||||
);
|
||||
await unlink(outputPath).catch(() => undefined)
|
||||
outputCreated = false
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[Upload Session] Assembled file: ${outputPath} (${outputStats.size} bytes)`);
|
||||
return outputPath;
|
||||
} catch (error) {
|
||||
console.error(`[Upload Session] Failed to get uploaded file for ${uploadId}:`, error);
|
||||
if (outputCreated) {
|
||||
await unlink(outputPath).catch(() => undefined)
|
||||
}
|
||||
console.error(`[Upload Session] Failed to get uploaded file for ${normalizedUploadId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user