Security hardening and edge case fixes across frontend

Security Improvements:
- Add input sanitization utilities (XSS, SQL injection prevention)
- Add token validation with JWT structure verification
- Add secure form validators with pattern enforcement
- Implement proper token storage with encryption support

Service Hardening:
- Add timeout (30s) and retry logic (3 attempts) to all API calls
- Add UUID validation for all ID parameters
- Add null/undefined checks with defensive defaults
- Proper error propagation with typed error handling

Component Fixes:
- Fix memory leaks with takeUntilDestroyed pattern
- Remove mock data fallbacks in error handlers
- Add proper loading/error state management
- Add form field length limits and validation

Files affected: 51 (6000+ lines added for security)
This commit is contained in:
Mahi
2026-02-08 02:10:09 -04:00
parent 80566bf0a2
commit 2c10cd5662
51 changed files with 6094 additions and 656 deletions

View File

@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService, UploadProgress } from '../../../core/services/api.service';
import { Observable, throwError, map, catchError } from 'rxjs';
import { ApiService, UploadProgress, validateId } from '../../../core/services/api.service';
import {
DocumentResponseDto,
DocumentVersionResponseDto,
@@ -8,6 +8,139 @@ import {
DocumentType,
} from '../../../api/models';
// File validation constants
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
const ALLOWED_MIME_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
];
const ALLOWED_EXTENSIONS = [
'.pdf',
'.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
'.doc',
'.docx',
'.xls',
'.xlsx',
'.txt',
];
/**
* Validates a file before upload
*/
function validateFile(file: File | null | undefined): File {
if (!file) {
throw new Error('File is required');
}
if (!(file instanceof File)) {
throw new Error('Invalid file object');
}
// Check file size
if (file.size === 0) {
throw new Error('File is empty');
}
if (file.size > MAX_FILE_SIZE_BYTES) {
const maxSizeMB = MAX_FILE_SIZE_BYTES / (1024 * 1024);
throw new Error(`File size exceeds maximum allowed size of ${maxSizeMB}MB`);
}
// Check MIME type
if (file.type && !ALLOWED_MIME_TYPES.includes(file.type)) {
throw new Error(`File type '${file.type}' is not allowed`);
}
// Check file extension
const fileName = file.name || '';
const extension = fileName.toLowerCase().substring(fileName.lastIndexOf('.'));
if (!ALLOWED_EXTENSIONS.includes(extension)) {
throw new Error(`File extension '${extension}' is not allowed`);
}
// Sanitize filename - prevent path traversal
if (fileName.includes('..') || fileName.includes('/') || fileName.includes('\\')) {
throw new Error('Invalid filename');
}
return file;
}
/**
* Validates document type
*/
function validateDocType(docType: DocumentType | string | undefined | null): DocumentType {
if (!docType) {
throw new Error('Document type is required');
}
const validDocTypes: DocumentType[] = [
'FIRE_SAFETY_CERTIFICATE',
'BUILDING_PLAN',
'PROPERTY_OWNERSHIP',
'INSPECTION_REPORT',
'POLLUTION_CERTIFICATE',
'ELECTRICAL_SAFETY_CERTIFICATE',
'STRUCTURAL_STABILITY_CERTIFICATE',
'IDENTITY_PROOF',
'ADDRESS_PROOF',
'OTHER',
];
if (!validDocTypes.includes(docType as DocumentType)) {
throw new Error(`Invalid document type: ${docType}`);
}
return docType as DocumentType;
}
/**
* Sanitizes description text
*/
function sanitizeDescription(description: string | undefined | null): string | undefined {
if (!description) {
return undefined;
}
if (typeof description !== 'string') {
return undefined;
}
const trimmed = description.trim();
if (trimmed.length === 0) {
return undefined;
}
// Limit length
if (trimmed.length > 1000) {
throw new Error('Description cannot exceed 1000 characters');
}
return trimmed;
}
/**
* Ensures array response is valid
*/
function ensureValidArray<T>(response: T[] | null | undefined): T[] {
return Array.isArray(response) ? response : [];
}
@Injectable({
providedIn: 'root',
})
@@ -15,20 +148,69 @@ export class DocumentService {
private readonly api = inject(ApiService);
getDocuments(requestId: string): Observable<DocumentResponseDto[]> {
return this.api.get<DocumentResponseDto[]>(`/requests/${requestId}/documents`);
try {
const validRequestId = validateId(requestId, 'Request ID');
return this.api.get<DocumentResponseDto[]>(`/requests/${validRequestId}/documents`).pipe(
map((response) => ensureValidArray(response)),
catchError((error: unknown) => {
const message =
error instanceof Error ? error.message : `Failed to fetch documents for request: ${requestId}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
getDocument(requestId: string, documentId: string): Observable<DocumentResponseDto> {
return this.api.get<DocumentResponseDto>(`/requests/${requestId}/documents/${documentId}`);
try {
const validRequestId = validateId(requestId, 'Request ID');
const validDocumentId = validateId(documentId, 'Document ID');
return this.api
.get<DocumentResponseDto>(`/requests/${validRequestId}/documents/${validDocumentId}`)
.pipe(
map((response) => {
if (!response) {
throw new Error('Document not found');
}
return response;
}),
catchError((error: unknown) => {
const message =
error instanceof Error ? error.message : `Failed to fetch document: ${documentId}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
getDocumentVersions(
requestId: string,
documentId: string
): Observable<DocumentVersionResponseDto[]> {
return this.api.get<DocumentVersionResponseDto[]>(
`/requests/${requestId}/documents/${documentId}/versions`
);
getDocumentVersions(requestId: string, documentId: string): Observable<DocumentVersionResponseDto[]> {
try {
const validRequestId = validateId(requestId, 'Request ID');
const validDocumentId = validateId(documentId, 'Document ID');
return this.api
.get<DocumentVersionResponseDto[]>(
`/requests/${validRequestId}/documents/${validDocumentId}/versions`
)
.pipe(
map((response) => ensureValidArray(response)),
catchError((error: unknown) => {
const message =
error instanceof Error
? error.message
: `Failed to fetch versions for document: ${documentId}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
uploadDocument(
@@ -37,13 +219,31 @@ export class DocumentService {
docType: DocumentType,
description?: string
): Observable<DocumentResponseDto> {
const formData = new FormData();
formData.append('file', file);
formData.append('docType', docType);
if (description) {
formData.append('description', description);
try {
const validRequestId = validateId(requestId, 'Request ID');
const validFile = validateFile(file);
const validDocType = validateDocType(docType);
const sanitizedDescription = sanitizeDescription(description);
const formData = new FormData();
formData.append('file', validFile);
formData.append('docType', validDocType);
if (sanitizedDescription) {
formData.append('description', sanitizedDescription);
}
return this.api
.upload<DocumentResponseDto>(`/requests/${validRequestId}/documents`, formData)
.pipe(
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : 'Failed to upload document';
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
return this.api.upload<DocumentResponseDto>(`/requests/${requestId}/documents`, formData);
}
/**
@@ -55,41 +255,134 @@ export class DocumentService {
docType: DocumentType,
description?: string
): Observable<UploadProgress<DocumentResponseDto>> {
const formData = new FormData();
formData.append('file', file);
formData.append('docType', docType);
if (description) {
formData.append('description', description);
try {
const validRequestId = validateId(requestId, 'Request ID');
const validFile = validateFile(file);
const validDocType = validateDocType(docType);
const sanitizedDescription = sanitizeDescription(description);
const formData = new FormData();
formData.append('file', validFile);
formData.append('docType', validDocType);
if (sanitizedDescription) {
formData.append('description', sanitizedDescription);
}
return this.api
.uploadWithProgress<DocumentResponseDto>(`/requests/${validRequestId}/documents`, formData)
.pipe(
catchError((error: unknown) => {
const message =
error instanceof Error ? error.message : 'Failed to upload document with progress';
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
return this.api.uploadWithProgress<DocumentResponseDto>(`/requests/${requestId}/documents`, formData);
}
updateDocument(
requestId: string,
documentId: string,
file: File
): Observable<DocumentResponseDto> {
const formData = new FormData();
formData.append('file', file);
return this.api.upload<DocumentResponseDto>(
`/requests/${requestId}/documents/${documentId}`,
formData
);
updateDocument(requestId: string, documentId: string, file: File): Observable<DocumentResponseDto> {
try {
const validRequestId = validateId(requestId, 'Request ID');
const validDocumentId = validateId(documentId, 'Document ID');
const validFile = validateFile(file);
const formData = new FormData();
formData.append('file', validFile);
return this.api
.upload<DocumentResponseDto>(
`/requests/${validRequestId}/documents/${validDocumentId}`,
formData
)
.pipe(
catchError((error: unknown) => {
const message =
error instanceof Error ? error.message : `Failed to update document: ${documentId}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
deleteDocument(requestId: string, documentId: string): Observable<void> {
return this.api.delete<void>(`/requests/${requestId}/documents/${documentId}`);
try {
const validRequestId = validateId(requestId, 'Request ID');
const validDocumentId = validateId(documentId, 'Document ID');
return this.api
.delete<void>(`/requests/${validRequestId}/documents/${validDocumentId}`)
.pipe(
catchError((error: unknown) => {
const message =
error instanceof Error ? error.message : `Failed to delete document: ${documentId}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
getDownloadUrl(requestId: string, documentId: string): Observable<DownloadUrlResponseDto> {
return this.api.get<DownloadUrlResponseDto>(
`/requests/${requestId}/documents/${documentId}/download`
);
try {
const validRequestId = validateId(requestId, 'Request ID');
const validDocumentId = validateId(documentId, 'Document ID');
return this.api
.get<DownloadUrlResponseDto>(
`/requests/${validRequestId}/documents/${validDocumentId}/download`
)
.pipe(
map((response) => {
if (!response || !response.url) {
throw new Error('Invalid download URL response');
}
return response;
}),
catchError((error: unknown) => {
const message =
error instanceof Error
? error.message
: `Failed to get download URL for document: ${documentId}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
verifyDocument(requestId: string, documentId: string): Observable<{ verified: boolean }> {
return this.api.get<{ verified: boolean }>(
`/requests/${requestId}/documents/${documentId}/verify`
);
try {
const validRequestId = validateId(requestId, 'Request ID');
const validDocumentId = validateId(documentId, 'Document ID');
return this.api
.get<{ verified: boolean }>(
`/requests/${validRequestId}/documents/${validDocumentId}/verify`
)
.pipe(
map((response) => {
if (!response) {
return { verified: false };
}
return {
verified: typeof response.verified === 'boolean' ? response.verified : false,
};
}),
catchError((error: unknown) => {
const message =
error instanceof Error ? error.message : `Failed to verify document: ${documentId}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
}