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 } from '../../../core/services/api.service';
import { Observable, throwError, map, catchError } from 'rxjs';
import { ApiService, validateId, validatePagination } from '../../../core/services/api.service';
import {
WorkflowResponseDto,
CreateWorkflowDto,
@@ -9,6 +9,118 @@ import {
WorkflowValidationResultDto,
} from '../../../api/models';
/**
* Ensures response has valid data array for paginated workflows
*/
function ensureValidPaginatedResponse(
response: PaginatedWorkflowsResponse | null | undefined,
page: number,
limit: number
): PaginatedWorkflowsResponse {
if (!response) {
return {
data: [],
total: 0,
page,
limit,
totalPages: 0,
hasNextPage: false,
};
}
return {
data: Array.isArray(response.data) ? response.data : [],
total: typeof response.total === 'number' && response.total >= 0 ? response.total : 0,
page: typeof response.page === 'number' && response.page >= 1 ? response.page : page,
limit: typeof response.limit === 'number' && response.limit >= 1 ? response.limit : limit,
totalPages: typeof response.totalPages === 'number' && response.totalPages >= 0 ? response.totalPages : 0,
hasNextPage: typeof response.hasNextPage === 'boolean' ? response.hasNextPage : false,
};
}
/**
* Validates workflow data for creation
*/
function validateCreateWorkflowDto(dto: CreateWorkflowDto | null | undefined): CreateWorkflowDto {
if (!dto) {
throw new Error('Workflow data is required');
}
if (!dto.name || typeof dto.name !== 'string' || dto.name.trim().length === 0) {
throw new Error('Workflow name is required');
}
if (dto.name.trim().length > 200) {
throw new Error('Workflow name cannot exceed 200 characters');
}
if (!dto.departmentId || typeof dto.departmentId !== 'string' || dto.departmentId.trim().length === 0) {
throw new Error('Department ID is required');
}
if (!dto.stages || !Array.isArray(dto.stages) || dto.stages.length === 0) {
throw new Error('At least one workflow stage is required');
}
// Validate each stage
dto.stages.forEach((stage, index) => {
if (!stage.name || typeof stage.name !== 'string' || stage.name.trim().length === 0) {
throw new Error(`Stage ${index + 1}: Name is required`);
}
if (!stage.order || typeof stage.order !== 'number' || stage.order < 1) {
throw new Error(`Stage ${index + 1}: Valid order is required`);
}
});
return {
...dto,
name: dto.name.trim(),
description: dto.description?.trim() || undefined,
departmentId: dto.departmentId.trim(),
};
}
/**
* Validates workflow data for update
*/
function validateUpdateWorkflowDto(dto: UpdateWorkflowDto | null | undefined): UpdateWorkflowDto {
if (!dto) {
throw new Error('Update data is required');
}
const sanitized: UpdateWorkflowDto = {};
if (dto.name !== undefined) {
if (typeof dto.name !== 'string' || dto.name.trim().length === 0) {
throw new Error('Workflow name cannot be empty');
}
if (dto.name.trim().length > 200) {
throw new Error('Workflow name cannot exceed 200 characters');
}
sanitized.name = dto.name.trim();
}
if (dto.description !== undefined) {
sanitized.description = typeof dto.description === 'string' ? dto.description.trim() : undefined;
}
if (dto.isActive !== undefined) {
if (typeof dto.isActive !== 'boolean') {
throw new Error('isActive must be a boolean');
}
sanitized.isActive = dto.isActive;
}
if (dto.stages !== undefined) {
if (!Array.isArray(dto.stages)) {
throw new Error('Stages must be an array');
}
sanitized.stages = dto.stages;
}
return sanitized;
}
@Injectable({
providedIn: 'root',
})
@@ -16,30 +128,134 @@ export class WorkflowService {
private readonly api = inject(ApiService);
getWorkflows(page = 1, limit = 10): Observable<PaginatedWorkflowsResponse> {
return this.api.get<PaginatedWorkflowsResponse>('/workflows', { page, limit });
const validated = validatePagination(page, limit);
return this.api
.get<PaginatedWorkflowsResponse>('/workflows', {
page: validated.page,
limit: validated.limit,
})
.pipe(
map((response) => ensureValidPaginatedResponse(response, validated.page, validated.limit)),
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : 'Failed to fetch workflows';
return throwError(() => new Error(message));
})
);
}
getWorkflow(id: string): Observable<WorkflowResponseDto> {
return this.api.get<WorkflowResponseDto>(`/workflows/${id}`);
try {
const validId = validateId(id, 'Workflow ID');
return this.api.get<WorkflowResponseDto>(`/workflows/${validId}`).pipe(
map((response) => {
if (!response) {
throw new Error('Workflow not found');
}
// Ensure nested arrays are valid
return {
...response,
stages: Array.isArray(response.stages) ? response.stages : [],
};
}),
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : `Failed to fetch workflow: ${id}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
createWorkflow(dto: CreateWorkflowDto): Observable<WorkflowResponseDto> {
return this.api.post<WorkflowResponseDto>('/workflows', dto);
try {
const sanitizedDto = validateCreateWorkflowDto(dto);
return this.api.post<WorkflowResponseDto>('/workflows', sanitizedDto).pipe(
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : 'Failed to create workflow';
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
updateWorkflow(id: string, dto: UpdateWorkflowDto): Observable<WorkflowResponseDto> {
return this.api.patch<WorkflowResponseDto>(`/workflows/${id}`, dto);
try {
const validId = validateId(id, 'Workflow ID');
const sanitizedDto = validateUpdateWorkflowDto(dto);
return this.api.patch<WorkflowResponseDto>(`/workflows/${validId}`, sanitizedDto).pipe(
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : `Failed to update workflow: ${id}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
deleteWorkflow(id: string): Observable<void> {
return this.api.delete<void>(`/workflows/${id}`);
try {
const validId = validateId(id, 'Workflow ID');
return this.api.delete<void>(`/workflows/${validId}`).pipe(
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : `Failed to delete workflow: ${id}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
validateWorkflow(dto: CreateWorkflowDto): Observable<WorkflowValidationResultDto> {
return this.api.post<WorkflowValidationResultDto>('/workflows/validate', dto);
try {
const sanitizedDto = validateCreateWorkflowDto(dto);
return this.api.post<WorkflowValidationResultDto>('/workflows/validate', sanitizedDto).pipe(
map((response) => {
if (!response) {
return { isValid: false, errors: ['Validation failed: No response'] };
}
return {
isValid: typeof response.isValid === 'boolean' ? response.isValid : false,
errors: Array.isArray(response.errors) ? response.errors : [],
};
}),
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : 'Failed to validate workflow';
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
toggleActive(id: string, isActive: boolean): Observable<WorkflowResponseDto> {
return this.api.patch<WorkflowResponseDto>(`/workflows/${id}`, { isActive });
try {
const validId = validateId(id, 'Workflow ID');
if (typeof isActive !== 'boolean') {
return throwError(() => new Error('isActive must be a boolean value'));
}
return this.api.patch<WorkflowResponseDto>(`/workflows/${validId}`, { isActive }).pipe(
catchError((error: unknown) => {
const message =
error instanceof Error ? error.message : `Failed to toggle active status for workflow: ${id}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
}

View File

@@ -439,9 +439,9 @@
<!-- Bottom Info Bar -->
<footer class="builder-footer">
<div class="footer-left">
<mat-form-field appearance="outline" class="request-type-field">
<mat-label>Request Type</mat-label>
<mat-select [formControl]="workflowForm.controls.requestType">
<mat-form-field appearance="outline" class="workflow-type-field">
<mat-label>Workflow Type</mat-label>
<mat-select [formControl]="workflowForm.controls.workflowType">
<mat-option value="NEW_LICENSE">New License</mat-option>
<mat-option value="RENEWAL">Renewal</mat-option>
<mat-option value="AMENDMENT">Amendment</mat-option>

View File

@@ -20,6 +20,7 @@ import { MatDividerModule } from '@angular/material/divider';
import { WorkflowService } from '../services/workflow.service';
import { DepartmentService } from '../../departments/services/department.service';
import { NotificationService } from '../../../core/services/notification.service';
import { AuthService } from '../../../core/services/auth.service';
import { WorkflowResponseDto, WorkflowStage, DepartmentResponseDto } from '../../../api/models';
// Node position interface for canvas positioning
@@ -81,6 +82,7 @@ export class WorkflowBuilderComponent implements OnInit {
private readonly workflowService = inject(WorkflowService);
private readonly departmentService = inject(DepartmentService);
private readonly notification = inject(NotificationService);
private readonly authService = inject(AuthService);
// State signals
readonly loading = signal(false);
@@ -105,7 +107,7 @@ export class WorkflowBuilderComponent implements OnInit {
readonly workflowForm = this.fb.nonNullable.group({
name: ['', Validators.required],
description: [''],
requestType: ['NEW_LICENSE', Validators.required],
workflowType: ['NEW_LICENSE', Validators.required],
isActive: [true],
});
@@ -162,7 +164,7 @@ export class WorkflowBuilderComponent implements OnInit {
this.workflowForm.patchValue({
name: workflow.name,
description: workflow.description || '',
requestType: workflow.requestType,
workflowType: workflow.workflowType,
isActive: workflow.isActive,
});
@@ -505,10 +507,17 @@ export class WorkflowBuilderComponent implements OnInit {
this.saving.set(true);
const workflowData = this.workflowForm.getRawValue();
const currentUser = this.authService.currentUser();
// Get departmentId from current user or first stage with a department
const departmentId = currentUser?.departmentId ||
this.stages().find(s => s.departmentId)?.departmentId || '';
const dto = {
name: workflowData.name,
description: workflowData.description || undefined,
requestType: workflowData.requestType,
workflowType: workflowData.workflowType,
departmentId: departmentId,
stages: this.stages().map((s, index) => ({
id: s.id,
name: s.name,

View File

@@ -14,7 +14,16 @@ import { PageHeaderComponent } from '../../../shared/components/page-header/page
import { WorkflowService } from '../services/workflow.service';
import { DepartmentService } from '../../departments/services/department.service';
import { NotificationService } from '../../../core/services/notification.service';
import { AuthService } from '../../../core/services/auth.service';
import { DepartmentResponseDto } from '../../../api/models';
import {
INPUT_LIMITS,
noScriptValidator,
noNullBytesValidator,
notOnlyWhitespaceValidator,
normalizeWhitespace,
} from '../../../shared/utils/form-validators';
import { createSubmitDebounce } from '../../../shared/utils/form-utils';
@Component({
selector: 'app-workflow-form',
@@ -58,15 +67,24 @@ import { DepartmentResponseDto } from '../../../api/models';
<div class="form-grid">
<mat-form-field appearance="outline">
<mat-label>Workflow Name</mat-label>
<input matInput formControlName="name" placeholder="e.g., Resort License Workflow" />
<input matInput formControlName="name" placeholder="e.g., Resort License Workflow" [maxlength]="limits.NAME_MAX" />
@if (form.controls.name.hasError('required')) {
<mat-error>Name is required</mat-error>
}
@if (form.controls.name.hasError('minlength')) {
<mat-error>Minimum {{ limits.NAME_MIN }} characters required</mat-error>
}
@if (form.controls.name.hasError('maxlength')) {
<mat-error>Maximum {{ limits.NAME_MAX }} characters allowed</mat-error>
}
@if (form.controls.name.hasError('dangerousContent')) {
<mat-error>Invalid characters detected</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Request Type</mat-label>
<mat-select formControlName="requestType">
<mat-label>Workflow Type</mat-label>
<mat-select formControlName="workflowType">
<mat-option value="NEW_LICENSE">New License</mat-option>
<mat-option value="RENEWAL">Renewal</mat-option>
<mat-option value="AMENDMENT">Amendment</mat-option>
@@ -75,7 +93,14 @@ import { DepartmentResponseDto } from '../../../api/models';
<mat-form-field appearance="outline" class="full-width">
<mat-label>Description</mat-label>
<textarea matInput formControlName="description" rows="2"></textarea>
<textarea matInput formControlName="description" rows="2" [maxlength]="limits.DESCRIPTION_MAX"></textarea>
@if (form.controls.description.hasError('maxlength')) {
<mat-error>Maximum {{ limits.DESCRIPTION_MAX }} characters allowed</mat-error>
}
@if (form.controls.description.hasError('dangerousContent')) {
<mat-error>Invalid characters detected</mat-error>
}
<mat-hint align="end">{{ form.controls.description.value?.length || 0 }}/{{ limits.DESCRIPTION_MAX }}</mat-hint>
</mat-form-field>
</div>
</div>
@@ -83,9 +108,9 @@ import { DepartmentResponseDto } from '../../../api/models';
<div class="form-section">
<div class="section-header">
<h3>Approval Stages</h3>
<button mat-button type="button" color="primary" (click)="addStage()">
<button mat-button type="button" color="primary" (click)="addStage()" [disabled]="stagesArray.length >= maxStages">
<mat-icon>add</mat-icon>
Add Stage
Add Stage {{ stagesArray.length >= maxStages ? '(max reached)' : '' }}
</button>
</div>
@@ -101,7 +126,7 @@ import { DepartmentResponseDto } from '../../../api/models';
<div class="stage-form">
<mat-form-field appearance="outline">
<mat-label>Stage Name</mat-label>
<input matInput formControlName="name" />
<input matInput formControlName="name" [maxlength]="limits.NAME_MAX" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Department</mat-label>
@@ -228,6 +253,10 @@ export class WorkflowFormComponent implements OnInit {
private readonly workflowService = inject(WorkflowService);
private readonly departmentService = inject(DepartmentService);
private readonly notification = inject(NotificationService);
private readonly authService = inject(AuthService);
/** Debounce handler to prevent double-click submissions */
private readonly submitDebounce = createSubmitDebounce(500);
readonly loading = signal(false);
readonly submitting = signal(false);
@@ -235,10 +264,27 @@ export class WorkflowFormComponent implements OnInit {
readonly departments = signal<DepartmentResponseDto[]>([]);
private workflowId: string | null = null;
/** Input limits exposed for template binding */
readonly limits = INPUT_LIMITS;
/** Maximum number of approval stages allowed */
readonly maxStages = 20;
readonly form = this.fb.nonNullable.group({
name: ['', [Validators.required]],
description: [''],
requestType: ['NEW_LICENSE', [Validators.required]],
name: ['', [
Validators.required,
Validators.minLength(INPUT_LIMITS.NAME_MIN),
Validators.maxLength(INPUT_LIMITS.NAME_MAX),
noScriptValidator(),
noNullBytesValidator(),
notOnlyWhitespaceValidator(),
]],
description: ['', [
Validators.maxLength(INPUT_LIMITS.DESCRIPTION_MAX),
noScriptValidator(),
noNullBytesValidator(),
]],
workflowType: ['NEW_LICENSE', [Validators.required]],
stages: this.fb.array([this.createStageGroup()]),
});
@@ -272,7 +318,7 @@ export class WorkflowFormComponent implements OnInit {
this.form.patchValue({
name: workflow.name,
description: workflow.description || '',
requestType: workflow.requestType,
workflowType: workflow.workflowType,
});
this.stagesArray.clear();
@@ -300,7 +346,14 @@ export class WorkflowFormComponent implements OnInit {
private createStageGroup() {
return this.fb.group({
id: [''],
name: ['', Validators.required],
name: ['', [
Validators.required,
Validators.minLength(INPUT_LIMITS.NAME_MIN),
Validators.maxLength(INPUT_LIMITS.NAME_MAX),
noScriptValidator(),
noNullBytesValidator(),
notOnlyWhitespaceValidator(),
]],
departmentId: ['', Validators.required],
order: [1],
isRequired: [true],
@@ -308,6 +361,12 @@ export class WorkflowFormComponent implements OnInit {
}
addStage(): void {
// Prevent adding more than max stages
if (this.stagesArray.length >= this.maxStages) {
this.notification.warning(`Maximum ${this.maxStages} stages allowed`);
return;
}
const order = this.stagesArray.length + 1;
const group = this.createStageGroup();
group.patchValue({ order });
@@ -328,18 +387,41 @@ export class WorkflowFormComponent implements OnInit {
}
onSubmit(): void {
if (this.form.invalid) return;
// Debounce to prevent double-click submissions
this.submitDebounce(() => this.performSubmit());
}
private performSubmit(): void {
// Prevent submission if already in progress
if (this.submitting()) {
return;
}
if (this.form.invalid) {
this.form.markAllAsTouched();
// Also mark stage controls as touched
this.stagesArray.controls.forEach(control => {
control.markAllAsTouched();
});
return;
}
this.submitting.set(true);
const values = this.form.getRawValue();
const currentUser = this.authService.currentUser();
// Get departmentId from current user or first stage
const departmentId = currentUser?.departmentId ||
(values.stages[0]?.departmentId) || '';
const dto = {
name: values.name!,
description: values.description || undefined,
requestType: values.requestType!,
name: normalizeWhitespace(values.name),
description: normalizeWhitespace(values.description) || undefined,
workflowType: values.workflowType!,
departmentId: departmentId,
stages: values.stages.map((s, i) => ({
id: s.id || `stage-${i + 1}`,
name: s.name || `Stage ${i + 1}`,
name: normalizeWhitespace(s.name) || `Stage ${i + 1}`,
departmentId: s.departmentId || '',
isRequired: s.isRequired ?? true,
order: i + 1,
@@ -357,8 +439,9 @@ export class WorkflowFormComponent implements OnInit {
);
this.router.navigate(['/workflows', result.id]);
},
error: () => {
error: (err) => {
this.submitting.set(false);
this.notification.error(err?.error?.message || 'Failed to save workflow. Please try again.');
},
});
}

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { Component, OnInit, OnDestroy, inject, signal, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
@@ -72,9 +73,9 @@ import { WorkflowResponseDto } from '../../../api/models';
</td>
</ng-container>
<ng-container matColumnDef="requestType">
<th mat-header-cell *matHeaderCellDef>Request Type</th>
<td mat-cell *matCellDef="let row">{{ formatType(row.requestType) }}</td>
<ng-container matColumnDef="workflowType">
<th mat-header-cell *matHeaderCellDef>Workflow Type</th>
<td mat-cell *matCellDef="let row">{{ formatType(row.workflowType) }}</td>
</ng-container>
<ng-container matColumnDef="stages">
@@ -156,16 +157,18 @@ import { WorkflowResponseDto } from '../../../api/models';
`,
],
})
export class WorkflowListComponent implements OnInit {
export class WorkflowListComponent implements OnInit, OnDestroy {
private readonly workflowService = inject(WorkflowService);
private readonly destroyRef = inject(DestroyRef);
readonly loading = signal(true);
readonly hasError = signal(false);
readonly workflows = signal<WorkflowResponseDto[]>([]);
readonly totalItems = signal(0);
readonly pageSize = signal(10);
readonly pageIndex = signal(0);
readonly displayedColumns = ['name', 'requestType', 'stages', 'status', 'actions'];
readonly displayedColumns = ['name', 'workflowType', 'stages', 'status', 'actions'];
ngOnInit(): void {
this.loadWorkflows();
@@ -173,16 +176,30 @@ export class WorkflowListComponent implements OnInit {
loadWorkflows(): void {
this.loading.set(true);
this.workflowService.getWorkflows(this.pageIndex() + 1, this.pageSize()).subscribe({
next: (response) => {
this.workflows.set(response.data);
this.totalItems.set(response.total);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
this.hasError.set(false);
this.workflowService.getWorkflows(this.pageIndex() + 1, this.pageSize())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
this.workflows.set(response?.data ?? []);
this.totalItems.set(response?.total ?? 0);
this.loading.set(false);
},
error: () => {
this.hasError.set(true);
this.loading.set(false);
},
});
}
ngOnDestroy(): void {
// Cleanup handled by DestroyRef/takeUntilDestroyed
}
/** Retry loading data - clears error state */
retryLoad(): void {
this.loadWorkflows();
}
onPageChange(event: PageEvent): void {

View File

@@ -56,8 +56,8 @@ import { WorkflowResponseDto } from '../../../api/models';
<app-status-badge [status]="wf.isActive ? 'ACTIVE' : 'INACTIVE'" />
</div>
<div class="info-item">
<span class="label">Request Type</span>
<span class="value">{{ formatType(wf.requestType) }}</span>
<span class="label">Workflow Type</span>
<span class="value">{{ formatType(wf.workflowType) }}</span>
</div>
<div class="info-item">
<span class="label">Total Stages</span>
@@ -279,6 +279,10 @@ export class WorkflowPreviewComponent implements OnInit {
this.notification.success(wf.isActive ? 'Workflow deactivated' : 'Workflow activated');
this.loadWorkflow();
},
error: (err) => {
this.notification.error('Operation failed. Please try again.');
console.error('Error:', err);
},
});
}
@@ -302,6 +306,10 @@ export class WorkflowPreviewComponent implements OnInit {
this.notification.success('Workflow deleted');
this.router.navigate(['/workflows']);
},
error: (err) => {
this.notification.error('Operation failed. Please try again.');
console.error('Error:', err);
},
});
}
});