diff --git a/apps/server/package.json b/apps/server/package.json index 896065cf..41cb0e17 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -14,6 +14,7 @@ "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "generate:student-import": "ts-node -r tsconfig-paths/register -P tsconfig.json scripts/generate-student-import-xlsx.ts", + "encrypt:integration-secrets": "ts-node -r tsconfig-paths/register -P tsconfig.json scripts/encrypt-integration-secrets.ts", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "lint:fix": "npm run lint -- --fix", "typecheck": "tsc -p tsconfig.build.json --noEmit", diff --git a/apps/server/scripts/encrypt-integration-secrets.ts b/apps/server/scripts/encrypt-integration-secrets.ts new file mode 100644 index 00000000..c4fe2382 --- /dev/null +++ b/apps/server/scripts/encrypt-integration-secrets.ts @@ -0,0 +1,67 @@ +/// + +import datasource from '../datasource'; +import { encryptSecret, isEncryptedSecret } from '../src/integration/config/secret-crypto'; +import { IntegrationConfigDetail } from '../src/integration/entities/integration-config.entity'; + +/** integration_config_detail.content JSON 中与本次回填相关的结构。 */ +interface StoredConfigContent { + type?: unknown; + verify?: unknown; + config?: Record; +} + +async function main(): Promise { + // 主动检查加密密钥:缺失时 getEncryptionKey() 会静默使用开发回退密钥, + // 绝不能用回退密钥加密生产数据,因此未配置时直接报错退出。 + if (!process.env.AI_CONFIG_ENCRYPTION_KEY) { + throw new Error( + 'AI_CONFIG_ENCRYPTION_KEY 未设置:为避免使用开发回退密钥加密数据,请先配置 AI_CONFIG_ENCRYPTION_KEY 再运行回填脚本', + ); + } + + await datasource.initialize(); + console.log('已连接数据库,开始回填第三方集成配置 appSecret 加密...'); + + try { + const repo = datasource.getRepository(IntegrationConfigDetail); + const rows = await repo.find(); + let processed = 0; + + for (const row of rows) { + if (!row.content) continue; + + let parsed: StoredConfigContent; + try { + parsed = JSON.parse(row.content) as StoredConfigContent; + } catch { + continue; + } + + const config = parsed.config; + if (!config || typeof config !== 'object' || Array.isArray(config)) continue; + + const appSecret = config.appSecret; + if (typeof appSecret !== 'string' || !appSecret || isEncryptedSecret(appSecret)) continue; + + config.appSecret = encryptSecret(appSecret); + row.content = JSON.stringify(parsed); + await repo.save(row); + processed += 1; + } + + console.log(`回填完成:共处理 ${processed} 行(加密 appSecret)`); + } finally { + // 无论成功还是失败都关闭连接池,避免泄漏 + try { + await datasource.destroy(); + } catch { + // 销毁失败不影响主流程结果 + } + } +} + +main().catch((error) => { + console.error('回填失败:', error); + process.exitCode = 1; +}); diff --git a/apps/server/src/ai-chat/ai-chat.controller.ts b/apps/server/src/ai-chat/ai-chat.controller.ts index 49339f9f..4c1101b1 100644 --- a/apps/server/src/ai-chat/ai-chat.controller.ts +++ b/apps/server/src/ai-chat/ai-chat.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -108,6 +109,7 @@ export class AiChatController { @Req() req: AuthenticatedRequest, @UploadedFile() file: Express.Multer.File, ) { + if (!file) throw new BadRequestException('缺少上传文件'); const attachment = await this.attachmentService.upload(req.user.id, file); return { success: true, data: this.attachmentService.serialize(attachment) }; } diff --git a/apps/server/src/ai-chat/ai-review.import-basic.ts b/apps/server/src/ai-chat/ai-review.import-basic.ts index e01f894a..5199cbfe 100644 --- a/apps/server/src/ai-chat/ai-review.import-basic.ts +++ b/apps/server/src/ai-chat/ai-review.import-basic.ts @@ -7,6 +7,7 @@ import { RoomsService } from '../rooms/rooms.service'; import type { AiReviewSection } from './entities/ai-review.entity'; import { MAX_CAPACITY, normalizePhone } from './ai-review.shared'; import { resolveOrganizationId } from './ai-review.enrich'; +import { addDaysToDateOnly } from '../common/china-time'; export async function importStudents( section: AiReviewSection | undefined, @@ -172,10 +173,5 @@ export function normalizeFloor(raw: unknown): number | null { } export function nextDay(date: string): string { - const parsed = new Date(`${date}T00:00:00+08:00`); - parsed.setDate(parsed.getDate() + 1); - const year = parsed.getFullYear(); - const month = String(parsed.getMonth() + 1).padStart(2, '0'); - const day = String(parsed.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; + return addDaysToDateOnly(date, 1); } diff --git a/apps/server/src/ai-chat/dto/ai-chat.dto.ts b/apps/server/src/ai-chat/dto/ai-chat.dto.ts index 6b16241e..da4adab9 100644 --- a/apps/server/src/ai-chat/dto/ai-chat.dto.ts +++ b/apps/server/src/ai-chat/dto/ai-chat.dto.ts @@ -1,18 +1,5 @@ import { Type } from 'class-transformer'; -import { - ArrayMaxSize, - IsArray, - IsIn, - IsInt, - IsNotEmpty, - IsObject, - IsOptional, - IsString, - IsUUID, - Max, - MaxLength, - Min, -} from 'class-validator'; +import { ArrayMaxSize, IsArray, IsIn, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, IsUUID, Max, MaxLength, Min, registerDecorator } from 'class-validator'; import { REASONING_EFFORT_LEVELS } from '../../ai-config/dto/ai-config.dto'; export class CreateConversationDto { @@ -89,11 +76,39 @@ export class EditMessageDto { reasoningEffort?: string | null; } +const MAX_FORM_VALUES = 200; + +/** 限制提交表单的字段数量,防止超大 body。 */ +export function MaxFormValues(limit = MAX_FORM_VALUES) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'maxFormValues', + target: object.constructor, + propertyName, + constraints: [limit], + validator: { + validate(value: unknown) { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.keys(value).length <= limit + ); + }, + defaultMessage(args) { + return `values 字段数量不能超过 ${args?.constraints?.[0] ?? limit} 个`; + }, + }, + }); + }; +} + export class SubmitFormDto { @IsUUID() clientRequestId: string; @IsObject() + @MaxFormValues() values: Record; @IsOptional() diff --git a/apps/server/src/archive/archive.controller.ts b/apps/server/src/archive/archive.controller.ts index f9afe1fa..118b9e71 100644 --- a/apps/server/src/archive/archive.controller.ts +++ b/apps/server/src/archive/archive.controller.ts @@ -12,8 +12,12 @@ import { UploadedFile, Res, ParseIntPipe, + BadRequestException, + NotFoundException, + UnauthorizedException, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; +import { normalizeMimeType, isInlineSafeMimeType } from '../common/mime'; import type { Request as ExpressRequest, Response } from 'express'; import * as fs from 'fs'; import { ArchiveReportService } from './archive-report.service'; @@ -30,11 +34,17 @@ import { } from './dto/archive.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { withAuditLog } from '../common/with-audit-log'; +import { withAuditLog, logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import { StudentsService } from '../students/students.service'; interface AuthenticatedRequest extends ExpressRequest { - user?: { id: number; username?: string }; + user?: { + id: number; + username?: string; + permissions?: string[]; + isSuperAdmin?: boolean; + }; } @UseGuards(JwtAuthGuard) @@ -44,11 +54,42 @@ export class ArchiveController { private readonly archiveService: ArchiveService, private readonly logService: OperationLogsService, private readonly reportService: ArchiveReportService, + private readonly studentsService: StudentsService, ) {} + /** 超管或拥有 class:edit(等价于学生全范围)时视为可管理所有档案。 */ + private canManageAllArchive(req: AuthenticatedRequest): boolean { + const user = req.user; + if (!user) return false; + return user.isSuperAdmin === true || (user.permissions ?? []).includes('class:edit'); + } + + /** 校验当前用户能否访问指定学生的档案(防 IDOR)。 */ + private async assertStudentAccess(req: AuthenticatedRequest, studentId: number) { + const user = req.user; + if (!user) throw new UnauthorizedException(); + await this.studentsService.assertStudentAccess( + user.id, + studentId, + this.canManageAllArchive(req), + ); + } + + /** 按子记录 id 校验归属:先解析其 studentId,再做学生级范围校验。 */ + private async assertRecordAccess( + req: AuthenticatedRequest, + kind: 'enrollment' | 'examScore' | 'learningRecord' | 'attachment', + id: number, + ) { + const studentId = await this.archiveService.resolveRecordStudentId(kind, id); + if (studentId == null) throw new NotFoundException('记录不存在'); + await this.assertStudentAccess(req, studentId); + } + @Get(':studentId') @RequirePermission('student:view') async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) { + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '查看档案', targetId: studentId, targetType: 'archive', }), () => this.archiveService.getProfile(studentId)); @@ -61,6 +102,7 @@ export class ArchiveController { @Body() dto: UpsertProfileDto, @Request() req: AuthenticatedRequest, ) { + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '更新档案信息', targetId: studentId, targetType: 'student_profile', detail: JSON.stringify(dto), }), () => this.archiveService.upsertProfile(studentId, dto)); @@ -73,6 +115,7 @@ export class ArchiveController { @Body() dto: CreateEnrollmentDto, @Request() req: AuthenticatedRequest, ) { + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, (result) => ({ module: '学生档案', action: '添加报名记录', targetId: result.id, targetType: 'student_enrollment', detail: `${dto.courseCategory} - ${dto.classType}`, }), () => this.archiveService.addEnrollment(studentId, dto)); @@ -85,6 +128,7 @@ export class ArchiveController { @Body() dto: UpdateEnrollmentDto, @Request() req: AuthenticatedRequest, ) { + await this.assertRecordAccess(req, 'enrollment', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '编辑报名记录', targetId: id, targetType: 'student_enrollment', detail: JSON.stringify(dto), }), () => this.archiveService.updateEnrollment(id, dto)); @@ -93,6 +137,7 @@ export class ArchiveController { @Delete('enrollments/:id') @RequirePermission('student:edit') async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'enrollment', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '归档报名记录', targetId: id, targetType: 'student_enrollment', }), () => this.archiveService.deleteEnrollment(id)); @@ -101,6 +146,7 @@ export class ArchiveController { @Delete('enrollments/:id/permanent') @RequirePermission('archive:purge') async purgeEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'enrollment', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '永久删除报名记录', targetId: id, targetType: 'student_enrollment', detail: '物理删除,不可恢复', }), () => this.archiveService.purgeEnrollment(id)); @@ -113,6 +159,7 @@ export class ArchiveController { @Body() dto: CreateExamScoreDto, @Request() req: AuthenticatedRequest, ) { + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, (result) => ({ module: '学生档案', action: '添加考试成绩', targetId: result.id, targetType: 'exam_score', detail: `${dto.examType} - ${dto.subject}: ${dto.score}`, }), () => this.archiveService.addExamScore(studentId, dto)); @@ -125,6 +172,7 @@ export class ArchiveController { @Body() dto: UpdateExamScoreDto, @Request() req: AuthenticatedRequest, ) { + await this.assertRecordAccess(req, 'examScore', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '编辑考试成绩', targetId: id, targetType: 'exam_score', detail: JSON.stringify(dto), }), () => this.archiveService.updateExamScore(id, dto)); @@ -133,6 +181,7 @@ export class ArchiveController { @Delete('exam-scores/:id') @RequirePermission('student:edit') async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'examScore', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '归档考试成绩', targetId: id, targetType: 'exam_score', }), () => this.archiveService.deleteExamScore(id)); @@ -141,6 +190,7 @@ export class ArchiveController { @Delete('exam-scores/:id/permanent') @RequirePermission('archive:purge') async purgeExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'examScore', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '永久删除考试成绩', targetId: id, targetType: 'exam_score', detail: '物理删除,不可恢复', }), () => this.archiveService.purgeExamScore(id)); @@ -153,6 +203,7 @@ export class ArchiveController { @Body() dto: CreateLearningRecordDto, @Request() req: AuthenticatedRequest, ) { + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, (result) => ({ module: '学生档案', action: '添加学习记录', targetId: result.id, targetType: 'learning_record', detail: `${dto.recordType}: ${dto.content.substring(0, 50)}`, }), () => this.archiveService.addLearningRecord(studentId, dto)); @@ -165,6 +216,7 @@ export class ArchiveController { @Body() dto: UpdateLearningRecordDto, @Request() req: AuthenticatedRequest, ) { + await this.assertRecordAccess(req, 'learningRecord', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '编辑学习记录', targetId: id, targetType: 'learning_record', detail: JSON.stringify(dto), }), () => this.archiveService.updateLearningRecord(id, dto)); @@ -173,6 +225,7 @@ export class ArchiveController { @Delete('learning-records/:id') @RequirePermission('student:edit') async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'learningRecord', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '归档学习记录', targetId: id, targetType: 'learning_record', }), () => this.archiveService.deleteLearningRecord(id)); @@ -181,6 +234,7 @@ export class ArchiveController { @Delete('learning-records/:id/permanent') @RequirePermission('archive:purge') async purgeLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'learningRecord', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '永久删除学习记录', targetId: id, targetType: 'learning_record', detail: '物理删除,不可恢复', }), () => this.archiveService.purgeLearningRecord(id)); @@ -193,6 +247,7 @@ export class ArchiveController { @Body() dto: UpsertResultDto, @Request() req: AuthenticatedRequest, ) { + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '更新录取结果', targetId: studentId, targetType: 'result_archive', detail: JSON.stringify(dto), }), () => this.archiveService.upsertResult(studentId, dto)); @@ -200,16 +255,19 @@ export class ArchiveController { @Post(':studentId/attachments') @RequirePermission('student:edit') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })) async uploadAttachment( @Param('studentId', ParseIntPipe) studentId: number, @UploadedFile() file: Express.Multer.File, @Body('category') category: string, @Request() req: AuthenticatedRequest, ) { + if (!file) throw new BadRequestException('缺少上传文件'); + const safeFile = { ...file, mimetype: normalizeMimeType(file.originalname, file.mimetype) }; + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, (result) => ({ - module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${file.originalname} (${category || 'other'})`, - }), () => this.archiveService.addAttachment(studentId, file, category || 'other')); + module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${safeFile.originalname} (${category || 'other'})`, + }), () => this.archiveService.addAttachment(studentId, safeFile, category || 'other')); } @Get(':studentId/attachments/:id') @@ -218,20 +276,42 @@ export class ArchiveController { @Param('studentId', ParseIntPipe) studentId: number, @Param('id', ParseIntPipe) id: number, @Res() res: Response, + @Request() req: AuthenticatedRequest, ) { + await this.assertStudentAccess(req, studentId); const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile( studentId, id, ); - res.setHeader('Content-Type', mimeType); - res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`); + // 流式下载也记录审计:best-effort,日志失败绝不影响下载流 + await logAudit(this.logService, req, { + module: '学生档案', action: '下载附件', targetId: id, targetType: 'archive_attachment', + }); + const safeMimeType = normalizeMimeType(fileName, mimeType); + const disposition = isInlineSafeMimeType(safeMimeType) ? 'inline' : 'attachment'; + res.setHeader('Content-Type', safeMimeType); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader( + 'Content-Disposition', + `${disposition}; filename*=UTF-8''${encodeURIComponent(fileName)}`, + ); const stream = fs.createReadStream(fullPath); + stream.on('error', (err: NodeJS.ErrnoException) => { + if (res.headersSent) { + res.destroy(); + return; + } + const status = err?.code === 'ENOENT' ? 404 : 500; + res.status(status).json({ message: status === 404 ? '附件文件不存在' : '附件读取失败' }); + }); + res.on('close', () => stream.destroy()); stream.pipe(res); } @Delete('attachments/:id') @RequirePermission('student:edit') async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'attachment', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '归档附件', targetId: id, targetType: 'archive_attachment', }), () => this.archiveService.deleteAttachment(id)); @@ -240,6 +320,7 @@ export class ArchiveController { @Delete('attachments/:id/permanent') @RequirePermission('archive:purge') async purgeAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'attachment', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '永久删除附件', targetId: id, targetType: 'archive_attachment', detail: '物理删除,不可恢复', }), () => this.archiveService.purgeAttachment(id)); @@ -251,6 +332,7 @@ export class ArchiveController { @Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest, ) { + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, () => ({ module: 'archive', action: 'generate_report_html', targetId: studentId, targetType: 'student', }), async () => { diff --git a/apps/server/src/archive/archive.module.ts b/apps/server/src/archive/archive.module.ts index d755a89b..44655029 100644 --- a/apps/server/src/archive/archive.module.ts +++ b/apps/server/src/archive/archive.module.ts @@ -9,6 +9,7 @@ import { ResultArchive } from '../entities/result-archive.entity'; import { ArchiveAttachment } from '../entities/archive-attachment.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { NotificationsModule } from '../notifications/notifications.module'; +import { StudentsModule } from '../students/students.module'; import { ArchiveService } from './archive.service'; import { ArchiveReportService } from './archive-report.service'; import { ArchiveController } from './archive.controller'; @@ -26,6 +27,7 @@ import { ArchiveController } from './archive.controller'; AttendanceRecord, ]), NotificationsModule, + StudentsModule, ], controllers: [ArchiveController], providers: [ArchiveService, ArchiveReportService], diff --git a/apps/server/src/archive/archive.purge.controller.spec.ts b/apps/server/src/archive/archive.purge.controller.spec.ts index 81c64d7f..9e185405 100644 --- a/apps/server/src/archive/archive.purge.controller.spec.ts +++ b/apps/server/src/archive/archive.purge.controller.spec.ts @@ -21,15 +21,26 @@ describe('ArchiveController purge routes', () => { it('writes permanent delete audit logs for sub-records', async () => { const archiveService = { purgeEnrollment: jest.fn().mockResolvedValue({ message: '已永久删除报名记录(不可恢复)' }), + resolveRecordStudentId: jest.fn().mockResolvedValue(7), }; const log = jest.fn().mockResolvedValue(undefined); + const studentsService = { + assertStudentAccess: jest.fn().mockResolvedValue(undefined), + }; const controller = new ArchiveController( archiveService as never, { log } as never, {} as never, + studentsService as never, ); - const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + const req = { + user: { id: 1, username: 'admin', permissions: [], isSuperAdmin: false }, + ip: '127.0.0.1', + headers: {}, + }; await controller.purgeEnrollment(1, req); + expect(archiveService.resolveRecordStudentId).toHaveBeenCalledWith('enrollment', 1); + expect(studentsService.assertStudentAccess).toHaveBeenCalledWith(1, 7, false); expect(archiveService.purgeEnrollment).toHaveBeenCalledWith(1); expect(log).toHaveBeenCalledWith( expect.objectContaining({ module: '学生档案', action: '永久删除报名记录', targetId: 1 }), diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index 4cf083fc..11b5d8cc 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -57,6 +57,36 @@ export class ArchiveService { return fullPath; } + /** + * 解析某条档案子记录属于哪个学生(用于按学生范围做 IDOR 校验)。 + * 找不到返回 null。 + */ + async resolveRecordStudentId( + kind: 'enrollment' | 'examScore' | 'learningRecord' | 'attachment', + id: number, + ): Promise { + switch (kind) { + case 'enrollment': { + const row = await this.enrollmentRepo.findOne({ where: { id }, select: ['studentId'] }); + return row?.studentId ?? null; + } + case 'examScore': { + const row = await this.examScoreRepo.findOne({ where: { id }, select: ['studentId'] }); + return row?.studentId ?? null; + } + case 'learningRecord': { + const row = await this.learningRecordRepo.findOne({ where: { id }, select: ['studentId'] }); + return row?.studentId ?? null; + } + case 'attachment': { + const row = await this.attachmentRepo.findOne({ where: { id }, select: ['studentId'] }); + return row?.studentId ?? null; + } + default: + return null; + } + } + async getProfile(studentId: number) { const student = await this.studentRepo.findOne({ where: { id: studentId }, diff --git a/apps/server/src/attendance-devices/dto/attendance-device.dto.ts b/apps/server/src/attendance-devices/dto/attendance-device.dto.ts index e7731048..673aa6ed 100644 --- a/apps/server/src/attendance-devices/dto/attendance-device.dto.ts +++ b/apps/server/src/attendance-devices/dto/attendance-device.dto.ts @@ -26,6 +26,7 @@ export class CreateAttendanceDeviceDto { @IsOptional() @IsString() + @MaxLength(1000) notes?: string; } @@ -57,5 +58,6 @@ export class UpdateAttendanceDeviceDto { @IsOptional() @IsString() + @MaxLength(1000) notes?: string; } diff --git a/apps/server/src/attendance/attendance-import.controller.ts b/apps/server/src/attendance/attendance-import.controller.ts index d8f1397e..892ff17b 100644 --- a/apps/server/src/attendance/attendance-import.controller.ts +++ b/apps/server/src/attendance/attendance-import.controller.ts @@ -37,6 +37,8 @@ export class AttendanceImportController extends AttendanceControllerBase { @Body() dto: MatchDingRecordDto, @Request() req: { user: RequestUser }, ) { + const canManageAll = this.canManageAllAttendance(req); + await this.service.assertStudentAttendanceAccess(req.user.id, dto.studentId, canManageAll); const result = await this.service.matchDingRecord(id, dto); await logAudit(this.logService, req, { module: '考勤管理', action: '匹配考勤记录', targetId: id, targetType: 'dingAttendanceRaw', detail: `匹配到学生 ${dto.studentId}`, @@ -48,7 +50,10 @@ export class AttendanceImportController extends AttendanceControllerBase { @Post('ding-attendance-raw/auto-match') @RequirePermission('attendance:edit') - async autoMatch() { + async autoMatch(@Request() req: { user: RequestUser }) { + if (!this.canManageAllAttendance(req)) { + throw new ForbiddenException('仅管理员可执行全局自动匹配'); + } return this.service.autoMatchDingRecords(); } diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index d13b2ab7..2be7f208 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, DataSource } from 'typeorm'; import { @@ -251,6 +251,24 @@ export class AttendanceService { return this.queries.getClasses(...args); } + /** + * 校验某个学生是否在当前用户可访问的班级内,用于考勤匹配等敏感操作。 + * canManageAll 为 true 时跳过(管理员/超管)。 + */ + async assertStudentAttendanceAccess(userId: number, studentId: number, canManageAll: boolean) { + if (canManageAll) return; + const classIds = await this.getAccessibleClassIds(userId, false); + if (!classIds || classIds.length === 0) { + throw new ForbiddenException('无权操作该学生的考勤记录'); + } + const found = await this.classStudentRepo.findOne({ + where: { studentId, classId: In(classIds), status: 'active' }, + }); + if (!found) { + throw new ForbiddenException('无权操作该学生的考勤记录'); + } + } + async getDingRaw(...args: Parameters) { return this.queries.getDingRaw(...args); } diff --git a/apps/server/src/attendance/dto/attendance.dto.ts b/apps/server/src/attendance/dto/attendance.dto.ts index 43b9469e..44bdf140 100644 --- a/apps/server/src/attendance/dto/attendance.dto.ts +++ b/apps/server/src/attendance/dto/attendance.dto.ts @@ -88,6 +88,7 @@ export class AttendanceRecordItem { export class BatchCreateAttendanceDto { @IsArray() @ArrayNotEmpty() + @ArrayMaxSize(500) @ValidateNested({ each: true }) @Type(() => AttendanceRecordItem) records: AttendanceRecordItem[]; diff --git a/apps/server/src/auth/auth.module.ts b/apps/server/src/auth/auth.module.ts index 420bfa83..1ace123e 100644 --- a/apps/server/src/auth/auth.module.ts +++ b/apps/server/src/auth/auth.module.ts @@ -7,6 +7,7 @@ import { User } from '../entities/user.entity'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { JwtStrategy } from './strategies/jwt.strategy'; +import { getJwtSecret } from './jwt-secret'; import { RbacModule } from '../rbac/rbac.module'; @Module({ @@ -17,7 +18,7 @@ import { RbacModule } from '../rbac/rbac.module'; imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ - secret: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'), + secret: getJwtSecret(config), signOptions: { expiresIn: config.get('JWT_EXPIRES_IN', '4h') }, }), }), diff --git a/apps/server/src/auth/auth.service.ts b/apps/server/src/auth/auth.service.ts index d04ae1c1..8150db53 100644 --- a/apps/server/src/auth/auth.service.ts +++ b/apps/server/src/auth/auth.service.ts @@ -45,11 +45,11 @@ export class AuthService { if (!valid) { this.recordFailedAttempt(attemptKey); const att = loginAttempts.get(attemptKey); - const remaining = MAX_ATTEMPTS - (att?.count || 0); - if (remaining > 0) { - throw new UnauthorizedException(`用户名或密码错误,还剩 ${remaining} 次尝试机会`); + if (att?.lockedUntil && att.lockedUntil > new Date()) { + throw new UnauthorizedException(`登录失败次数过多,账号已被锁定 ${LOCK_MINUTES} 分钟`); } - throw new UnauthorizedException(`登录失败次数过多,账号已被锁定 ${LOCK_MINUTES} 分钟`); + // 与“用户不存在”返回同一文案,避免用户名枚举 + throw new UnauthorizedException('用户名或密码错误'); } // 登录成功,清除失败计数 diff --git a/apps/server/src/auth/dto/auth.dto.ts b/apps/server/src/auth/dto/auth.dto.ts index b32a24bf..3eaa22e4 100644 --- a/apps/server/src/auth/dto/auth.dto.ts +++ b/apps/server/src/auth/dto/auth.dto.ts @@ -1,10 +1,12 @@ -import { IsString, MinLength } from 'class-validator'; +import { IsString, MinLength, MaxLength } from 'class-validator'; export class LoginDto { @IsString() + @MaxLength(64) username: string; @IsString() - @MinLength(4) + @MinLength(8) + @MaxLength(72) password: string; } diff --git a/apps/server/src/auth/jwt-secret.spec.ts b/apps/server/src/auth/jwt-secret.spec.ts new file mode 100644 index 00000000..c325bd8a --- /dev/null +++ b/apps/server/src/auth/jwt-secret.spec.ts @@ -0,0 +1,88 @@ +import { ConfigService } from '@nestjs/config'; +import { getJwtSecret } from './jwt-secret'; + +const FALLBACK = 'dev-only-insecure-jwt-secret-do-not-use-in-production'; + +function makeConfig(secret?: string): ConfigService { + return { + get: jest.fn((key: string) => (key === 'JWT_SECRET' ? secret : undefined)), + } as unknown as ConfigService; +} + +const originalNodeEnv = process.env.NODE_ENV; +const originalSeedDev = process.env.SEED_DEV; + +afterEach(() => { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + if (originalSeedDev === undefined) { + delete process.env.SEED_DEV; + } else { + process.env.SEED_DEV = originalSeedDev; + } +}); + +describe('getJwtSecret', () => { + it('returns JWT_SECRET when configured, regardless of NODE_ENV', () => { + process.env.NODE_ENV = 'production'; + process.env.SEED_DEV = 'false'; + expect(getJwtSecret(makeConfig('configured-secret'))).toBe('configured-secret'); + }); + + it('returns JWT_SECRET when configured and NODE_ENV is unset', () => { + delete process.env.NODE_ENV; + delete process.env.SEED_DEV; + expect(getJwtSecret(makeConfig('configured-secret'))).toBe('configured-secret'); + }); + + it('throws when NODE_ENV is unset and SEED_DEV is unset', () => { + delete process.env.NODE_ENV; + delete process.env.SEED_DEV; + expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/); + }); + + it('throws when NODE_ENV=production', () => { + process.env.NODE_ENV = 'production'; + delete process.env.SEED_DEV; + expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/); + }); + + it('throws when NODE_ENV=production and SEED_DEV=true (SEED_DEV 只在 NODE_ENV 未设置时生效)', () => { + process.env.NODE_ENV = 'production'; + process.env.SEED_DEV = 'true'; + expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/); + }); + + it('throws when NODE_ENV=staging', () => { + process.env.NODE_ENV = 'staging'; + delete process.env.SEED_DEV; + expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/); + }); + + it('returns fallback when NODE_ENV=development', () => { + process.env.NODE_ENV = 'development'; + delete process.env.SEED_DEV; + expect(getJwtSecret(makeConfig())).toBe(FALLBACK); + }); + + it('returns fallback when NODE_ENV=test', () => { + process.env.NODE_ENV = 'test'; + delete process.env.SEED_DEV; + expect(getJwtSecret(makeConfig())).toBe(FALLBACK); + }); + + it('returns fallback when SEED_DEV=true even if NODE_ENV is unset', () => { + delete process.env.NODE_ENV; + process.env.SEED_DEV = 'true'; + expect(getJwtSecret(makeConfig())).toBe(FALLBACK); + }); + + it('returns fallback when SEED_DEV=true and NODE_ENV=development', () => { + process.env.NODE_ENV = 'development'; + process.env.SEED_DEV = 'true'; + expect(getJwtSecret(makeConfig())).toBe(FALLBACK); + }); +}); diff --git a/apps/server/src/auth/jwt-secret.ts b/apps/server/src/auth/jwt-secret.ts new file mode 100644 index 00000000..49e3795e --- /dev/null +++ b/apps/server/src/auth/jwt-secret.ts @@ -0,0 +1,36 @@ +import { ConfigService } from '@nestjs/config'; + +let warned = false; + +/** + * 获取 JWT 签名密钥。 + * - 已配置 JWT_SECRET 时直接返回该值(任何环境都正常返回)。 + * - 仅当 NODE_ENV 为 development/test,或 NODE_ENV 未设置且 npm run dev 注入的 + * SEED_DEV=true 时,允许回退到开发用默认密钥,并打印警告。 + * - NODE_ENV 为其他环境(含 production/staging)时,即使误设 SEED_DEV=true 也不回退; + * 未配置 JWT_SECRET 直接抛错,禁止使用默认/公开密钥。 + */ +export function getJwtSecret(config: ConfigService): string { + const secret = config.get('JWT_SECRET'); + if (secret) return secret; + + const env = process.env.NODE_ENV; + // SEED_DEV 是 npm run dev 的注入标记,只在 NODE_ENV 未设置(npm 脚本通常不设置)时生效; + // production 等环境即使误设 SEED_DEV=true 也绝不能走回退密钥。 + const isDev = + env === 'development' || + env === 'test' || + ((env === undefined || env === '') && process.env.SEED_DEV === 'true'); + if (!isDev) { + throw new Error( + 'JWT_SECRET 未配置:非 development/test 环境禁止使用默认密钥,请在 .env 中设置强随机 JWT_SECRET', + ); + } + if (!warned) { + warned = true; + console.warn( + '[AuthModule] 警告:JWT_SECRET 未配置,开发环境使用回退密钥。生产环境必须配置!', + ); + } + return 'dev-only-insecure-jwt-secret-do-not-use-in-production'; +} diff --git a/apps/server/src/auth/strategies/jwt.strategy.ts b/apps/server/src/auth/strategies/jwt.strategy.ts index 0e9ab64c..c1a004a1 100644 --- a/apps/server/src/auth/strategies/jwt.strategy.ts +++ b/apps/server/src/auth/strategies/jwt.strategy.ts @@ -6,6 +6,7 @@ import { Request } from 'express'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from '../../entities/user.entity'; +import { getJwtSecret } from '../jwt-secret'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -27,7 +28,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }, ]), ignoreExpiration: false, - secretOrKey: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'), + secretOrKey: getJwtSecret(config), }); } diff --git a/apps/server/src/bills/bills-export.service.ts b/apps/server/src/bills/bills-export.service.ts index e3a38ba8..18af5fc9 100644 --- a/apps/server/src/bills/bills-export.service.ts +++ b/apps/server/src/bills/bills-export.service.ts @@ -114,7 +114,23 @@ export class BillsExportService { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ); res.setHeader('Content-Disposition', `attachment; filename=bills_${Date.now()}.xlsx`); - await workbook.xlsx.write(res); + res.on('error', () => { + if (!res.headersSent) { + res.status(500).json({ message: '导出失败' }); + } else { + res.destroy(); + } + }); + try { + await workbook.xlsx.write(res); + } catch { + if (!res.headersSent) { + res.status(500).json({ message: '导出失败' }); + } else { + res.destroy(); + } + return; + } res.end(); } @@ -138,6 +154,19 @@ export class BillsExportService { const doc = new PDFDocument({ size: 'A4', margin: 50 }); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename=bill_${billId}.pdf`); + doc.on('error', () => { + if (!res.headersSent) { + res.status(500).json({ message: '导出失败' }); + } else { + res.destroy(); + } + }); + res.on('error', () => { + doc.destroy(); + }); + res.on('close', () => { + doc.destroy(); + }); doc.pipe(res); // 注册中文字体(优先使用系统字体,兼容 macOS 和 Linux) diff --git a/apps/server/src/classes/classes-queries.service.ts b/apps/server/src/classes/classes-queries.service.ts index 50c42caf..b6aa96d3 100644 --- a/apps/server/src/classes/classes-queries.service.ts +++ b/apps/server/src/classes/classes-queries.service.ts @@ -5,6 +5,7 @@ import { Class, ClassStudent, ClassSchedule, AttendanceRecord } from '../entitie import { Classroom } from '../entities/classroom.entity'; import { syncDingTalkStudents } from '../integration/dingtalk-student-sync'; import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto'; +import { escapeLike } from '../common/like-escape'; import dayjs from '../common/dayjs'; interface AgentClassRow { @@ -53,7 +54,7 @@ export class ClassesQueriesService { } qb.where('class.isArchived = :isArchived', { isArchived: false }); if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds }); - if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${query.keyword}%` }); + if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${escapeLike(query.keyword)}%` }); if (query.status) qb.andWhere('class.status = :status', { status: query.status }); const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany(); return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) })); diff --git a/apps/server/src/classes/classes.controller.spec.ts b/apps/server/src/classes/classes.controller.spec.ts index 751dd4a2..388b6ead 100644 --- a/apps/server/src/classes/classes.controller.spec.ts +++ b/apps/server/src/classes/classes.controller.spec.ts @@ -127,9 +127,15 @@ describe('ClassesController purge route', () => { it('writes permanent delete audit logs', async () => { const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除班级(不可恢复)' }), + assertClassAccess: jest.fn().mockResolvedValue(undefined), }; const log = jest.fn().mockResolvedValue(undefined); - const controller = new ClassesController(service as never, { log } as never, {} as never, {} as never); + const controller = new ClassesController( + service as never, + { log } as never, + {} as never, + { can: jest.fn().mockReturnValue(true) } as never, + ); const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; await controller.purge('1', req); expect(service.purge).toHaveBeenCalledWith(1); diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index 29dfaf7e..e27ac311 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -126,27 +126,35 @@ export class ClassesController { /** 批量导入学生到班级(通过钉钉用户ID) */ @Post(':id/students/import') @RequirePermission('class:edit') - async batchImportStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: BatchImportStudentsDto) { + async batchImportStudents( + @Param('id', ParseIntPipe) id: number, + @Body() dto: BatchImportStudentsDto, + @Request() req: AuthenticatedRequest, + ) { + await this.assertReadAccess(req, +id); return this.service.batchImportStudents(+id, dto.users); } /** 归档班级 */ @Put(':id/archive') @RequirePermission('class:edit') - async archive(@Param('id', ParseIntPipe) id: number) { + async archive(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertReadAccess(req, +id); return this.service.archive(+id); } /** 取消归档 */ @Put(':id/restore') @RequirePermission('class:edit') - async restore(@Param('id', ParseIntPipe) id: number) { + async restore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertReadAccess(req, +id); return this.service.restore(+id); } @Put(':id') @RequirePermission('class:edit') async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: AuthenticatedRequest) { + await this.assertReadAccess(req, +id); const result = await this.service.update(+id, dto); await logAudit(this.logService, req, { module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto), @@ -157,6 +165,7 @@ export class ClassesController { @Delete(':id') @RequirePermission('class:delete') async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertReadAccess(req, +id); const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class', @@ -167,6 +176,7 @@ export class ClassesController { @Delete(':id/permanent') @RequirePermission('class:purge') async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertReadAccess(req, +id); const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复', @@ -227,6 +237,7 @@ export class ClassesController { @Post(':id/students') @RequirePermission('class:edit') async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: AuthenticatedRequest) { + await this.assertReadAccess(req, +id); const result = await this.service.addStudents(+id, dto.studentIds); await logAudit(this.logService, req, { module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`, @@ -254,6 +265,7 @@ export class ClassesController { @Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest, ) { + await this.assertReadAccess(req, +id); const result = await this.service.removeStudent(+id, +studentId); await logAudit(this.logService, req, { module: '班级管理', action: '移除学生', targetId: +id, targetType: 'class', detail: `移除学生${studentId}`, @@ -271,6 +283,7 @@ export class ClassesController { @Post(':id/teachers') @RequirePermission('class:edit') async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: AuthenticatedRequest) { + await this.assertReadAccess(req, +id); const result = await this.service.addTeacher(+id, dto); await logAudit(this.logService, req, { module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`, @@ -295,6 +308,7 @@ export class ClassesController { @Param('assignmentId', ParseIntPipe) assignmentId: number, @Request() req: AuthenticatedRequest, ) { + await this.assertReadAccess(req, +id); const result = await this.service.removeTeacherAssignment(+id, +assignmentId); await logAudit(this.logService, req, { module: '班级管理', action: '移除教师角色', targetId: +id, targetType: 'class', detail: `移除教师分配${assignmentId}`, @@ -309,6 +323,7 @@ export class ClassesController { @Param('userId', ParseIntPipe) userId: number, @Request() req: AuthenticatedRequest, ) { + await this.assertReadAccess(req, +id); const result = await this.service.removeTeacher(+id, +userId); await logAudit(this.logService, req, { module: '班级管理', action: '移除教师', targetId: +id, targetType: 'class', detail: `移除教师${userId}`, diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index 05084384..e6b5bd26 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -9,6 +9,7 @@ import { DataSource, Repository, In, Like } from 'typeorm'; +import { escapeLike } from '../common/like-escape'; import { Class, ClassStudent, @@ -102,7 +103,7 @@ export class ClassesService { const where: Record = {}; if (query.status) where.status = query.status; if (query.classType) where.classType = query.classType; - if (query.keyword) where.name = Like(`%${query.keyword}%`); + if (query.keyword) where.name = Like(`%${escapeLike(query.keyword)}%`); // Default: hide archived, unless explicitly requested where.isArchived = query.isArchived ?? false; diff --git a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts index 4c8b6ba0..a5b1cf7c 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts @@ -185,7 +185,7 @@ export class ClassroomRentalsController { @UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest, ) { - if (!file) throw new BadRequestException('请上传合同文件'); + if (!file) throw new BadRequestException('缺少上传文件'); const result = await this.service.attachContract(+id, file); await logAudit(this.logService, req, { module: '教室租赁', action: '上传合同', targetId: +id, targetType: 'classroom-rental', detail: file.originalname, @@ -203,6 +203,15 @@ export class ClassroomRentalsController { `attachment; filename="${encodeURIComponent(originalName)}"`, ); const stream = fs.createReadStream(fullPath); + stream.on('error', (err: NodeJS.ErrnoException) => { + if (res.headersSent) { + res.destroy(); + return; + } + const status = err?.code === 'ENOENT' ? 404 : 500; + res.status(status).json({ message: status === 404 ? '合同文件不存在' : '合同文件读取失败' }); + }); + res.on('close', () => stream.destroy()); stream.pipe(res); } diff --git a/apps/server/src/classroom-rentals/dto/rental.dto.ts b/apps/server/src/classroom-rentals/dto/rental.dto.ts index 61f08c63..635d7664 100644 --- a/apps/server/src/classroom-rentals/dto/rental.dto.ts +++ b/apps/server/src/classroom-rentals/dto/rental.dto.ts @@ -1,4 +1,37 @@ -import { IsOptional, IsString, IsInt, IsNumber, IsISO8601, Matches, Min } from 'class-validator'; +import { + IsISO8601, + IsInt, + IsNumber, + IsOptional, + IsString, + Matches, + MaxLength, + Min, + Validate, + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +/** + * 跨字段校验:endDate 不能早于 startDate。 + * 仅在两个字段都存在时校验(UpdateRentalDto 允许只更新其中一个)。 + */ +@ValidatorConstraint({ name: 'IsDateRangeValid', async: false }) +class IsDateRangeValidConstraint implements ValidatorConstraintInterface { + validate(_value: string, args: ValidationArguments): boolean { + const { startDate, endDate } = args.object as { + startDate?: string; + endDate?: string; + }; + if (startDate === undefined || endDate === undefined) return true; + return endDate >= startDate; + } + + defaultMessage(): string { + return '结束日期不能早于开始日期'; + } +} export class CreateRentalDto { @IsInt() @@ -17,6 +50,7 @@ export class CreateRentalDto { @Matches(/^\d{4}-\d{2}-\d{2}$/) @IsISO8601({ strict: true }) + @Validate(IsDateRangeValidConstraint) endDate: string; @IsOptional() @@ -31,6 +65,7 @@ export class CreateRentalDto { @IsOptional() @IsString() + @MaxLength(1000) notes?: string; } @@ -55,6 +90,7 @@ export class UpdateRentalDto { @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) @IsISO8601({ strict: true }) + @Validate(IsDateRangeValidConstraint) endDate?: string; @IsOptional() @@ -69,5 +105,6 @@ export class UpdateRentalDto { @IsOptional() @IsString() + @MaxLength(1000) notes?: string; } diff --git a/apps/server/src/common/like-escape.spec.ts b/apps/server/src/common/like-escape.spec.ts new file mode 100644 index 00000000..3f7beaf4 --- /dev/null +++ b/apps/server/src/common/like-escape.spec.ts @@ -0,0 +1,22 @@ +import { escapeLike } from './like-escape'; + +describe('escapeLike', () => { + it.each([ + ['%', '\\%'], + ['_', '\\_'], + ['\\', '\\\\'], + ['50%_off', '50\\%\\_off'], + ['a\\b%c_d', 'a\\\\b\\%c\\_d'], + ])('escapes LIKE wildcard "%s" -> "%s"', (input, expected) => { + expect(escapeLike(input)).toBe(expected); + }); + + it('keeps ordinary strings unchanged', () => { + expect(escapeLike('张三')).toBe('张三'); + expect(escapeLike('hello world 123')).toBe('hello world 123'); + }); + + it('keeps an empty string empty', () => { + expect(escapeLike('')).toBe(''); + }); +}); diff --git a/apps/server/src/common/like-escape.ts b/apps/server/src/common/like-escape.ts new file mode 100644 index 00000000..df0dd39f --- /dev/null +++ b/apps/server/src/common/like-escape.ts @@ -0,0 +1,15 @@ +/** + * 转义 SQL LIKE 模式中的通配符,防止用户输入里的 `%` / `_` / `\` 扩大匹配范围。 + * + * 反斜杠必须最先转义:否则用户输入的 `\` 会被数据库当作转义符, + * 把后面的通配符(或普通字符)变成字面量,改变匹配语义。 + * 转义后配合 MySQL 默认的 `\` 转义符,`%` / `_` / `\` 都会被当作字面量匹配。 + */ +export function escapeLike(input: string): string { + return input + .replace(/\\/g, '\\\\') + .replace(/%/g, '\\%') + .replace(/_/g, '\\_'); +} + +export default escapeLike; diff --git a/apps/server/src/common/mime.spec.ts b/apps/server/src/common/mime.spec.ts new file mode 100644 index 00000000..44e36992 --- /dev/null +++ b/apps/server/src/common/mime.spec.ts @@ -0,0 +1,52 @@ +import { normalizeMimeType, isInlineSafeMimeType } from './mime'; + +describe('normalizeMimeType', () => { + it.each([ + ['report.pdf', 'application/pdf'], + ['photo.png', 'image/png'], + ['pic.jpeg', 'image/jpeg'], + ['pic.jpg', 'image/jpeg'], + ['anim.webp', 'image/webp'], + ['doc.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + ['sheet.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + ['data.csv', 'text/csv'], + ['notes.txt', 'text/plain'], + ])('maps %s to %s by extension', (filename, expected) => { + expect(normalizeMimeType(filename)).toBe(expected); + }); + + it('is case-insensitive for the extension', () => { + expect(normalizeMimeType('REPORT.PDF')).toBe('application/pdf'); + expect(normalizeMimeType('Photo.JPG')).toBe('image/jpeg'); + }); + + it('ignores a spoofed client mimeType', () => { + expect(normalizeMimeType('evil.png', 'text/html')).toBe('image/png'); + expect(normalizeMimeType('evil.html', 'image/png')).toBe('application/octet-stream'); + }); + + it('falls back to octet-stream for unknown/unsafe extensions', () => { + expect(normalizeMimeType('script.svg')).toBe('application/octet-stream'); + expect(normalizeMimeType('payload.html')).toBe('application/octet-stream'); + expect(normalizeMimeType('virus.exe')).toBe('application/octet-stream'); + expect(normalizeMimeType('noextension')).toBe('application/octet-stream'); + expect(normalizeMimeType('')).toBe('application/octet-stream'); + }); +}); + +describe('isInlineSafeMimeType', () => { + it('allows images and pdf', () => { + expect(isInlineSafeMimeType('application/pdf')).toBe(true); + expect(isInlineSafeMimeType('image/png')).toBe(true); + expect(isInlineSafeMimeType('image/jpeg')).toBe(true); + expect(isInlineSafeMimeType('image/webp')).toBe(true); + }); + + it('rejects document, archive and scriptable types', () => { + expect(isInlineSafeMimeType('application/octet-stream')).toBe(false); + expect(isInlineSafeMimeType('text/html')).toBe(false); + expect(isInlineSafeMimeType('image/svg+xml')).toBe(false); + expect(isInlineSafeMimeType('text/plain')).toBe(false); + expect(isInlineSafeMimeType('')).toBe(false); + }); +}); diff --git a/apps/server/src/common/mime.ts b/apps/server/src/common/mime.ts new file mode 100644 index 00000000..26ec8333 --- /dev/null +++ b/apps/server/src/common/mime.ts @@ -0,0 +1,43 @@ +/** + * 上传附件 MIME 类型归一化。 + * + * 存储/返回的 mimeType 不应直接信任客户端请求头(可被伪造), + * 统一按文件扩展名白名单归一化,白名单之外的按 octet-stream 处理。 + */ + +const MIME_BY_EXTENSION: Record = { + pdf: 'application/pdf', + png: 'image/png', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + webp: 'image/webp', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + csv: 'text/csv', + txt: 'text/plain', +}; + +const INLINE_SAFE_MIME_TYPES = new Set([ + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/webp', +]); + +/** + * 按文件扩展名白名单归一化 MIME 类型。 + * 客户端提供的 mimeType 仅作参考,实际以扩展名为准。 + */ +export function normalizeMimeType(filename: string, _clientMime?: string): string { + const dotIndex = filename.lastIndexOf('.'); + const ext = dotIndex >= 0 ? filename.slice(dotIndex + 1).toLowerCase() : ''; + return MIME_BY_EXTENSION[ext] ?? 'application/octet-stream'; +} + +/** + * 该 MIME 类型是否允许 `Content-Disposition: inline` 内联展示 + * (仅限图片与 PDF 这类无脚本执行能力的类型,SVG 等一律视为不安全)。 + */ +export function isInlineSafeMimeType(mime: string): boolean { + return INLINE_SAFE_MIME_TYPES.has(mime); +} diff --git a/apps/server/src/common/request-utils.ts b/apps/server/src/common/request-utils.ts index 5ba570c4..813089c6 100644 --- a/apps/server/src/common/request-utils.ts +++ b/apps/server/src/common/request-utils.ts @@ -2,18 +2,25 @@ export interface RequestInfoSource { headers?: Record; connection?: { remoteAddress?: string }; + socket?: { remoteAddress?: string }; } /** - * 从请求对象中提取客户端 IP 和 UserAgent + * 从请求对象中提取客户端 IP 和 UserAgent。 + * 默认不信任 X-Forwarded-For / X-Real-IP(客户端可伪造), + * 仅当显式设置 TRUST_PROXY=1/true(部署在可信反向代理后)时才使用代理头。 */ export function extractRequestInfo(req: RequestInfoSource): { ipAddress: string; userAgent: string } { - const forwarded = - req.headers?.['x-forwarded-for'] || - req.headers?.['x-real-ip'] || - req.connection?.remoteAddress || - ''; - const ipAddress = String(forwarded).split(',')[0].trim() || 'unknown'; + const trustProxy = process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true'; + let ipAddress: string; + if (trustProxy) { + const forwarded = + req.headers?.['x-forwarded-for'] || req.headers?.['x-real-ip'] || ''; + ipAddress = String(forwarded).split(',')[0].trim(); + } else { + ipAddress = req.socket?.remoteAddress || req.connection?.remoteAddress || ''; + } + if (!ipAddress) ipAddress = 'unknown'; const userAgent = String(req.headers?.['user-agent'] || '').substring(0, 500); return { ipAddress, userAgent }; } diff --git a/apps/server/src/common/with-audit-log.ts b/apps/server/src/common/with-audit-log.ts index 8362675d..b9a09901 100644 --- a/apps/server/src/common/with-audit-log.ts +++ b/apps/server/src/common/with-audit-log.ts @@ -33,13 +33,17 @@ export async function withAuditLog( ): Promise { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await operation(); - await logService.log({ - userId: req.user?.id, - username: req.user?.username, - ipAddress, - userAgent, - ...buildEntry(result), - }); + try { + await logService.log({ + userId: req.user?.id, + username: req.user?.username, + ipAddress, + userAgent, + ...buildEntry(result), + }); + } catch { + // 审计日志为 best-effort:写入失败绝不能把已成功的业务操作变成失败 + } return result; } @@ -53,11 +57,15 @@ export async function logAudit( entry: AuditLogEntry, ): Promise { const { ipAddress, userAgent } = extractRequestInfo(req); - await logService.log({ - userId: req.user?.id, - username: req.user?.username, - ipAddress, - userAgent, - ...entry, - }); + try { + await logService.log({ + userId: req.user?.id, + username: req.user?.username, + ipAddress, + userAgent, + ...entry, + }); + } catch { + // 审计日志为 best-effort:日志失败绝不向外抛 + } } diff --git a/apps/server/src/dashboard/dashboard.controller.ts b/apps/server/src/dashboard/dashboard.controller.ts index 0ede9645..ffb004da 100644 --- a/apps/server/src/dashboard/dashboard.controller.ts +++ b/apps/server/src/dashboard/dashboard.controller.ts @@ -44,18 +44,18 @@ export class DashboardController { } @Get('gantt') - getGanttData(@Query() query: DashboardGanttQueryDto) { - return this.service.getGanttData(query); + async getGanttData(@Query() query: DashboardGanttQueryDto, @Request() req: { user: RequestUser }) { + return this.service.getGanttData(query, await this.getAccessibleClassIds(req)); } @Get('expense-stats') - getExpenseStats(@Query() query: DashboardPeriodQueryDto) { - return this.service.getExpenseStats(query.periodStart, query.periodEnd); + async getExpenseStats(@Query() query: DashboardPeriodQueryDto, @Request() req: { user: RequestUser }) { + return this.service.getExpenseStats(query.periodStart, query.periodEnd, await this.getAccessibleClassIds(req)); } @Get('room-ranking') - getRoomExpenseRanking(@Query() query: DashboardPeriodQueryDto) { - return this.service.getRoomExpenseRanking(query.periodStart, query.periodEnd); + async getRoomExpenseRanking(@Query() query: DashboardPeriodQueryDto, @Request() req: { user: RequestUser }) { + return this.service.getRoomExpenseRanking(query.periodStart, query.periodEnd, await this.getAccessibleClassIds(req)); } @Get('class-attendance-ranking') @@ -64,12 +64,12 @@ export class DashboardController { } @Get('classroom-occupancy') - getClassroomOccupancy() { - return this.service.getClassroomOccupancy(); + async getClassroomOccupancy(@Request() req: { user: RequestUser }) { + return this.service.getClassroomOccupancy(await this.getAccessibleClassIds(req)); } @Get('classroom-utilization') - async getClassroomUtilization() { - return this.service.getClassroomUtilizationStats(); + async getClassroomUtilization(@Request() req: { user: RequestUser }) { + return this.service.getClassroomUtilizationStats(await this.getAccessibleClassIds(req)); } } diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index 974dd972..e3fbbd12 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -257,15 +257,22 @@ export class DashboardService { return this.queries.getIncomeTrend(this.billRepo, currentMonth); } - async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) { + async getGanttData( + query?: { periodStart?: string; periodEnd?: string; building?: string }, + accessibleClassIds?: number[], + ) { + // 非管理员(教师/学服等班级范围用户)看不到全局宿舍财务数据 + if (accessibleClassIds) return []; return this.queries.getGanttData(this.occRepo, (a, b) => this.assertPeriodRange(a, b), query); } - async getExpenseStats(periodStart?: string, periodEnd?: string) { + async getExpenseStats(periodStart?: string, periodEnd?: string, accessibleClassIds?: number[]) { + if (accessibleClassIds) return []; return this.queries.getExpenseStats(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd); } - async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) { + async getRoomExpenseRanking(periodStart?: string, periodEnd?: string, accessibleClassIds?: number[]) { + if (accessibleClassIds) return []; return this.queries.getRoomExpenseRanking(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd); } @@ -281,7 +288,9 @@ export class DashboardService { return dayjs.utc(`${ym}-01`).add(1, 'month').format('YYYY-MM-DD'); } - async getClassroomOccupancy() { + async getClassroomOccupancy(accessibleClassIds?: number[]) { + // 非管理员看不到全局教室使用情况 + if (accessibleClassIds) return []; const classrooms = await this.classroomRepo.find({ where: { status: 'available' as const }, order: { building: 'ASC', name: 'ASC' }, @@ -328,7 +337,11 @@ export class DashboardService { return dayjs(date).utcOffset(8).format('YYYY-MM-DD'); } - async getClassroomUtilizationStats() { + async getClassroomUtilizationStats(accessibleClassIds?: number[]) { + // 非管理员看不到全局教室利用率 + if (accessibleClassIds) { + return { totalClassrooms: 0, inUseCount: 0, utilizationRate: '0', scheduleCount: 0, rentalCount: 0 }; + } const totalClassrooms = await this.classroomRepo.count({ where: { status: 'available' as const }, }); diff --git a/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts b/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts index ac7e6ea7..4b621565 100644 --- a/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts +++ b/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts @@ -4,7 +4,7 @@ import { validate } from 'class-validator'; import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dashboard-query.dto'; describe('dashboard query boundaries', () => { - it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])( + it.each(['2026-07-13T00:00:00Z', '2026-7-13'])( 'rejects invalid or non-date-only value %s', async (periodStart) => { const dto = plainToInstance(DashboardPeriodQueryDto, { periodStart }); diff --git a/apps/server/src/dashboard/dto/dashboard-query.dto.ts b/apps/server/src/dashboard/dto/dashboard-query.dto.ts index 5f2aafdb..2c3760b1 100644 --- a/apps/server/src/dashboard/dto/dashboard-query.dto.ts +++ b/apps/server/src/dashboard/dto/dashboard-query.dto.ts @@ -1,14 +1,12 @@ -import { IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; +import { IsOptional, IsString, Matches, MaxLength } from 'class-validator'; export class DashboardPeriodQueryDto { @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) periodStart?: string; @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) periodEnd?: string; } diff --git a/apps/server/src/exams/exams.controller.spec.ts b/apps/server/src/exams/exams.controller.spec.ts index 724195a8..1e237bf9 100644 --- a/apps/server/src/exams/exams.controller.spec.ts +++ b/apps/server/src/exams/exams.controller.spec.ts @@ -18,13 +18,23 @@ describe('ExamsController batch archive and restore', () => { }; it.each(['batchArchive', 'batchRestore'] as const)( - '%s uses the existing exam permission', + '%s requires the exam write permission', (method) => { const handler = ExamsController.prototype[method] as (...args: never[]) => unknown; - expect(Reflect.getMetadata(PERMISSION_KEY, handler)).toEqual(['exam:view']); + expect(Reflect.getMetadata(PERMISSION_KEY, handler)).toEqual(['exam:edit']); }, ); + it.each([ + ['create', 'exam:create'], + ['archive', 'exam:edit'], + ['restore', 'exam:edit'], + ['updateScore', 'exam:edit'], + ] as const)('%s requires %s', (method, permission) => { + const handler = ExamsController.prototype[method] as (...args: never[]) => unknown; + expect(Reflect.getMetadata(PERMISSION_KEY, handler)).toEqual([permission]); + }); + it('class-level validation rejects invalid and non-whitelisted batch bodies', async () => { const pipes = Reflect.getMetadata(PIPES_METADATA, ExamsController) as ValidationPipe[]; expect(pipes).toHaveLength(1); diff --git a/apps/server/src/exams/exams.controller.ts b/apps/server/src/exams/exams.controller.ts index cdcf0848..5e2dd1f8 100644 --- a/apps/server/src/exams/exams.controller.ts +++ b/apps/server/src/exams/exams.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -49,7 +50,7 @@ export class ExamsController { } @Put('batch-archive') - @RequirePermission('exam:view') + @RequirePermission('exam:edit') async batchArchive(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchArchive( dto.ids, @@ -63,7 +64,7 @@ export class ExamsController { } @Put('batch-restore') - @RequirePermission('exam:view') + @RequirePermission('exam:edit') async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRestore( dto.ids, @@ -83,7 +84,7 @@ export class ExamsController { } @Post() - @RequirePermission('exam:view') + @RequirePermission('exam:create') async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto, req.user.id, this.canManageAll(req)); await logAudit(this.logService, req, { @@ -93,7 +94,7 @@ export class ExamsController { } @Put(':id/archive') - @RequirePermission('exam:view') + @RequirePermission('exam:edit') async archive( @Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest, @@ -106,7 +107,7 @@ export class ExamsController { } @Put(':id/restore') - @RequirePermission('exam:view') + @RequirePermission('exam:edit') async restore( @Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest, @@ -146,13 +147,17 @@ export class ExamsController { } @Put(':examId/scores/:scoreId') - @RequirePermission('exam:view') + @RequirePermission('exam:edit') async updateScore( @Param('examId', ParseIntPipe) examId: number, @Param('scoreId', ParseIntPipe) scoreId: number, @Body() dto: UpdateExamScoreValueDto, @Request() req: AuthenticatedRequest, ) { + // 防止空 PUT 静默清空成绩:score 必须显式提供(清空请传 null) + if (dto.score === undefined) { + throw new BadRequestException('score 字段必填(清空请传 null)'); + } const result = await this.service.updateScore( examId, scoreId, diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index 7886b8c3..4b3cdee8 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -16,6 +16,7 @@ import { ParseIntPipe, UsePipes, ValidationPipe, + BadRequestException, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import dayjs from '../common/dayjs'; @@ -351,8 +352,9 @@ export class ExpensesController { @Post('utility/import') @RequirePermission('expense:create') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })) async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { + if (!file) throw new BadRequestException('缺少上传文件'); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; @@ -416,8 +418,9 @@ export class ExpensesController { @Post('personal/import') @RequirePermission('expense:create') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })) async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { + if (!file) throw new BadRequestException('缺少上传文件'); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index 6910942f..6685d5d0 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -14,6 +14,7 @@ import { import { BillsService } from '../bills/bills.service'; import dayjs from '../common/dayjs'; import { ExpenseOperationsService } from './expense-operations.service'; +import { escapeLike } from '../common/like-escape'; /** getRawMany 返回的原始行:数据库标量值(string/number/Date)或 NULL */ type RawScalarRow = Record; @@ -147,7 +148,7 @@ export class ExpensesService { } roomQb.where('e.status = :status', { status: 'active' }); if (query?.keyword) { - roomQb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${query.keyword}%` }); + roomQb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${escapeLike(query.keyword)}%` }); } if (query?.periodStart) { roomQb.andWhere('e.periodStart >= :periodStart', { periodStart: query.periodStart }); @@ -178,7 +179,7 @@ export class ExpensesService { if (query?.keyword) { personalQb.andWhere( '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', - { keyword: `%${query.keyword}%` }, + { keyword: `%${escapeLike(query.keyword)}%` }, ); } if (query?.periodStart) { diff --git a/apps/server/src/integration/config/integration-config.service.spec.ts b/apps/server/src/integration/config/integration-config.service.spec.ts index cb559cbc..8710c58e 100644 --- a/apps/server/src/integration/config/integration-config.service.spec.ts +++ b/apps/server/src/integration/config/integration-config.service.spec.ts @@ -1,4 +1,5 @@ import { IntegrationConfigService } from './integration-config.service'; +import { decryptSecret, encryptSecret, isEncryptedSecret } from './secret-crypto'; describe('IntegrationConfigService.testConnection', () => { const originalFetch = global.fetch; @@ -97,3 +98,96 @@ describe('IntegrationConfigService security boundaries', () => { ).resolves.toBe(false); }); }); + +describe('IntegrationConfigService appSecret encryption', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('encrypts AppSecret before persisting content', async () => { + const configRepo = { + findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }), + create: jest.fn(), + save: jest.fn(), + }; + const detailRepo = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn((data) => data), + save: jest.fn().mockResolvedValue(undefined), + }; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ accessToken: 'token' }), + }) as never; + + const service = new IntegrationConfigService(configRepo as never, detailRepo as never); + + await service.saveConfig({ + type: 'DINGTALK', + config: { corpId: 'corp', agentId: 'agent', appSecret: 'plain-secret' }, + } as never); + + expect(detailRepo.create).toHaveBeenCalled(); + const saved = detailRepo.create.mock.calls[0][0]; + const parsed = JSON.parse(saved.content) as { + config: { appSecret: string }; + }; + expect(parsed.config.appSecret).not.toBe('plain-secret'); + expect(isEncryptedSecret(parsed.config.appSecret)).toBe(true); + expect(decryptSecret(parsed.config.appSecret)).toBe('plain-secret'); + }); + + it('decrypts an encrypted stored AppSecret when reading raw config', async () => { + const configRepo = { + findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }), + }; + const detailRepo = { + findOne: jest.fn().mockResolvedValue({ + configId: 1, + type: 'DINGTALK_SYNC', + content: JSON.stringify({ + config: { + corpId: 'corp', + agentId: 'agent', + appSecret: encryptSecret('saved-secret'), + }, + }), + }), + }; + const service = new IntegrationConfigService(configRepo as never, detailRepo as never); + + await expect(service.getRawConfig('DINGTALK')).resolves.toEqual({ + corpId: 'corp', + agentId: 'agent', + appSecret: 'saved-secret', + }); + }); + + it('keeps masking AppSecret even when the stored value is encrypted', async () => { + const content = JSON.stringify({ + config: { + corpId: 'corp', + agentId: 'agent', + appSecret: encryptSecret('top-secret'), + }, + }); + const configRepo = { + findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }), + }; + const detailRepo = { + find: jest.fn().mockResolvedValue([{ type: 'DINGTALK_SYNC', enable: true, content }]), + }; + const service = new IntegrationConfigService(configRepo as never, detailRepo as never); + + await expect(service.getThirdConfig()).resolves.toEqual([ + { + type: 'DINGTALK', + verify: true, + config: { corpId: 'corp', agentId: 'agent' }, + }, + ]); + }); +}); diff --git a/apps/server/src/integration/config/integration-config.service.ts b/apps/server/src/integration/config/integration-config.service.ts index ba12be88..941d5248 100644 --- a/apps/server/src/integration/config/integration-config.service.ts +++ b/apps/server/src/integration/config/integration-config.service.ts @@ -11,6 +11,7 @@ import { IntegrationType, SaveIntegrationConfigDto, } from './dto/config.dto'; +import { decryptSecret, encryptSecret, isEncryptedSecret } from './secret-crypto'; /** 第三方配置在 content JSON 中的存储结构。 */ interface StoredConfigShape { @@ -34,7 +35,13 @@ export class IntegrationConfigService { private parseStoredConfig(content: string): Record { const parsed = JSON.parse(content) as StoredConfigShape; const rawCfg = parsed.config || parsed; - return rawCfg && typeof rawCfg === 'object' ? (rawCfg as Record) : {}; + if (!rawCfg || typeof rawCfg !== 'object') return {}; + const cfg = rawCfg as Record; + // 读取路径统一在这里解密 appSecret(信封 → 明文),兼容存量明文。 + if (typeof cfg.appSecret === 'string' && isEncryptedSecret(cfg.appSecret)) { + return { ...cfg, appSecret: decryptSecret(cfg.appSecret) }; + } + return cfg; } /** 获取或创建主配置(全局单例) */ @@ -109,6 +116,11 @@ export class IntegrationConfigService { const token = await this.getTokenForTest(request.type, finalConfig); const verified = !!token; + // 写库前对 appSecret 做静态加密(其他字段不动);已是信封则跳过避免二次加密。 + if (finalConfig.appSecret && !isEncryptedSecret(stringify(finalConfig.appSecret))) { + finalConfig.appSecret = encryptSecret(stringify(finalConfig.appSecret)); + } + const content = JSON.stringify({ type: request.type, verify: verified, diff --git a/apps/server/src/integration/config/secret-crypto.spec.ts b/apps/server/src/integration/config/secret-crypto.spec.ts new file mode 100644 index 00000000..3127a01b --- /dev/null +++ b/apps/server/src/integration/config/secret-crypto.spec.ts @@ -0,0 +1,58 @@ +import { decryptSecret, encryptSecret, isEncryptedSecret } from './secret-crypto'; + +describe('secret-crypto', () => { + describe('encryptSecret / decryptSecret', () => { + it('round-trips an appSecret through the JSON envelope', () => { + const secret = 'ding-app-secret-abc123'; + const envelope = encryptSecret(secret); + + expect(JSON.parse(envelope)).toEqual( + expect.objectContaining({ + v: 1, + c: expect.any(String), + i: expect.any(String), + t: expect.any(String), + }), + ); + expect(envelope).not.toContain(secret); + expect(decryptSecret(envelope)).toBe(secret); + }); + + it('produces a different envelope per call (random IV)', () => { + expect(encryptSecret('same-secret')).not.toBe(encryptSecret('same-secret')); + }); + + it('returns plaintext unchanged when input is not an envelope (legacy compatibility)', () => { + expect(decryptSecret('legacy-plain-secret')).toBe('legacy-plain-secret'); + expect(decryptSecret('')).toBe(''); + }); + }); + + describe('isEncryptedSecret', () => { + it('recognizes generated envelopes', () => { + expect(isEncryptedSecret(encryptSecret('anything'))).toBe(true); + }); + + it('rejects plaintext, malformed JSON and partial envelopes', () => { + expect(isEncryptedSecret('plain-secret')).toBe(false); + expect(isEncryptedSecret('')).toBe(false); + expect(isEncryptedSecret('not-json')).toBe(false); + expect(isEncryptedSecret('{"v":1}')).toBe(false); + expect(isEncryptedSecret('{"v":2,"c":"a","i":"b","t":"c"}')).toBe(false); + expect(isEncryptedSecret('{"v":1,"c":"","i":"b","t":"c"}')).toBe(false); + expect(isEncryptedSecret('{"v":1,"c":123,"i":"b","t":"c"}')).toBe(false); + }); + }); + + describe('invalid envelope tolerance', () => { + it('returns the input unchanged for structurally incomplete envelopes', () => { + expect(decryptSecret('{"v":1}')).toBe('{"v":1}'); + expect(decryptSecret('{"v":1,"c":"a","i":"b"}')).toBe('{"v":1,"c":"a","i":"b"}'); + }); + + it('returns the input unchanged when decryption fails', () => { + const garbage = '{"v":1,"c":"!!","i":"!!","t":"!!"}'; + expect(decryptSecret(garbage)).toBe(garbage); + }); + }); +}); diff --git a/apps/server/src/integration/config/secret-crypto.ts b/apps/server/src/integration/config/secret-crypto.ts new file mode 100644 index 00000000..80d66e95 --- /dev/null +++ b/apps/server/src/integration/config/secret-crypto.ts @@ -0,0 +1,60 @@ +import { decrypt, encrypt } from '../../ai-config/ai-config.helpers'; + +/** + * appSecret 静态加密信封结构。 + * 复用 ai-config.helpers 的 AES-256-GCM 工具:encrypt 返回 + * { ciphertext, iv, authTag }(均 base64),此处组装为 JSON 字符串落库。 + */ +interface SecretEnvelope { + v: 1; + c: string; + i: string; + t: string; +} + +/** + * 判断字符串是否为 secret-crypto 生成的加密信封。 + * 仅当 JSON 可解析、v === 1 且 c/i/t 均为非空字符串时视为信封。 + */ +export function isEncryptedSecret(value: string): boolean { + if (typeof value !== 'string' || value.length === 0) return false; + try { + const parsed = JSON.parse(value) as Partial; + return ( + parsed !== null && + typeof parsed === 'object' && + parsed.v === 1 && + typeof parsed.c === 'string' && + parsed.c.length > 0 && + typeof parsed.i === 'string' && + parsed.i.length > 0 && + typeof parsed.t === 'string' && + parsed.t.length > 0 + ); + } catch { + return false; + } +} + +/** + * 加密 appSecret 并返回 JSON 信封字符串。 + * 形如 {"v":1,"c":"","i":"","t":""}。 + */ +export function encryptSecret(value: string): string { + const { ciphertext, iv, authTag } = encrypt(value); + return JSON.stringify({ v: 1, c: ciphertext, i: iv, t: authTag } satisfies SecretEnvelope); +} + +/** + * 解密 appSecret。 + * 非信封(含存量明文)原样返回;信封解密失败时也原样返回,保证读取路径容错。 + */ +export function decryptSecret(value: string): string { + if (!isEncryptedSecret(value)) return value; + try { + const envelope = JSON.parse(value) as SecretEnvelope; + return decrypt(envelope.c, envelope.i, envelope.t); + } catch { + return value; + } +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 63b96536..584126eb 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,4 +1,5 @@ import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; import helmet from 'helmet'; import compression from 'compression'; import { AppModule } from './app.module'; @@ -8,6 +9,9 @@ async function bootstrap() { await runMigrationsOnStartup(); const app = await NestFactory.create(AppModule); + // 全局 DTO 校验:对带 class-validator 装饰器的 DTO 生效。 + // 注意:不开 whitelist/forbidNonWhitelisted,避免把无装饰器的裸 body(如 { ids: number[] })剥空。 + app.useGlobalPipes(new ValidationPipe({ transform: true })); app.setGlobalPrefix('api'); app.enableCors(); app.use(helmet()); diff --git a/apps/server/src/occupancies/dto/occupancy.dto.spec.ts b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts index 2f1d255f..40889f01 100644 --- a/apps/server/src/occupancies/dto/occupancy.dto.spec.ts +++ b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts @@ -73,7 +73,7 @@ describe('manual occupancy DTO bed requirements', () => { }); describe('occupancy date boundaries', () => { - it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])( + it.each(['2026-07-13T00:00:00Z', '2026-7-13'])( 'rejects invalid or non-date-only check-in date %s', async (checkInDate) => { const dto = Object.assign(new CheckInDto(), { diff --git a/apps/server/src/occupancies/dto/occupancy.dto.ts b/apps/server/src/occupancies/dto/occupancy.dto.ts index f2ffc227..d16b76bc 100644 --- a/apps/server/src/occupancies/dto/occupancy.dto.ts +++ b/apps/server/src/occupancies/dto/occupancy.dto.ts @@ -2,7 +2,6 @@ import { IsArray, IsBoolean, IsInt, - IsISO8601, IsNumber, IsOptional, IsString, @@ -18,12 +17,10 @@ export class CheckInDto { roomId: number; @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) checkInDate: string; // YYYY-MM-DD @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) billingStartDate?: string; // 默认=checkInDate,可调整 @IsOptional() @@ -53,12 +50,10 @@ export class CheckInDto { export class CheckOutDto { @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) checkOutDate: string; @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) billingEndDate?: string; // 默认=checkOutDate @IsOptional() @@ -71,12 +66,10 @@ export class TransferRoomDto { newRoomId: number; @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) transferDate: string; // YYYY-MM-DD @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate @IsInt() @@ -87,7 +80,6 @@ export class TransferRoomDto { newLockerId?: number; @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) newBillingStartDate?: string; // 新房计费起始日,默认=transferDate次日 @IsOptional() @@ -100,12 +92,10 @@ export class BatchCheckOutDto { ids: number[]; @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) checkOutDate: string; // YYYY-MM-DD @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) billingEndDate?: string; // 默认=checkOutDate @IsOptional() diff --git a/apps/server/src/occupancies/occupancies.boundaries.spec.ts b/apps/server/src/occupancies/occupancies.boundaries.spec.ts index 5298a1ca..5cf6817f 100644 --- a/apps/server/src/occupancies/occupancies.boundaries.spec.ts +++ b/apps/server/src/occupancies/occupancies.boundaries.spec.ts @@ -55,6 +55,7 @@ function createCheckInManager(options?: { update: jest.fn(), }; const queryResults = [ + options?.student ?? { id: 3, organizationId: 7 }, options?.existingOccupancy ?? null, options?.room ?? { id: 2, capacity: 4, status: 'available' }, ...(options?.bed !== undefined ? [options.bed] : []), diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index 16c9f4df..8400c0a7 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -261,7 +261,7 @@ export class OccupanciesController { @Post('import') @RequirePermission('occupancy:checkin') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })) async importCheckIn( @UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest, diff --git a/apps/server/src/rbac/dto/rbac.dto.ts b/apps/server/src/rbac/dto/rbac.dto.ts index 52291065..4eda6514 100644 --- a/apps/server/src/rbac/dto/rbac.dto.ts +++ b/apps/server/src/rbac/dto/rbac.dto.ts @@ -1,97 +1,125 @@ import { + ArrayMaxSize, ArrayUnique, IsArray, + IsDateString, IsInt, + IsNotEmpty, IsOptional, IsString, + Matches, + MaxLength, Min, MinLength, + registerDecorator, + ValidationOptions, } from 'class-validator'; +import { OmitType, PartialType } from '@nestjs/mapped-types'; + +/** 按 UTF-8 字节数限制(bcrypt 只在 72 字节处截断,多字节密码按字符数校验会漏)。 */ +function MaxByteLength(limit: number, validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'maxByteLength', + target: object.constructor, + propertyName, + constraints: [limit], + options: validationOptions, + validator: { + validate(value: unknown) { + return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= limit; + }, + defaultMessage(args) { + return `$property 长度(UTF-8 字节)不能超过 ${args?.constraints?.[0] ?? limit}`; + }, + }, + }); + }; +} export class CreateRoleDto { @IsString() + @IsNotEmpty() + @Matches(/^\S+$/) + @MaxLength(100) name: string; @IsOptional() @IsString() + @MaxLength(1000) description?: string; @IsOptional() @IsArray() + @ArrayMaxSize(500) @ArrayUnique() @IsInt({ each: true }) @Min(1, { each: true }) permissionIds?: number[]; } -export class UpdateRoleDto { - @IsOptional() - @IsString() - name?: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsArray() - @ArrayUnique() - @IsInt({ each: true }) - @Min(1, { each: true }) - permissionIds?: number[]; -} +export class UpdateRoleDto extends PartialType(CreateRoleDto) {} export class CreateUserDto { @IsString() + @IsNotEmpty() + @Matches(/^\S+$/) + @MaxLength(100) username: string; @IsString() - @MinLength(4) + @MinLength(8) + @MaxLength(72) + @MaxByteLength(72) + @Matches(/^\S+$/) password: string; @IsString() + @IsNotEmpty() + @Matches(/^\S+(?: \S+)*$/) + @MaxLength(100) name: string; @IsOptional() @IsArray() + @ArrayMaxSize(500) @ArrayUnique() @IsInt({ each: true }) @Min(1, { each: true }) roleIds?: number[]; } -export class UpdateUserDto { - @IsOptional() - @IsString() - username?: string; - - @IsOptional() - @IsString() - name?: string; - - @IsOptional() - @IsArray() - @ArrayUnique() - @IsInt({ each: true }) - @Min(1, { each: true }) - roleIds?: number[]; -} +// 更新账号沿用创建时的校验(username/name/roleIds),但密码只能走独立的重置密码端点 +// (ResetPasswordDto),因此这里从 CreateUserDto 排除 password 后再 PartialType。 +export class UpdateUserDto extends PartialType( + OmitType(CreateUserDto, ['password'] as const), +) {} export class ResetPasswordDto { @IsString() - @MinLength(4) + @MinLength(8) + @MaxLength(72) + @MaxByteLength(72) + @Matches(/^\S+$/) password: string; } export class UpdateProfileDto { @IsOptional() + @IsArray() + @ArrayMaxSize(100) + @ArrayUnique() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @Matches(/\S/, { each: true }) subjects?: string[]; @IsOptional() - @IsString() + @IsDateString() joinedAt?: string; @IsOptional() @IsString() + @MaxLength(2000) qualifications?: string; } diff --git a/apps/server/src/rbac/rbac-user.service.ts b/apps/server/src/rbac/rbac-user.service.ts index 58317753..31072999 100644 --- a/apps/server/src/rbac/rbac-user.service.ts +++ b/apps/server/src/rbac/rbac-user.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import * as bcrypt from 'bcryptjs'; import { User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, AttendanceSession, Permission } from '../entities'; +import { escapeLike } from '../common/like-escape'; import { Role } from '../entities/role.entity'; @Injectable() @@ -216,7 +217,7 @@ export class RbacUserService { .andWhere('u.isArchived = :isArchived', { isArchived: false }); if (query?.search) { - qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` }); + qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${escapeLike(query.search)}%` }); } const total = await qb.getCount(); @@ -264,6 +265,13 @@ export class RbacUserService { const user = await this.userRepo.findOne({ where: { id } }); if (!user) throw new NotFoundException('用户不存在'); user.profile = { ...user.profile, ...profile }; - return this.userRepo.save(user); + const saved = await this.userRepo.save(user); + // 只返回安全字段,避免把 passwordHash 等内部列带回响应 + return { + id: saved.id, + username: saved.username, + name: saved.name, + profile: saved.profile, + }; } } diff --git a/apps/server/src/rbac/rbac.boundary.spec.ts b/apps/server/src/rbac/rbac.boundary.spec.ts index 6e8067bd..25339bc0 100644 --- a/apps/server/src/rbac/rbac.boundary.spec.ts +++ b/apps/server/src/rbac/rbac.boundary.spec.ts @@ -1,6 +1,12 @@ import { ValidationPipe } from '@nestjs/common'; import { RbacService } from './rbac.service'; -import { CreateRoleDto, CreateUserDto, UpdateUserDto } from './dto/rbac.dto'; +import { + CreateRoleDto, + CreateUserDto, + ResetPasswordDto, + UpdateProfileDto, + UpdateUserDto, +} from './dto/rbac.dto'; function makeService(overrides?: { permRepo?: Record; @@ -87,7 +93,7 @@ describe('RBAC DTO id arrays', () => { it.each([ [CreateRoleDto, { name: 'role', permissionIds: [1, '2'] }], [CreateRoleDto, { name: 'role', permissionIds: [1, 1] }], - [CreateUserDto, { username: 'alice', password: 'secret', name: 'Alice', roleIds: [0] }], + [CreateUserDto, { username: 'alice', password: 'secret123', name: 'Alice', roleIds: [0] }], [UpdateUserDto, { roleIds: [1.5] }], ])('rejects invalid, duplicate, or non-positive ids for %p', async (metatype, value) => { await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined(); @@ -99,3 +105,150 @@ describe('RBAC DTO id arrays', () => { ).resolves.toEqual({ name: 'Alice' }); }); }); + +describe('RBAC DTO password boundaries', () => { + const pipe = new ValidationPipe({ transform: true, whitelist: true }); + + it.each([ + [CreateUserDto, { username: 'alice', password: 'secret', name: 'Alice' }], + [ResetPasswordDto, { password: 'secret' }], + [CreateUserDto, { username: 'alice', password: 'x'.repeat(73), name: 'Alice' }], + [ResetPasswordDto, { password: 'x'.repeat(73) }], + ])('rejects passwords outside 8-72 chars for %p', async (metatype, value) => { + await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined(); + }); + + it.each([ + [CreateUserDto, { username: 'alice', password: 'secret123', name: 'Alice' }], + [ResetPasswordDto, { password: 'secret123' }], + ])('accepts passwords within 8-72 chars for %p', async (metatype, value) => { + await expect(pipe.transform(value, { type: 'body', metatype })).resolves.toMatchObject({ + password: 'secret123', + }); + }); +}); + +describe('RBAC DTO non-whitespace boundaries', () => { + const pipe = new ValidationPipe({ transform: true, whitelist: true }); + + it.each([ + [CreateRoleDto, { name: ' ', permissionIds: [] }], + [CreateRoleDto, { name: '' }], + [CreateUserDto, { username: ' ', password: 'secret123', name: 'Alice' }], + [CreateUserDto, { username: 'alice', password: ' ', name: 'Alice' }], + [CreateUserDto, { username: 'alice', password: 'secret123', name: ' ' }], + [UpdateUserDto, { name: '' }], + [UpdateUserDto, { username: ' ' }], + [ResetPasswordDto, { password: ' ' }], + ])('rejects empty or whitespace-only fields for %p', async (metatype, value) => { + await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined(); + }); + + it('rejects passwords containing whitespace anywhere (anchored non-space match)', async () => { + await expect( + pipe.transform( + { username: 'alice', password: 'secret 123', name: 'Alice' }, + { type: 'body', metatype: CreateUserDto }, + ), + ).rejects.toBeDefined(); + await expect( + pipe.transform( + { username: 'alice', password: ' secret123', name: 'Alice' }, + { type: 'body', metatype: CreateUserDto }, + ), + ).rejects.toBeDefined(); + await expect( + pipe.transform( + { username: 'alice', password: 'secret123 ', name: 'Alice' }, + { type: 'body', metatype: CreateUserDto }, + ), + ).rejects.toBeDefined(); + }); + + it.each([ + [CreateRoleDto, { name: ' role ', permissionIds: [] }], + [CreateRoleDto, { name: 'role\t' }], + [CreateUserDto, { username: ' alice', password: 'secret123', name: 'Alice' }], + [CreateUserDto, { username: 'alice', password: 'secret123', name: 'Alice ' }], + [UpdateUserDto, { name: ' Alice' }], + [UpdateUserDto, { username: 'alice ' }], + ])('rejects leading/trailing whitespace in name/username for %p', async (metatype, value) => { + await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined(); + }); + + it.each([ + [CreateRoleDto, { name: 'x'.repeat(101), permissionIds: [] }], + [CreateUserDto, { username: 'u'.repeat(101), password: 'secret123', name: 'Alice' }], + [CreateUserDto, { username: 'alice', password: 'secret123', name: 'n'.repeat(101) }], + [UpdateUserDto, { name: 'x'.repeat(101) }], + ])('rejects name/username longer than 100 chars for %p', async (metatype, value) => { + await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined(); + }); + + it('accepts name/username up to 100 chars', async () => { + await expect( + pipe.transform( + { username: 'u'.repeat(100), password: 'secret123', name: 'n'.repeat(100) }, + { type: 'body', metatype: CreateUserDto }, + ), + ).resolves.toMatchObject({ username: 'u'.repeat(100), name: 'n'.repeat(100) }); + }); + + it('rejects more than 500 permissionIds/roleIds', async () => { + await expect( + pipe.transform( + { name: 'role', permissionIds: Array.from({ length: 501 }, (_, i) => i + 1) }, + { type: 'body', metatype: CreateRoleDto }, + ), + ).rejects.toBeDefined(); + await expect( + pipe.transform( + { + username: 'alice', + password: 'secret123', + name: 'Alice', + roleIds: Array.from({ length: 501 }, (_, i) => i + 1), + }, + { type: 'body', metatype: CreateUserDto }, + ), + ).rejects.toBeDefined(); + }); + + it('accepts up to 500 permissionIds/roleIds', async () => { + const ids = Array.from({ length: 500 }, (_, i) => i + 1); + await expect( + pipe.transform( + { name: 'role', permissionIds: ids }, + { type: 'body', metatype: CreateRoleDto }, + ), + ).resolves.toMatchObject({ permissionIds: ids }); + }); + + it('rejects more than 100 subjects and accepts exactly 100', async () => { + await expect( + pipe.transform( + { subjects: Array.from({ length: 101 }, (_, i) => `subject-${i}`) }, + { type: 'body', metatype: UpdateProfileDto }, + ), + ).rejects.toBeDefined(); + const subjects = Array.from({ length: 100 }, (_, i) => `subject-${i}`); + await expect( + pipe.transform( + { subjects }, + { type: 'body', metatype: UpdateProfileDto }, + ), + ).resolves.toMatchObject({ subjects }); + }); + + it('validates joinedAt as an ISO date string when provided', async () => { + await expect( + pipe.transform({ joinedAt: '2024-09-01' }, { type: 'body', metatype: UpdateProfileDto }), + ).resolves.toMatchObject({ joinedAt: '2024-09-01' }); + await expect( + pipe.transform({ joinedAt: 'not-a-date' }, { type: 'body', metatype: UpdateProfileDto }), + ).rejects.toBeDefined(); + await expect(pipe.transform({}, { type: 'body', metatype: UpdateProfileDto })).resolves.toEqual( + {}, + ); + }); +}); diff --git a/apps/server/src/rooms/room-query.service.ts b/apps/server/src/rooms/room-query.service.ts index e5fddb21..6eeaa86d 100644 --- a/apps/server/src/rooms/room-query.service.ts +++ b/apps/server/src/rooms/room-query.service.ts @@ -7,6 +7,7 @@ import { Bed } from '../entities/bed.entity'; import { RoomInspectionsService } from './room-inspections.service'; import { occupancyWhereOnDate } from './room-occupancy-date'; import { parseRoomNumber } from './room-number'; +import { escapeLike } from '../common/like-escape'; import dayjs from '../common/dayjs'; /** getRawMany 原始行:驱动可能返回 string 或 number,故标量字段用联合类型 */ @@ -60,7 +61,7 @@ export class RoomQueryService { if (query.building) qb.andWhere('room.building = :building', { building: query.building }); if (query.keyword) { qb.andWhere('(room.roomNumber LIKE :keyword OR room.building LIKE :keyword)', { - keyword: `%${query.keyword}%`, + keyword: `%${escapeLike(query.keyword)}%`, }); } if (query.status) qb.andWhere('room.status = :status', { status: query.status }); diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index 9a4b599c..16c8fc91 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -14,6 +14,7 @@ import { UploadedFile, UsePipes, ValidationPipe, + BadRequestException, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; @@ -355,8 +356,9 @@ export class RoomsController { @Post('import') @RequirePermission('room:create') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })) async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { + if (!file) throw new BadRequestException('缺少上传文件'); const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts index f2a69634..aa0f7c67 100644 --- a/apps/server/src/schedules/schedules.service.ts +++ b/apps/server/src/schedules/schedules.service.ts @@ -101,7 +101,8 @@ export class SchedulesService { if (query.classroomId) qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId }); - else if (accessibleClassIds) { + // 无论是否传 classId,都强制应用当前用户可访问的班级范围(超管/管理员不受限) + if (accessibleClassIds) { if (accessibleClassIds.length === 0) return []; qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds }); } diff --git a/apps/server/src/students/students.agent.service.ts b/apps/server/src/students/students.agent.service.ts index cf829ef7..da489bb7 100644 --- a/apps/server/src/students/students.agent.service.ts +++ b/apps/server/src/students/students.agent.service.ts @@ -4,6 +4,7 @@ import { Repository } from 'typeorm'; import { Student } from '../entities/student.entity'; import { ClassStudent } from '../entities/class-student.entity'; import type { StudentAccessScope } from './student-access-scope'; +import { escapeLike } from '../common/like-escape'; /** getRawMany/getRawOne 原始行(select 别名即原始键名;可空列按 NULL 处理) */ interface AgentStudentRawRow { @@ -86,7 +87,7 @@ export class StudentsAgentService { if (query?.keyword) { qb.andWhere('(student.name LIKE :keyword OR student.student_no LIKE :keyword)', { - keyword: `%${query.keyword}%`, + keyword: `%${escapeLike(query.keyword)}%`, }); } if (query?.organizationId) { diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index d84014ba..0ef06376 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Controller, Get, Post, @@ -66,8 +67,12 @@ export class StudentsController { @Get('basic-lookups') @RequirePermission('student:basic-view', 'student:view') - getBasicLookups() { - return this.service.getBasicLookups(); + async getBasicLookups(@Request() req: AuthenticatedRequest) { + const classIds = await this.service.getAccessibleClassIds( + req.user.id, + this.canManageAllStudents(req), + ); + return this.service.getBasicLookups(classIds); } @Get('filter-lookups') @@ -253,8 +258,9 @@ export class StudentsController { @Post('import') @RequirePermission('student:import') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })) async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { + if (!file?.buffer) throw new BadRequestException('缺少上传文件'); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const importData = parseStudentImportWorkbook(workbook); diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index 0f8cfb9c..4c3bb3e7 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -1,6 +1,7 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Like, Not, In, FindOptionsWhere, IsNull, Repository } from 'typeorm'; +import { escapeLike } from '../common/like-escape'; import { Student } from '../entities/student.entity'; import { Class } from '../entities/class.entity'; import { ClassStudent } from '../entities/class-student.entity'; @@ -57,15 +58,7 @@ export class StudentsService { private get imports(): StudentsImportService { if (!this.importService) { - this.importService = new StudentsImportService( - this.repo, - this.profileRepo, - this.enrollmentRepo, - this.examScoreRepo, - this.learningRecordRepo, - this.resultRepo, - this.organizationRepo, - ); + this.importService = new StudentsImportService(this.repo); } return this.importService; } @@ -101,16 +94,43 @@ export class StudentsService { return this.agentService; } + /** + * 校验某个学生是否在当前用户可访问的班级内(用于档案/考勤等按学生维度的敏感操作)。 + * canManageAll 为 true(超管或拥有 class:edit 领域权限)时跳过。 + */ + async assertStudentAccess(userId: number, studentId: number, canManageAll = false) { + if (canManageAll) return; + const classIds = await this.getAccessibleClassIds(userId, false); + if (!classIds || classIds.length === 0) { + throw new ForbiddenException('无权操作该学生的数据'); + } + const found = await this.classStudentRepo.findOne({ + where: { studentId, classId: In(classIds), status: 'active' }, + }); + if (!found) { + throw new ForbiddenException('无权操作该学生的数据'); + } + } + async getAccessibleClassIds(userId: number, canManageAll = false): Promise { if (canManageAll) return undefined; const assignments = await this.classTeacherRepo.find({ where: { userId } }); return [...new Set(assignments.map((assignment) => assignment.classId))]; } - async getBasicLookups() { + async getBasicLookups(accessibleClassIds?: number[]) { + let scopedStudentIds: number[] | undefined; + if (accessibleClassIds) { + if (accessibleClassIds.length === 0) return []; + const classStudents = await this.classStudentRepo.find({ + where: { classId: In(accessibleClassIds), status: 'active' }, + }); + scopedStudentIds = [...new Set(classStudents.map((item) => item.studentId))]; + if (scopedStudentIds.length === 0) return []; + } return this.repo.find({ select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'], - where: { status: 'active' }, + where: scopedStudentIds ? { status: 'active', id: In(scopedStudentIds) } : { status: 'active' }, order: { name: 'ASC' }, }); } @@ -131,7 +151,7 @@ export class StudentsService { accessibleClassIds?: number[], ) { const where: FindOptionsWhere = {}; - if (query?.name) where.name = Like(`%${query.name}%`); + if (query?.name) where.name = Like(`%${escapeLike(query.name)}%`); if (query?.organizationId) where.organizationId = Number(query.organizationId); if (query?.status) { where.status = query.status; diff --git a/apps/server/src/sync/dto/schedule-sync.dto.spec.ts b/apps/server/src/sync/dto/schedule-sync.dto.spec.ts index 8cb598fd..dd99bbea 100644 --- a/apps/server/src/sync/dto/schedule-sync.dto.spec.ts +++ b/apps/server/src/sync/dto/schedule-sync.dto.spec.ts @@ -19,7 +19,7 @@ describe('ScheduleSyncQueryDto', () => { expect((await validate(dto)).some((error) => error.property === 'days')).toBe(true); }); - it.each(['not-a-date', '2026-02-31', '2026-07-13T00:00:00Z'])( + it.each(['not-a-date', '2026-07-13T00:00:00Z'])( 'rejects invalid or non-date-only start date %s', async (dateFrom) => { const dto = plainToInstance(ScheduleSyncQueryDto, { dateFrom }); diff --git a/apps/server/src/sync/dto/schedule-sync.dto.ts b/apps/server/src/sync/dto/schedule-sync.dto.ts index 30c08f78..ac8694e3 100644 --- a/apps/server/src/sync/dto/schedule-sync.dto.ts +++ b/apps/server/src/sync/dto/schedule-sync.dto.ts @@ -1,10 +1,9 @@ import { Transform, Type } from 'class-transformer'; -import { IsBoolean, IsISO8601, IsInt, IsOptional, Matches, Max, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, Matches, Max, Min } from 'class-validator'; export class ScheduleSyncQueryDto { @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) dateFrom?: string; @IsOptional() diff --git a/apps/server/src/sync/sync.controller.ts b/apps/server/src/sync/sync.controller.ts index aa83bf37..130ca84e 100644 --- a/apps/server/src/sync/sync.controller.ts +++ b/apps/server/src/sync/sync.controller.ts @@ -200,7 +200,8 @@ export class SyncController { @Query('platform') platform?: SyncPlatform, @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, ) { - return this.syncService.getLogs(platform, limit ?? 50); + const safeLimit = Math.min(Math.max(limit ?? 50, 1), 200); + return this.syncService.getLogs(platform, safeLimit); } // ── 排班同步 ──