diff --git a/app/api/materials/[id]/assets/route.ts b/app/api/materials/[id]/assets/route.ts index 471cd2a..63060be 100644 --- a/app/api/materials/[id]/assets/route.ts +++ b/app/api/materials/[id]/assets/route.ts @@ -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({ diff --git a/app/api/materials/[id]/skus/[skuId]/route.ts b/app/api/materials/[id]/skus/[skuId]/route.ts index 4f47d1a..9772107 100644 --- a/app/api/materials/[id]/skus/[skuId]/route.ts +++ b/app/api/materials/[id]/skus/[skuId]/route.ts @@ -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 diff --git a/app/api/materials/[id]/white-background/route.ts b/app/api/materials/[id]/white-background/route.ts index ce82389..37e915a 100644 --- a/app/api/materials/[id]/white-background/route.ts +++ b/app/api/materials/[id]/white-background/route.ts @@ -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>) - 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({ diff --git a/app/api/materials/upload/cleanup/route.ts b/app/api/materials/upload/cleanup/route.ts index c381bd7..25540a3 100644 --- a/app/api/materials/upload/cleanup/route.ts +++ b/app/api/materials/upload/cleanup/route.ts @@ -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) { // 如果之前没有统计到文件,至少算删除了目录本身 diff --git a/app/api/materials/upload/init/route.test.ts b/app/api/materials/upload/init/route.test.ts index 881a74f..a459595 100644 --- a/app/api/materials/upload/init/route.test.ts +++ b/app/api/materials/upload/init/route.test.ts @@ -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() + }) }) diff --git a/app/api/materials/upload/init/route.ts b/app/api/materials/upload/init/route.ts index f737783..3fe1151 100644 --- a/app/api/materials/upload/init/route.ts +++ b/app/api/materials/upload/init/route.ts @@ -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 = { diff --git a/app/api/materials/upload/product-package/abort/route.ts b/app/api/materials/upload/product-package/abort/route.ts index 853c86b..a8d22ad 100644 --- a/app/api/materials/upload/product-package/abort/route.ts +++ b/app/api/materials/upload/product-package/abort/route.ts @@ -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 { + 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, diff --git a/app/api/upload/psd/route.test.ts b/app/api/upload/psd/route.test.ts new file mode 100644 index 0000000..e6d5301 --- /dev/null +++ b/app/api/upload/psd/route.test.ts @@ -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 | null + materialId?: string + contentLength?: string | null +}) { + const values = new Map([ + ['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() + }) +}) diff --git a/app/api/upload/psd/route.ts b/app/api/upload/psd/route.ts index 596c7e9..01ad44a 100644 --- a/app/api/upload/psd/route.ts +++ b/app/api/upload/psd/route.ts @@ -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) { diff --git a/components/upload/ProductPackageUploader.test.tsx b/components/upload/ProductPackageUploader.test.tsx index 4bbb796..560abdb 100644 --- a/components/upload/ProductPackageUploader.test.tsx +++ b/components/upload/ProductPackageUploader.test.tsx @@ -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() diff --git a/lib/__tests__/file-cleanup-security.test.ts b/lib/__tests__/file-cleanup-security.test.ts new file mode 100644 index 0000000..5a7bd9e --- /dev/null +++ b/lib/__tests__/file-cleanup-security.test.ts @@ -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() + }) +}) diff --git a/lib/__tests__/material-assets-authorization.test.ts b/lib/__tests__/material-assets-authorization.test.ts index 88195dc..dfdeda0 100644 --- a/lib/__tests__/material-assets-authorization.test.ts +++ b/lib/__tests__/material-assets-authorization.test.ts @@ -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, + }) + }) }) diff --git a/lib/__tests__/material-authorization.test.ts b/lib/__tests__/material-authorization.test.ts new file mode 100644 index 0000000..ce67d12 --- /dev/null +++ b/lib/__tests__/material-authorization.test.ts @@ -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) + }) +}) diff --git a/lib/__tests__/path-security.test.ts b/lib/__tests__/path-security.test.ts index 572820d..f1d02f5 100644 --- a/lib/__tests__/path-security.test.ts +++ b/lib/__tests__/path-security.test.ts @@ -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() + }) + }) }) diff --git a/lib/__tests__/product-package-abort-route-security.test.ts b/lib/__tests__/product-package-abort-route-security.test.ts new file mode 100644 index 0000000..00fb4ee --- /dev/null +++ b/lib/__tests__/product-package-abort-route-security.test.ts @@ -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-`) + } + }) +}) diff --git a/lib/__tests__/s3-delete.test.ts b/lib/__tests__/s3-delete.test.ts index 733d6be..00057b7 100644 --- a/lib/__tests__/s3-delete.test.ts +++ b/lib/__tests__/s3-delete.test.ts @@ -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() + }) }) diff --git a/lib/__tests__/standard-model-white-background-upload-guard.test.ts b/lib/__tests__/standard-model-white-background-upload-guard.test.ts new file mode 100644 index 0000000..8b32d1f --- /dev/null +++ b/lib/__tests__/standard-model-white-background-upload-guard.test.ts @@ -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) + }) +}) diff --git a/lib/__tests__/upload-sessions-security.test.ts b/lib/__tests__/upload-sessions-security.test.ts new file mode 100644 index 0000000..cf94819 --- /dev/null +++ b/lib/__tests__/upload-sessions-security.test.ts @@ -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') + }) +}) diff --git a/lib/__tests__/white-background-upload-route.test.ts b/lib/__tests__/white-background-upload-route.test.ts index 19ffc71..fd66319 100644 --- a/lib/__tests__/white-background-upload-route.test.ts +++ b/lib/__tests__/white-background-upload-route.test.ts @@ -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') diff --git a/lib/file-cleanup.ts b/lib/file-cleanup.ts index a309a5d..31af266 100644 --- a/lib/file-cleanup.ts +++ b/lib/file-cleanup.ts @@ -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 { 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 { + if (options?.allowPhysicalDelete === false) { + return { + success: true, + skipped: true, + reason: 'physical_delete_disabled', + }; + } + const referenced = await hasActiveFileReferences(fileUrl, options); if (referenced) { return { diff --git a/lib/material-authorization.ts b/lib/material-authorization.ts new file mode 100644 index 0000000..de6f79e --- /dev/null +++ b/lib/material-authorization.ts @@ -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 { + if ( + material.userId === user.id || + isSystemAdmin(user.role) || + user.isSuperAdmin === true + ) { + return true + } + + return isSuperAdmin(user.id) +} diff --git a/lib/path-security.ts b/lib/path-security.ts index 7a22fad..81bab9b 100644 --- a/lib/path-security.ts +++ b/lib/path-security.ts @@ -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 { + 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 { + 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 +} /** * 检查路径是否包含路径遍历字符 diff --git a/lib/s3.ts b/lib/s3.ts index 436490b..5e0291a 100644 --- a/lib/s3.ts +++ b/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 { export async function deleteFromS3(fileUrl: string): Promise { try { - const key = getS3KeyFromUrl(fileUrl) + const key = getTrustedS3MutationKey(fileUrl) const command = new DeleteObjectCommand({ Bucket: process.env.S3_BUCKET!, diff --git a/lib/standard-model-white-background-upload-guard.ts b/lib/standard-model-white-background-upload-guard.ts new file mode 100644 index 0000000..995f299 --- /dev/null +++ b/lib/standard-model-white-background-upload-guard.ts @@ -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> + otherImages: File[] +}): Promise { + 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}无法读取或像素总量过大`, + ) + } + } +} diff --git a/lib/upload-sessions.ts b/lib/upload-sessions.ts index 91bc7df..efb932a 100644 --- a/lib/upload-sessions.ts +++ b/lib/upload-sessions.ts @@ -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 { - 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 { - 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 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 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; } }