From 733dfb643e0e06b922d6a4396ba0f3c468cbbcae Mon Sep 17 00:00:00 2001 From: Maryermarh Date: Fri, 25 Sep 2026 03:20:20 +0100 Subject: [PATCH] fix(auth): centralize session recovery and sanitize SVG uploads Add proactive expiry checks and centralized 401 refresh/logout handling, safely rasterize static SVG uploads, and clarify sanitizer module boundaries. Closes #1309 Closes #1310 Closes #1311 Closes #1312 --- backend/src/auth/auth.controller.ts | 8 + backend/src/dispute/dispute.controller.ts | 6 +- backend/src/dispute/dispute.service.ts | 10 +- backend/src/documents/documents.controller.ts | 11 +- .../pipes/file-validation.pipe.spec.ts | 140 ++++++ .../documents/pipes/file-validation.pipe.ts | 415 +++++++++++++++- .../pipes/mime-type-validation.pipe.ts | 1 + frontend/__tests__/abdulrcrtw.test.ts | 2 +- .../(protected)/admin/providers/page.tsx | 69 ++- .../[locale]/(protected)/admin/users/page.tsx | 98 ++-- .../(protected)/disputes/[id]/page.tsx | 274 +++++----- .../[locale]/(protected)/disputes/page.tsx | 87 ++-- .../(protected)/documents/upload/page.tsx | 81 ++- frontend/app/[locale]/(protected)/layout.tsx | 81 +-- .../(protected)/settings/data/page.tsx | 47 +- .../(protected)/settings/security/page.tsx | 3 +- frontend/app/[locale]/2fa/setup/page.tsx | 53 +- frontend/app/[locale]/2fa/verify/page.tsx | 33 +- .../app/[locale]/forgot-password/page.tsx | 20 +- frontend/app/[locale]/login/LoginForm.tsx | 21 +- frontend/app/[locale]/reset-password/page.tsx | 25 +- frontend/app/[locale]/verify-email/page.tsx | 54 +- frontend/components/LanguageSwitcher.tsx | 20 +- frontend/components/SessionBootstrap.test.tsx | 72 +++ frontend/components/SessionBootstrap.tsx | 72 +++ .../components/disputes/FileDisputeModal.tsx | 21 +- .../components/layout/NotificationBell.tsx | 39 +- frontend/lib/__tests__/session-expiry.test.ts | 156 +++--- frontend/lib/api-client.ts | 466 ++++++++++++------ frontend/lib/auth-session.test.ts | 131 +++++ frontend/lib/auth-session.ts | 261 +++++++++- frontend/lib/disputes.test.ts | 94 ++++ frontend/lib/disputes.ts | 218 ++++++++ frontend/lib/document-mapper.test.ts | 36 ++ frontend/lib/document-mapper.ts | 36 ++ frontend/lib/document-sanitizer.test.ts | 83 ++++ frontend/lib/document-sanitizer.ts | 258 +++++++++- frontend/lib/sanitize.ts | 25 +- frontend/lib/session-expiry-warning.ts | 20 +- frontend/package-lock.json | 2 +- frontend/package.json | 1 + frontend/test-utils/LoginForm.test.tsx | 24 +- .../test-utils/admin-users-mutations.test.tsx | 94 ++++ frontend/test-utils/api-client.test.ts | 307 ++++++++++-- .../test-utils/language-switcher.test.tsx | 18 +- frontend/test-utils/mocks/handlers.ts | 118 ++++- .../test-utils/notification-bell.test.tsx | 99 ++++ 47 files changed, 3296 insertions(+), 914 deletions(-) create mode 100644 frontend/components/SessionBootstrap.test.tsx create mode 100644 frontend/components/SessionBootstrap.tsx create mode 100644 frontend/lib/auth-session.test.ts create mode 100644 frontend/lib/disputes.test.ts create mode 100644 frontend/lib/disputes.ts create mode 100644 frontend/lib/document-mapper.test.ts create mode 100644 frontend/lib/document-mapper.ts create mode 100644 frontend/lib/document-sanitizer.test.ts create mode 100644 frontend/test-utils/admin-users-mutations.test.tsx create mode 100644 frontend/test-utils/notification-bell.test.tsx diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index dfa80d44..facd1a8e 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -13,6 +13,7 @@ import { AuthGuard } from '@nestjs/passport'; import { Request, Response } from 'express'; import { Profile as GoogleProfile } from 'passport-google-oauth20'; import { Profile as GithubProfile } from 'passport-github2'; +import { User } from '../users/entities/user.entity'; import { AuthService } from './auth.service'; import { RegisterAuthDto } from './dto/register-auth.dto'; @@ -54,6 +55,13 @@ export class AuthController { return { message: 'Logged out successfully' }; } + @Get('me') + @UseGuards(JwtAuthGuard) + getCurrentUser(@Req() req: Request & { user?: User }) { + const user = req.user!; + return { id: user.id, role: user.role }; + } + @Get('google') @UseGuards(AuthGuard('google')) googleAuth() { diff --git a/backend/src/dispute/dispute.controller.ts b/backend/src/dispute/dispute.controller.ts index f953ab27..0ebad0af 100644 --- a/backend/src/dispute/dispute.controller.ts +++ b/backend/src/dispute/dispute.controller.ts @@ -66,8 +66,12 @@ export class DisputeController { @Param('id') id: string, @Req() req: Request & { user?: User }, ): Promise { - const dispute = await this.disputeService.findOne(id); const user = req.user!; + const dispute = await this.disputeService.findOne( + id, + user.id, + user.role === 'admin', + ); if (dispute.filedBy !== user.id && user.role !== 'admin') { throw new ForbiddenException('Access denied'); diff --git a/backend/src/dispute/dispute.service.ts b/backend/src/dispute/dispute.service.ts index 1b38ada4..33719154 100644 --- a/backend/src/dispute/dispute.service.ts +++ b/backend/src/dispute/dispute.service.ts @@ -1,9 +1,9 @@ import { + BadRequestException, Injectable, NotFoundException, UnauthorizedException, } from '@nestjs/common'; -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Dispute, DisputeStatus, ALLOWED_DISPUTE_TRANSITIONS } from './entities/dispute.entity'; @@ -94,12 +94,16 @@ export class DisputeService { }; } - async findOne(id: string, userId: string): Promise { + async findOne( + id: string, + userId?: string, + isAdmin = false, + ): Promise { const dispute = await this.disputeRepo.findOne({ where: { id } }); if (!dispute) { throw new NotFoundException(`Dispute ${id} not found`); } - if (dispute.filedBy !== userId) { + if (!isAdmin && userId && dispute.filedBy !== userId) { throw new UnauthorizedException('Unauthorized access'); } return this.toResponseDto(dispute); diff --git a/backend/src/documents/documents.controller.ts b/backend/src/documents/documents.controller.ts index ee818019..22b2cc22 100644 --- a/backend/src/documents/documents.controller.ts +++ b/backend/src/documents/documents.controller.ts @@ -37,7 +37,12 @@ import { FileValidationPipe } from './pipes/file-validation.pipe'; import { ListDocumentsDto } from './dto/list-documents.dto'; import { DocumentResponseDto } from './dto/document-response.dto'; -const ALLOWED_MIME_TYPES = ['application/pdf', 'image/png', 'image/jpeg']; +const ALLOWED_MIME_TYPES = [ + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/svg+xml', +]; const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; const DEFAULT_USER_QUOTA = 20; @@ -49,7 +54,9 @@ const fileFilter: multer.Options['fileFilter'] = (_req, file, callback) => { } return callback( - new BadRequestException('Only PDF, PNG, or JPEG files are allowed'), + new BadRequestException( + 'Only PDF, PNG, JPEG, or SVG files are allowed', + ), ); }; diff --git a/backend/src/documents/pipes/file-validation.pipe.spec.ts b/backend/src/documents/pipes/file-validation.pipe.spec.ts index d3a6fdce..5cf00850 100644 --- a/backend/src/documents/pipes/file-validation.pipe.spec.ts +++ b/backend/src/documents/pipes/file-validation.pipe.spec.ts @@ -44,6 +44,10 @@ async function createValidPng(): Promise { .toBuffer(); } +function createSvg(markup: string): Buffer { + return Buffer.from(markup, 'utf8'); +} + describe('FileValidationPipe', () => { let pipe: FileValidationPipe; @@ -90,6 +94,142 @@ describe('FileValidationPipe', () => { expect(result.mimetype).toBe('image/jpeg'); }); + it('should accept static SVG content and store a bounded PNG', async () => { + const buffer = createSvg(` + + Parcel A & B + + + + + + + Parcel A + + `); + const file = createFile(buffer, 'image/svg+xml', 'survey.svg'); + + const result = await pipe.transform(file); + + expect(result.mimetype).toBe('image/png'); + expect(result.buffer.subarray(1, 4).toString('ascii')).toBe('PNG'); + expect(result.size).toBe(result.buffer.length); + }); + + it.each<[string, RegExp]>([ + ['', /active or resource-bearing content/i], + ['