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

@@ -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.');
},
});
}