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

@@ -9,6 +9,7 @@ export interface ConfirmDialogData {
confirmText?: string;
cancelText?: string;
confirmColor?: 'primary' | 'accent' | 'warn';
hideCancel?: boolean;
}
@Component({
@@ -18,12 +19,14 @@ export interface ConfirmDialogData {
template: `
<h2 mat-dialog-title>{{ data.title }}</h2>
<mat-dialog-content>
<p>{{ data.message }}</p>
<p [style.white-space]="'pre-wrap'">{{ data.message }}</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="onCancel()">
{{ data.cancelText || 'Cancel' }}
</button>
@if (!data.hideCancel) {
<button mat-button (click)="onCancel()">
{{ data.cancelText || 'Cancel' }}
</button>
}
<button mat-raised-button [color]="data.confirmColor || 'primary'" (click)="onConfirm()">
{{ data.confirmText || 'Confirm' }}
</button>
@@ -33,7 +36,8 @@ export interface ConfirmDialogData {
`
mat-dialog-content p {
margin: 0;
color: rgba(0, 0, 0, 0.54);
color: rgba(0, 0, 0, 0.7);
line-height: 1.6;
}
`,
],

View File

@@ -0,0 +1,156 @@
import { signal, WritableSignal } from '@angular/core';
import { Subject } from 'rxjs';
import { debounceTime, filter, take } from 'rxjs/operators';
/**
* Creates a debounced submit handler to prevent double-click submissions.
* Returns a function that will only trigger once within the debounce period.
*
* Usage:
* ```typescript
* private submitDebounce = createSubmitDebounce(300);
*
* onSubmit(): void {
* this.submitDebounce(() => {
* // actual submit logic
* });
* }
* ```
*/
export function createSubmitDebounce(debounceMs = 300): (callback: () => void) => void {
const subject = new Subject<() => void>();
let isProcessing = false;
subject.pipe(
filter(() => !isProcessing),
debounceTime(debounceMs)
).subscribe((callback) => {
isProcessing = true;
try {
callback();
} finally {
// Reset after a short delay to allow for proper state cleanup
setTimeout(() => {
isProcessing = false;
}, debounceMs);
}
});
return (callback: () => void) => {
subject.next(callback);
};
}
/**
* Creates a submitting signal with automatic reset capability.
* Useful for managing form submission state.
*/
export function createSubmittingState(): {
submitting: WritableSignal<boolean>;
startSubmit: () => void;
endSubmit: () => void;
withSubmit: <T>(promise: Promise<T>) => Promise<T>;
} {
const submitting = signal(false);
return {
submitting,
startSubmit: () => submitting.set(true),
endSubmit: () => submitting.set(false),
withSubmit: async <T>(promise: Promise<T>): Promise<T> => {
submitting.set(true);
try {
return await promise;
} finally {
submitting.set(false);
}
},
};
}
/**
* Tracks whether a form has been modified since loading.
* Useful for "unsaved changes" warnings.
*/
export function createDirtyTracker(): {
isDirty: WritableSignal<boolean>;
markDirty: () => void;
markClean: () => void;
} {
const isDirty = signal(false);
return {
isDirty,
markDirty: () => isDirty.set(true),
markClean: () => isDirty.set(false),
};
}
/**
* Rate limits function calls. Only the last call within the window will execute.
*/
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, delay);
};
}
/**
* Throttles function calls. Ensures function is called at most once per interval.
*/
export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
interval: number
): (...args: Parameters<T>) => void {
let lastTime = 0;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
const now = Date.now();
const remaining = interval - (now - lastTime);
if (remaining <= 0) {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
lastTime = now;
fn(...args);
} else if (timeoutId === null) {
timeoutId = setTimeout(() => {
lastTime = Date.now();
timeoutId = null;
fn(...args);
}, remaining);
}
};
}
/**
* Prevents the callback from being called more than once.
* Useful for one-time submit operations.
*/
export function once<T extends (...args: unknown[]) => unknown>(fn: T): T {
let called = false;
let result: ReturnType<T>;
return ((...args: Parameters<T>) => {
if (!called) {
called = true;
result = fn(...args) as ReturnType<T>;
}
return result;
}) as T;
}

View File

@@ -0,0 +1,208 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
/**
* Input length constants for form fields
*/
export const INPUT_LIMITS = {
// Standard text fields
NAME_MIN: 2,
NAME_MAX: 200,
CODE_MAX: 50,
// Description/textarea fields
DESCRIPTION_MAX: 2000,
ADDRESS_MAX: 500,
// Contact fields
EMAIL_MAX: 254, // RFC 5321
PHONE_MAX: 20,
// URL fields
URL_MAX: 2048,
// Identifiers
ID_MAX: 100,
// Large text areas
NOTES_MAX: 5000,
} as const;
/**
* Patterns for dangerous content detection
*/
const DANGEROUS_PATTERNS = {
SCRIPT_TAG: /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
EVENT_HANDLER: /\bon\w+\s*=/gi,
JAVASCRIPT_URI: /javascript:/gi,
DATA_URI: /data:[^,]*;base64/gi,
NULL_BYTE: /\x00/g,
HTML_ENTITIES: /&#x?[0-9a-f]+;?/gi,
} as const;
/**
* Validates that input does not contain script tags or event handlers
*/
export function noScriptValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value || typeof control.value !== 'string') {
return null;
}
const value = control.value;
if (DANGEROUS_PATTERNS.SCRIPT_TAG.test(value) ||
DANGEROUS_PATTERNS.EVENT_HANDLER.test(value) ||
DANGEROUS_PATTERNS.JAVASCRIPT_URI.test(value)) {
return { dangerousContent: true };
}
return null;
};
}
/**
* Validates that input does not contain null bytes
*/
export function noNullBytesValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value || typeof control.value !== 'string') {
return null;
}
if (DANGEROUS_PATTERNS.NULL_BYTE.test(control.value)) {
return { nullBytes: true };
}
return null;
};
}
/**
* Validates that input does not contain only whitespace
*/
export function notOnlyWhitespaceValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value || typeof control.value !== 'string') {
return null;
}
if (control.value.trim().length === 0 && control.value.length > 0) {
return { onlyWhitespace: true };
}
return null;
};
}
/**
* Phone number validator (international format)
*/
export function phoneValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value || typeof control.value !== 'string') {
return null;
}
// Allow: digits, spaces, hyphens, parentheses, plus sign
// Minimum 6 digits, maximum 15 (E.164 standard)
const cleaned = control.value.replace(/[\s\-\(\)]/g, '');
const phoneRegex = /^\+?[0-9]{6,15}$/;
if (!phoneRegex.test(cleaned)) {
return { invalidPhone: true };
}
return null;
};
}
/**
* Validates URL format more strictly
*/
export function strictUrlValidator(requireHttps = false): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value || typeof control.value !== 'string') {
return null;
}
const value = control.value.trim();
// Check length
if (value.length > INPUT_LIMITS.URL_MAX) {
return { urlTooLong: true };
}
// Check for dangerous content
if (DANGEROUS_PATTERNS.JAVASCRIPT_URI.test(value) ||
DANGEROUS_PATTERNS.DATA_URI.test(value)) {
return { dangerousUrl: true };
}
// Validate URL format
try {
const url = new URL(value);
if (requireHttps && url.protocol !== 'https:') {
return { httpsRequired: true };
}
if (!['http:', 'https:'].includes(url.protocol)) {
return { invalidProtocol: true };
}
return null;
} catch {
return { invalidUrl: true };
}
};
}
/**
* Composite validator that applies common security checks
*/
export function secureInputValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const noScript = noScriptValidator()(control);
const noNull = noNullBytesValidator()(control);
const notWhitespace = notOnlyWhitespaceValidator()(control);
const errors = { ...noScript, ...noNull, ...notWhitespace };
return Object.keys(errors).length > 0 ? errors : null;
};
}
/**
* Sanitizes user input by removing dangerous content
* Use this before displaying user-generated content
*/
export function sanitizeInput(value: string | null | undefined): string {
if (!value || typeof value !== 'string') {
return '';
}
let sanitized = value;
// Remove null bytes
sanitized = sanitized.replace(DANGEROUS_PATTERNS.NULL_BYTE, '');
// Encode HTML special characters
sanitized = sanitized
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
return sanitized;
}
/**
* Trims and normalizes whitespace in input
*/
export function normalizeWhitespace(value: string | null | undefined): string {
if (!value || typeof value !== 'string') {
return '';
}
return value.trim().replace(/\s+/g, ' ');
}

View File

@@ -0,0 +1,2 @@
export * from './form-validators';
export * from './form-utils';