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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user