修复公网分享链接与下载权限绕过
This commit is contained in:
parent
49cfcf3ff7
commit
5da290cbfa
@ -8,7 +8,6 @@ import {
|
||||
canUserDownloadSource,
|
||||
canUserViewMaterial,
|
||||
isSystemAdmin,
|
||||
isShareLinkAccess,
|
||||
} from '@/lib/department-permission';
|
||||
import { updateMaterialWithSharedRules, type MaterialUpdateInput } from '@/lib/material-update';
|
||||
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
|
||||
@ -138,6 +137,14 @@ export async function GET(
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
let material;
|
||||
|
||||
try {
|
||||
@ -284,8 +291,6 @@ export async function GET(
|
||||
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 权限检查:如果不是分享链接访问,检查查看权限
|
||||
const isShare = isShareLinkAccess(request);
|
||||
let canAccessSource = false;
|
||||
const videoPreviewPolicy = getVideoPreviewPolicyFields(
|
||||
(material as any).materialType,
|
||||
@ -304,79 +309,64 @@ export async function GET(
|
||||
{ status: 403 },
|
||||
);
|
||||
|
||||
if (isShare && videoPreviewPolicy.videoPreviewBlocked) {
|
||||
const userRole = (session.user as any).role;
|
||||
const userDepartment = (session.user as any).department;
|
||||
const managedDepartment = (session.user as any).managedDepartment;
|
||||
const userId = (session.user as any).id;
|
||||
|
||||
// 超级管理员不受限制
|
||||
const { isSuperAdmin } = await import('@/lib/department-permission');
|
||||
const userIsSuperAdmin = await isSuperAdmin(userId);
|
||||
if (!userIsSuperAdmin) {
|
||||
const canView = await canUserViewMaterial(
|
||||
userDepartment,
|
||||
userRole,
|
||||
managedDepartment,
|
||||
(material as any).materialType,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!canView) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限查看该素材' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const canBypassVideoPreviewGate = isSystemAdmin(userRole) || userIsSuperAdmin;
|
||||
if (videoPreviewPolicy.videoPreviewBlocked && !canBypassVideoPreviewGate) {
|
||||
return buildVideoPreviewBlockedResponse();
|
||||
}
|
||||
|
||||
if (!isShare) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session?.user) {
|
||||
const userRole = (session.user as any).role;
|
||||
const userDepartment = (session.user as any).department;
|
||||
const managedDepartment = (session.user as any).managedDepartment;
|
||||
const userId = (session.user as any).id;
|
||||
|
||||
// 超级管理员不受限制
|
||||
const { isSuperAdmin } = await import('@/lib/department-permission');
|
||||
const userIsSuperAdmin = await isSuperAdmin(userId);
|
||||
if (!userIsSuperAdmin) {
|
||||
const canView = await canUserViewMaterial(
|
||||
userDepartment,
|
||||
userRole,
|
||||
managedDepartment,
|
||||
(material as any).materialType,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!canView) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限查看该素材' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const canBypassVideoPreviewGate = isSystemAdmin(userRole) || userIsSuperAdmin;
|
||||
if (videoPreviewPolicy.videoPreviewBlocked && !canBypassVideoPreviewGate) {
|
||||
return buildVideoPreviewBlockedResponse();
|
||||
}
|
||||
|
||||
if (isSystemAdmin(userRole) || userIsSuperAdmin) {
|
||||
canAccessSource = true;
|
||||
} else {
|
||||
const userDownloadSetting = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { canDownload: true },
|
||||
});
|
||||
if (userDownloadSetting?.canDownload) {
|
||||
const canDownloadMaterial = await canUserDownloadMaterial(
|
||||
if (isSystemAdmin(userRole) || userIsSuperAdmin) {
|
||||
canAccessSource = true;
|
||||
} else {
|
||||
const userDownloadSetting = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { canDownload: true },
|
||||
});
|
||||
if (userDownloadSetting?.canDownload) {
|
||||
const canDownloadMaterial = await canUserDownloadMaterial(
|
||||
userDepartment,
|
||||
userRole,
|
||||
managedDepartment,
|
||||
(material as any).materialType,
|
||||
userId
|
||||
);
|
||||
const requiresSourcePermission =
|
||||
isProductPackageType((material as any).materialType) ||
|
||||
is3DOriginalType((material as any).materialType);
|
||||
const hasSourcePermission = requiresSourcePermission
|
||||
? await canUserDownloadSource(
|
||||
userDepartment,
|
||||
userRole,
|
||||
managedDepartment,
|
||||
(material as any).materialType,
|
||||
'详情模板',
|
||||
userId
|
||||
);
|
||||
const requiresSourcePermission =
|
||||
isProductPackageType((material as any).materialType) ||
|
||||
is3DOriginalType((material as any).materialType);
|
||||
const hasSourcePermission = requiresSourcePermission
|
||||
? await canUserDownloadSource(
|
||||
userDepartment,
|
||||
userRole,
|
||||
managedDepartment,
|
||||
'详情模板',
|
||||
userId
|
||||
)
|
||||
: true;
|
||||
canAccessSource = canDownloadMaterial && hasSourcePermission;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 未登录用户且不是分享链接,不允许查看
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
);
|
||||
)
|
||||
: true;
|
||||
canAccessSource = canDownloadMaterial && hasSourcePermission;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,14 +13,13 @@ import path from 'path';
|
||||
import { lookup } from 'mime-types';
|
||||
import {
|
||||
canUserDownloadMaterial,
|
||||
isShareLinkAccess,
|
||||
canUserDownloadSource,
|
||||
} from '@/lib/department-permission';
|
||||
import {
|
||||
DownloadErrorCode,
|
||||
createDownloadError,
|
||||
} from '@/lib/download-errors';
|
||||
import { isProductPackageType } from '@/lib/material-type-mapper';
|
||||
import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper';
|
||||
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log';
|
||||
import { resolveStoredUploadPath } from '@/lib/upload-paths';
|
||||
import {
|
||||
@ -35,35 +34,30 @@ function normalizeStoredLocalPath(filePath: string) {
|
||||
// GET - 下载素材
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const isShare = isShareLinkAccess(request);
|
||||
|
||||
// 如果不是分享链接,需要登录
|
||||
if (!isShare) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
createDownloadError(DownloadErrorCode.UNAUTHORIZED),
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const userRole = (session.user as any).role;
|
||||
|
||||
// 检查用户级别的下载权限
|
||||
if (userRole !== 'ADMIN') {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { canDownload: true },
|
||||
});
|
||||
|
||||
if (!user || !user.canDownload) {
|
||||
return NextResponse.json(
|
||||
createDownloadError(DownloadErrorCode.UNAUTHORIZED),
|
||||
{ status: 401 }
|
||||
createDownloadError(DownloadErrorCode.FORBIDDEN),
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const userRole = (session.user as any).role;
|
||||
|
||||
// 检查用户级别的下载权限
|
||||
if (userRole !== 'ADMIN') {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { canDownload: true },
|
||||
});
|
||||
|
||||
if (!user || !user.canDownload) {
|
||||
return NextResponse.json(
|
||||
createDownloadError(DownloadErrorCode.FORBIDDEN),
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
@ -81,8 +75,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// 查询素材
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id: materialId },
|
||||
const material = await prisma.material.findFirst({
|
||||
where: { id: materialId, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
zipFilePath: true,
|
||||
@ -118,50 +112,46 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 权限检查:如果不是分享链接,检查部门级别的下载权限
|
||||
if (!isShare) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session?.user) {
|
||||
const userRole = (session.user as any).role;
|
||||
const userDepartment = (session.user as any).department;
|
||||
const managedDepartment = (session.user as any).managedDepartment;
|
||||
const userId = (session.user as any).id;
|
||||
|
||||
// 超级管理员不受限制
|
||||
const { isSuperAdmin } = await import('@/lib/department-permission');
|
||||
if (!(await isSuperAdmin(userId))) {
|
||||
const canDownload = await canUserDownloadMaterial(
|
||||
userDepartment,
|
||||
userRole,
|
||||
managedDepartment,
|
||||
material.materialType,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!canDownload) {
|
||||
return NextResponse.json(
|
||||
createDownloadError(DownloadErrorCode.NO_PERMISSION),
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
// 所有下载请求都必须检查部门和源文件权限,查询参数不能绕过。
|
||||
const userDepartment = (session.user as any).department;
|
||||
const managedDepartment = (session.user as any).managedDepartment;
|
||||
|
||||
// 任务-详情:PSD下载权限参考"详情模板"类型的 canDownloadSource 权限
|
||||
if (isProductPackageType(material.materialType) && downloadType === 'psd') {
|
||||
const canDownloadPsd = await canUserDownloadSource(
|
||||
userDepartment,
|
||||
userRole,
|
||||
managedDepartment,
|
||||
'详情模板',
|
||||
userId
|
||||
);
|
||||
|
||||
if (!canDownloadPsd) {
|
||||
return NextResponse.json(
|
||||
createDownloadError(DownloadErrorCode.NO_PERMISSION, '无权限下载 PSD 源文件'),
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
// 超级管理员不受限制
|
||||
const { isSuperAdmin } = await import('@/lib/department-permission');
|
||||
if (!(await isSuperAdmin(userId))) {
|
||||
const canDownload = await canUserDownloadMaterial(
|
||||
userDepartment,
|
||||
userRole,
|
||||
managedDepartment,
|
||||
material.materialType,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!canDownload) {
|
||||
return NextResponse.json(
|
||||
createDownloadError(DownloadErrorCode.NO_PERMISSION),
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 任务-详情 PSD 和任务原图源文件统一检查 canDownloadSource。
|
||||
const requiresSourcePermission =
|
||||
is3DOriginalType(material.materialType) ||
|
||||
(isProductPackageType(material.materialType) && downloadType === 'psd');
|
||||
if (requiresSourcePermission) {
|
||||
const canDownloadSourceFile = await canUserDownloadSource(
|
||||
userDepartment,
|
||||
userRole,
|
||||
managedDepartment,
|
||||
'详情模板',
|
||||
userId
|
||||
);
|
||||
|
||||
if (!canDownloadSourceFile) {
|
||||
return NextResponse.json(
|
||||
createDownloadError(DownloadErrorCode.NO_PERMISSION, '无权限下载该源文件'),
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -326,8 +316,8 @@ async function serveFile(filePath: string, material: any, fileNameSuffix: string
|
||||
'Content-Type': mimeType,
|
||||
'Content-Length': fileStats.size.toString(),
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
|
||||
// 静态文件:长期缓存(1天),允许重新验证
|
||||
'Cache-Control': 'public, max-age=86400, must-revalidate',
|
||||
// 鉴权下载不允许浏览器或前置代理共享缓存。
|
||||
'Cache-Control': 'private, no-store, max-age=0',
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Vary': 'Accept-Encoding',
|
||||
'ETag': `"${Buffer.from(`${filePath}-${fileStats.size}`).toString('base64').slice(0, 27)}"`,
|
||||
|
||||
@ -115,8 +115,8 @@ export async function GET(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const userId = session?.user ? (session.user as any).id : undefined;
|
||||
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id },
|
||||
const material = await prisma.material.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
previewUrl: true,
|
||||
|
||||
@ -14,6 +14,7 @@ import { parseDownloadError } from '@/lib/download-errors'
|
||||
import { validateAndTriggerDownload } from '@/lib/client-download'
|
||||
import { parseApiError, handleError, parseFetchError } from '@/lib/error-handler'
|
||||
import { normalizeVideoMaterialType, isVideoMaterialType as isVideoMaterialTypeValue } from '@/lib/video-material-types'
|
||||
import { buildPublicMaterialShareUrl } from '@/lib/public-app-url'
|
||||
// 首页拆分组件
|
||||
import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home'
|
||||
|
||||
@ -1074,7 +1075,7 @@ export default function Home() {
|
||||
|
||||
// 分享功能
|
||||
const handleShare = async (materialId: string) => {
|
||||
const url = `${window.location.origin}/share/${materialId}`
|
||||
const url = buildPublicMaterialShareUrl(materialId)
|
||||
setShareUrl(url)
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
|
||||
@ -10,7 +10,6 @@ import {
|
||||
canUserDownloadMaterial,
|
||||
canUserDownloadSource,
|
||||
canUserViewMaterial,
|
||||
isShareLinkAccess,
|
||||
isSuperAdmin,
|
||||
isSystemAdmin,
|
||||
} from '@/lib/department-permission'
|
||||
@ -39,7 +38,6 @@ jest.mock('@/lib/department-permission', () => ({
|
||||
canUserDownloadMaterial: jest.fn(),
|
||||
canUserDownloadSource: jest.fn(),
|
||||
canUserViewMaterial: jest.fn(),
|
||||
isShareLinkAccess: jest.fn(),
|
||||
isSuperAdmin: jest.fn(),
|
||||
isSystemAdmin: jest.fn(),
|
||||
}))
|
||||
@ -65,7 +63,6 @@ describe('material edit routes', () => {
|
||||
managedDepartment: null,
|
||||
},
|
||||
})
|
||||
;(isShareLinkAccess as jest.Mock).mockReturnValue(false)
|
||||
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
|
||||
;(isSystemAdmin as jest.Mock).mockReturnValue(false)
|
||||
;(canUserViewMaterial as jest.Mock).mockResolvedValue(true)
|
||||
@ -166,6 +163,23 @@ describe('material edit routes', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.each(['share=1', 'shareId=anything'])(
|
||||
'GET /api/materials/[id] 不能通过 %s 绕过登录校验',
|
||||
async (shareParam) => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
const response = await getMaterialById(
|
||||
new NextRequest(`http://localhost/api/materials/material-1?${shareParam}`),
|
||||
{ params: { id: 'material-1' } },
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(body.error).toBe('请先登录')
|
||||
expect(prisma.material.findFirst).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
test('GET /api/materials/[id] 应将 assets 中的 BigInt fileSize 序列化为字符串', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||||
id: 'material-1',
|
||||
|
||||
@ -3,11 +3,17 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { GET } from '@/app/api/materials/download/route'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import {
|
||||
canUserDownloadMaterial,
|
||||
canUserDownloadSource,
|
||||
isSuperAdmin,
|
||||
} from '@/lib/department-permission'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
findUnique: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
@ -23,13 +29,35 @@ jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/department-permission', () => {
|
||||
const actual = jest.requireActual('@/lib/department-permission')
|
||||
return {
|
||||
...actual,
|
||||
canUserDownloadMaterial: jest.fn(),
|
||||
canUserDownloadSource: jest.fn(),
|
||||
isSuperAdmin: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('materials download route video wait period', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: {
|
||||
id: 'admin-1',
|
||||
role: 'ADMIN',
|
||||
department: '管理部',
|
||||
managedDepartment: null,
|
||||
},
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: true })
|
||||
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
|
||||
;(canUserDownloadMaterial as jest.Mock).mockResolvedValue(true)
|
||||
;(canUserDownloadSource as jest.Mock).mockResolvedValue(true)
|
||||
})
|
||||
|
||||
function mockMaterial(materialType: string, createdAt: Date, zipFilePath = '') {
|
||||
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||||
id: 'material-1',
|
||||
zipFilePath,
|
||||
zipFileName: 'video.mp4',
|
||||
@ -43,6 +71,118 @@ describe('materials download route video wait period', () => {
|
||||
})
|
||||
}
|
||||
|
||||
test.each(['share=1', 'shareId=anything'])(
|
||||
'匿名请求不能通过 %s 绕过下载登录校验',
|
||||
async (shareParam) => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest(`http://localhost/api/materials/download?id=material-1&${shareParam}`),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(body.code).toBe('UNAUTHORIZED')
|
||||
expect(prisma.material.findFirst).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
test('无下载权限用户不能通过 share 参数绕过个人权限', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
role: 'USER',
|
||||
department: '测试部门',
|
||||
managedDepartment: null,
|
||||
},
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: false })
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/download?id=material-1&share=1'),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(body.code).toBe('FORBIDDEN')
|
||||
expect(prisma.material.findFirst).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('已软删除素材不能通过旧 ID 直接下载', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/download?id=material-deleted'),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(body.code).toBe('MATERIAL_NOT_FOUND')
|
||||
expect(prisma.material.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
id: 'material-deleted',
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('任务原图源文件不能通过 share 参数绕过源文件权限', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
role: 'USER',
|
||||
department: '测试部门',
|
||||
managedDepartment: null,
|
||||
},
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: true })
|
||||
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
|
||||
;(canUserDownloadMaterial as jest.Mock).mockResolvedValue(true)
|
||||
;(canUserDownloadSource as jest.Mock).mockResolvedValue(false)
|
||||
mockMaterial('任务-3D原图', new Date('2026-01-01T00:00:00.000Z'), 'uploads/source.zip')
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/download?id=material-1&share=1'),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(body.code).toBe('NO_PERMISSION')
|
||||
expect(canUserDownloadSource).toHaveBeenCalledWith(
|
||||
'测试部门',
|
||||
'USER',
|
||||
null,
|
||||
'详情模板',
|
||||
'user-1',
|
||||
)
|
||||
})
|
||||
|
||||
test('部门下载权限不能通过 share 参数绕过', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
role: 'USER',
|
||||
department: '测试部门',
|
||||
managedDepartment: null,
|
||||
},
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: true })
|
||||
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
|
||||
;(canUserDownloadMaterial as jest.Mock).mockResolvedValue(false)
|
||||
mockMaterial('3D场景', new Date('2026-01-01T00:00:00.000Z'), 'uploads/source.zip')
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/download?id=material-1&share=1'),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(body.code).toBe('NO_PERMISSION')
|
||||
expect(canUserDownloadSource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('非安装视频未满 15 天时应禁止下载,分享链接也不能绕过', async () => {
|
||||
mockMaterial('淘系-主图视频', new Date())
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import { getServerSession } from 'next-auth'
|
||||
|
||||
import { GET } from '@/app/api/materials/route'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getUserViewableMaterialTypes, isShareLinkAccess, isSuperAdmin, isSystemAdmin } from '@/lib/department-permission'
|
||||
import { getUserViewableMaterialTypes, isSuperAdmin, isSystemAdmin } from '@/lib/department-permission'
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
@ -43,7 +43,6 @@ jest.mock('@/lib/prisma', () => ({
|
||||
jest.mock('@/lib/department-permission', () => ({
|
||||
isSystemAdmin: jest.fn(),
|
||||
getUserViewableMaterialTypes: jest.fn(),
|
||||
isShareLinkAccess: jest.fn(),
|
||||
isSuperAdmin: jest.fn(),
|
||||
}))
|
||||
|
||||
@ -99,7 +98,6 @@ describe('materials route ai generated', () => {
|
||||
isSuperAdmin: false,
|
||||
},
|
||||
})
|
||||
;(isShareLinkAccess as jest.Mock).mockReturnValue(false)
|
||||
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
|
||||
;(isSystemAdmin as jest.Mock).mockReturnValue(false)
|
||||
;(getUserViewableMaterialTypes as jest.Mock).mockResolvedValue(['任务-详情'])
|
||||
|
||||
60
lib/__tests__/materials-share-security.test.ts
Normal file
60
lib/__tests__/materials-share-security.test.ts
Normal file
@ -0,0 +1,60 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { GET } from '@/app/api/materials/share/route'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
describe('materials share security', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(getServerSession as jest.Mock).mockResolvedValue(null)
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(null)
|
||||
})
|
||||
|
||||
test('公开分享只查询未删除素材', async () => {
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/share?id=material-deleted'),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(body.error).toBe('素材不存在')
|
||||
expect(prisma.material.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
id: 'material-deleted',
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('鉴权下载响应禁止共享缓存', () => {
|
||||
const routeSource = fs.readFileSync(
|
||||
path.join(process.cwd(), 'app/api/materials/download/route.ts'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(routeSource).toContain("'Cache-Control': 'private, no-store, max-age=0'")
|
||||
expect(routeSource).not.toContain("'Cache-Control': 'public, max-age=86400, must-revalidate'")
|
||||
})
|
||||
})
|
||||
47
lib/__tests__/public-app-url.test.ts
Normal file
47
lib/__tests__/public-app-url.test.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { buildPublicMaterialShareUrl, getPublicAppOrigin } from '@/lib/public-app-url'
|
||||
|
||||
const originalNextPublicAppUrl = process.env.NEXT_PUBLIC_APP_PUBLIC_URL
|
||||
const originalAppPublicUrl = process.env.APP_PUBLIC_URL
|
||||
const originalNextAuthUrl = process.env.NEXTAUTH_URL
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name]
|
||||
return
|
||||
}
|
||||
process.env[name] = value
|
||||
}
|
||||
|
||||
describe('public app origin', () => {
|
||||
afterEach(() => {
|
||||
restoreEnv('NEXT_PUBLIC_APP_PUBLIC_URL', originalNextPublicAppUrl)
|
||||
restoreEnv('APP_PUBLIC_URL', originalAppPublicUrl)
|
||||
restoreEnv('NEXTAUTH_URL', originalNextAuthUrl)
|
||||
})
|
||||
|
||||
test('uses the configured public origin', () => {
|
||||
process.env.NEXT_PUBLIC_APP_PUBLIC_URL = 'https://sc.jiansun.vip/some-path'
|
||||
process.env.APP_PUBLIC_URL = 'http://192.168.58.20:4320'
|
||||
process.env.NEXTAUTH_URL = 'http://localhost:3000'
|
||||
|
||||
expect(getPublicAppOrigin()).toBe('https://sc.jiansun.vip')
|
||||
})
|
||||
|
||||
test.each([
|
||||
'http://192.168.58.20:4320',
|
||||
'http://10.0.0.8:3000',
|
||||
'http://172.16.0.9:3000',
|
||||
'http://localhost:3000',
|
||||
])('never exposes a private origin: %s', (privateOrigin) => {
|
||||
process.env.NEXT_PUBLIC_APP_PUBLIC_URL = privateOrigin
|
||||
process.env.APP_PUBLIC_URL = privateOrigin
|
||||
process.env.NEXTAUTH_URL = privateOrigin
|
||||
|
||||
expect(getPublicAppOrigin()).toBe('https://sc.jiansun.vip')
|
||||
expect(buildPublicMaterialShareUrl('material/id')).toBe(
|
||||
'https://sc.jiansun.vip/share/material%2Fid',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -440,14 +440,6 @@ export async function canUserDownloadMaterial(
|
||||
return permission?.canDownload ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是分享链接访问
|
||||
*/
|
||||
export function isShareLinkAccess(request: Request): boolean {
|
||||
const url = new URL(request.url);
|
||||
return url.searchParams.has('share') || url.searchParams.has('shareId');
|
||||
}
|
||||
|
||||
/**
|
||||
* 为部门创建默认权限配置
|
||||
* 使用数据库中实际存在的所有素材类型
|
||||
|
||||
@ -46,6 +46,10 @@ export function getPublicAppOrigin(): string {
|
||||
return DEFAULT_PUBLIC_APP_URL
|
||||
}
|
||||
|
||||
export function buildPublicMaterialShareUrl(materialId: string): string {
|
||||
return `${getPublicAppOrigin()}/share/${encodeURIComponent(materialId)}`
|
||||
}
|
||||
|
||||
export function getAllowedPublicAppOrigins(): string[] {
|
||||
return Array.from(new Set([getPublicAppOrigin(), ...LEGACY_PUBLIC_APP_ORIGINS]))
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user