修复公网分享链接与下载权限绕过

This commit is contained in:
chenpei 2026-07-10 20:17:34 +08:00
parent 49cfcf3ff7
commit 5da290cbfa
11 changed files with 399 additions and 163 deletions

View File

@ -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;
}
}

View File

@ -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)}"`,

View File

@ -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,

View File

@ -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)

View File

@ -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',

View File

@ -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())

View File

@ -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(['任务-详情'])

View 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'")
})
})

View 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',
)
})
})

View File

@ -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');
}
/**
* 为部门创建默认权限配置
* 使用数据库中实际存在的所有素材类型

View File

@ -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]))
}