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:
@@ -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;
|
||||
}
|
||||
`,
|
||||
],
|
||||
|
||||
156
frontend/src/app/shared/utils/form-utils.ts
Normal file
156
frontend/src/app/shared/utils/form-utils.ts
Normal 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;
|
||||
}
|
||||
208
frontend/src/app/shared/utils/form-validators.ts
Normal file
208
frontend/src/app/shared/utils/form-validators.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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, ' ');
|
||||
}
|
||||
2
frontend/src/app/shared/utils/index.ts
Normal file
2
frontend/src/app/shared/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './form-validators';
|
||||
export * from './form-utils';
|
||||
Reference in New Issue
Block a user