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 {
WebhookResponseDto,
CreateWebhookDto,
@@ -8,8 +8,160 @@ import {
WebhookTestResultDto,
WebhookLogEntryDto,
PaginatedWebhookLogsResponse,
WebhookEvent,
} from '../../../api/models';
// Valid webhook events from the model
const VALID_WEBHOOK_EVENTS: WebhookEvent[] = [
'APPROVAL_REQUIRED',
'DOCUMENT_UPDATED',
'REQUEST_APPROVED',
'REQUEST_REJECTED',
'CHANGES_REQUESTED',
'LICENSE_MINTED',
'LICENSE_REVOKED',
];
/**
* Validates URL format
*/
function validateUrl(url: string | undefined | null, fieldName = 'URL'): string {
if (!url || typeof url !== 'string') {
throw new Error(`${fieldName} is required`);
}
const trimmed = url.trim();
if (trimmed.length === 0) {
throw new Error(`${fieldName} cannot be empty`);
}
// Basic URL validation
try {
const parsedUrl = new URL(trimmed);
// Only allow http and https protocols
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
throw new Error(`${fieldName} must use HTTP or HTTPS protocol`);
}
return trimmed;
} catch (error) {
if (error instanceof Error && error.message.includes('protocol')) {
throw error;
}
throw new Error(`${fieldName} is not a valid URL`);
}
}
/**
* Validates webhook events array
*/
function validateEvents(events: WebhookEvent[] | string[] | undefined | null): WebhookEvent[] {
if (!events) {
return [];
}
if (!Array.isArray(events)) {
throw new Error('Events must be an array');
}
const validated = events.filter(
(event): event is WebhookEvent =>
typeof event === 'string' && VALID_WEBHOOK_EVENTS.includes(event as WebhookEvent)
);
return validated as WebhookEvent[];
}
/**
* Ensures array response is valid
*/
function ensureValidArray<T>(response: T[] | null | undefined): T[] {
return Array.isArray(response) ? response : [];
}
/**
* Ensures paginated response is valid
*/
function ensureValidPaginatedResponse(
response: PaginatedWebhookLogsResponse | null | undefined,
page: number,
limit: number
): PaginatedWebhookLogsResponse {
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 create webhook DTO
*/
function validateCreateWebhookDto(dto: CreateWebhookDto | null | undefined): CreateWebhookDto {
if (!dto) {
throw new Error('Webhook data is required');
}
const url = validateUrl(dto.url, 'Webhook URL');
const events = validateEvents(dto.events);
if (events.length === 0) {
throw new Error('At least one event must be specified');
}
return {
url,
events,
description: dto.description?.trim() || undefined,
};
}
/**
* Validates update webhook DTO
*/
function validateUpdateWebhookDto(dto: UpdateWebhookDto | null | undefined): UpdateWebhookDto {
if (!dto) {
throw new Error('Update data is required');
}
const sanitized: UpdateWebhookDto = {};
if (dto.url !== undefined) {
sanitized.url = validateUrl(dto.url, 'Webhook URL');
}
if (dto.events !== undefined) {
sanitized.events = validateEvents(dto.events);
}
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;
}
return sanitized;
}
@Injectable({
providedIn: 'root',
})
@@ -17,34 +169,161 @@ export class WebhookService {
private readonly api = inject(ApiService);
getWebhooks(): Observable<WebhookResponseDto[]> {
return this.api.get<WebhookResponseDto[]>('/webhooks');
return this.api.get<WebhookResponseDto[]>('/webhooks').pipe(
map((response) => ensureValidArray(response)),
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : 'Failed to fetch webhooks';
return throwError(() => new Error(message));
})
);
}
getWebhook(id: string): Observable<WebhookResponseDto> {
return this.api.get<WebhookResponseDto>(`/webhooks/${id}`);
try {
const validId = validateId(id, 'Webhook ID');
return this.api.get<WebhookResponseDto>(`/webhooks/${validId}`).pipe(
map((response) => {
if (!response) {
throw new Error('Webhook not found');
}
return {
...response,
events: Array.isArray(response.events) ? response.events : [],
};
}),
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : `Failed to fetch webhook: ${id}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
createWebhook(dto: CreateWebhookDto): Observable<WebhookResponseDto> {
return this.api.post<WebhookResponseDto>('/webhooks', dto);
try {
const sanitizedDto = validateCreateWebhookDto(dto);
return this.api.post<WebhookResponseDto>('/webhooks', sanitizedDto).pipe(
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : 'Failed to create webhook';
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
updateWebhook(id: string, dto: UpdateWebhookDto): Observable<WebhookResponseDto> {
return this.api.patch<WebhookResponseDto>(`/webhooks/${id}`, dto);
try {
const validId = validateId(id, 'Webhook ID');
const sanitizedDto = validateUpdateWebhookDto(dto);
return this.api.patch<WebhookResponseDto>(`/webhooks/${validId}`, sanitizedDto).pipe(
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : `Failed to update webhook: ${id}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
deleteWebhook(id: string): Observable<void> {
return this.api.delete<void>(`/webhooks/${id}`);
try {
const validId = validateId(id, 'Webhook ID');
return this.api.delete<void>(`/webhooks/${validId}`).pipe(
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : `Failed to delete webhook: ${id}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
testWebhook(id: string): Observable<WebhookTestResultDto> {
return this.api.post<WebhookTestResultDto>(`/webhooks/${id}/test`, {});
try {
const validId = validateId(id, 'Webhook ID');
return this.api.post<WebhookTestResultDto>(`/webhooks/${validId}/test`, {}).pipe(
map((response) => {
if (!response) {
return {
success: false,
statusCode: 0,
statusMessage: 'No response received',
responseTime: 0,
error: 'No response from server',
};
}
return {
success: typeof response.success === 'boolean' ? response.success : false,
statusCode: typeof response.statusCode === 'number' ? response.statusCode : 0,
statusMessage:
typeof response.statusMessage === 'string' ? response.statusMessage : 'Unknown',
responseTime: typeof response.responseTime === 'number' ? response.responseTime : 0,
error: response.error,
};
}),
catchError((error: unknown) => {
const message = error instanceof Error ? error.message : `Failed to test webhook: ${id}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
getWebhookLogs(id: string, page = 1, limit = 20): Observable<PaginatedWebhookLogsResponse> {
return this.api.get<PaginatedWebhookLogsResponse>(`/webhooks/${id}/logs`, { page, limit });
try {
const validId = validateId(id, 'Webhook ID');
const validated = validatePagination(page, limit);
return this.api
.get<PaginatedWebhookLogsResponse>(`/webhooks/${validId}/logs`, {
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 logs for webhook: ${id}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
toggleActive(id: string, isActive: boolean): Observable<WebhookResponseDto> {
return this.api.patch<WebhookResponseDto>(`/webhooks/${id}`, { isActive });
try {
const validId = validateId(id, 'Webhook ID');
if (typeof isActive !== 'boolean') {
return throwError(() => new Error('isActive must be a boolean value'));
}
return this.api.patch<WebhookResponseDto>(`/webhooks/${validId}`, { isActive }).pipe(
catchError((error: unknown) => {
const message =
error instanceof Error
? error.message
: `Failed to toggle active status for webhook: ${id}`;
return throwError(() => new Error(message));
})
);
} catch (error) {
return throwError(() => error);
}
}
}

View File

@@ -14,6 +14,14 @@ import { PageHeaderComponent } from '../../../shared/components/page-header/page
import { WebhookService } from '../services/webhook.service';
import { NotificationService } from '../../../core/services/notification.service';
import { WebhookEvent } from '../../../api/models';
import {
INPUT_LIMITS,
noScriptValidator,
noNullBytesValidator,
strictUrlValidator,
normalizeWhitespace,
} from '../../../shared/utils/form-validators';
import { createSubmitDebounce } from '../../../shared/utils/form-utils';
@Component({
selector: 'app-webhook-form',
@@ -58,12 +66,22 @@ import { WebhookEvent } from '../../../api/models';
matInput
formControlName="url"
placeholder="https://your-server.com/webhook"
[maxlength]="limits.URL_MAX"
/>
@if (form.controls.url.hasError('required')) {
<mat-error>URL is required</mat-error>
}
@if (form.controls.url.hasError('pattern')) {
<mat-error>Enter a valid HTTPS URL</mat-error>
@if (form.controls.url.hasError('httpsRequired')) {
<mat-error>HTTPS is required for webhook URLs</mat-error>
}
@if (form.controls.url.hasError('invalidUrl')) {
<mat-error>Enter a valid URL</mat-error>
}
@if (form.controls.url.hasError('dangerousUrl')) {
<mat-error>URL contains unsafe content</mat-error>
}
@if (form.controls.url.hasError('urlTooLong')) {
<mat-error>URL is too long (max {{ limits.URL_MAX }} characters)</mat-error>
}
<mat-hint>Must be a publicly accessible HTTPS endpoint</mat-hint>
</mat-form-field>
@@ -88,7 +106,15 @@ import { WebhookEvent } from '../../../api/models';
formControlName="description"
rows="2"
placeholder="What is this webhook used for?"
[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 class="form-actions">
@@ -148,11 +174,17 @@ export class WebhookFormComponent implements OnInit {
private readonly webhookService = inject(WebhookService);
private readonly notification = inject(NotificationService);
/** Debounce handler to prevent double-click submissions */
private readonly submitDebounce = createSubmitDebounce(500);
readonly loading = signal(false);
readonly submitting = signal(false);
readonly isEditMode = signal(false);
private webhookId: string | null = null;
/** Input limits exposed for template binding */
readonly limits = INPUT_LIMITS;
readonly eventOptions: { value: WebhookEvent; label: string }[] = [
{ value: 'APPROVAL_REQUIRED', label: 'Approval Required' },
{ value: 'DOCUMENT_UPDATED', label: 'Document Updated' },
@@ -164,9 +196,16 @@ export class WebhookFormComponent implements OnInit {
];
readonly form = this.fb.nonNullable.group({
url: ['', [Validators.required, Validators.pattern(/^https:\/\/.+/)]],
url: ['', [
Validators.required,
strictUrlValidator(true), // Require HTTPS for webhooks
]],
events: [[] as WebhookEvent[], [Validators.required]],
description: [''],
description: ['', [
Validators.maxLength(INPUT_LIMITS.DESCRIPTION_MAX),
noScriptValidator(),
noNullBytesValidator(),
]],
});
ngOnInit(): void {
@@ -198,10 +237,30 @@ export class WebhookFormComponent 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();
return;
}
this.submitting.set(true);
const values = this.form.getRawValue();
const rawValues = this.form.getRawValue();
// Normalize and sanitize values
const values = {
url: rawValues.url.trim(),
events: rawValues.events,
description: normalizeWhitespace(rawValues.description),
};
const action$ = this.isEditMode()
? this.webhookService.updateWebhook(this.webhookId!, values)
@@ -214,8 +273,9 @@ export class WebhookFormComponent implements OnInit {
);
this.router.navigate(['/webhooks']);
},
error: () => {
error: (err) => {
this.submitting.set(false);
this.notification.error(err?.error?.message || 'Failed to save webhook. 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';
@@ -161,12 +162,14 @@ import { WebhookResponseDto } from '../../../api/models';
`,
],
})
export class WebhookListComponent implements OnInit {
export class WebhookListComponent implements OnInit, OnDestroy {
private readonly webhookService = inject(WebhookService);
private readonly notification = inject(NotificationService);
private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
readonly loading = signal(true);
readonly hasError = signal(false);
readonly webhooks = signal<WebhookResponseDto[]>([]);
readonly displayedColumns = ['url', 'events', 'status', 'actions'];
@@ -177,34 +180,49 @@ export class WebhookListComponent implements OnInit {
loadWebhooks(): void {
this.loading.set(true);
this.webhookService.getWebhooks().subscribe({
next: (data) => {
this.webhooks.set(data);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
this.hasError.set(false);
this.webhookService.getWebhooks()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (data) => {
this.webhooks.set(data ?? []);
this.loading.set(false);
},
error: () => {
this.hasError.set(true);
this.loading.set(false);
},
});
}
formatEvent(event: string): string {
return event.replace(/_/g, ' ').toLowerCase();
return event?.replace(/_/g, ' ').toLowerCase() ?? '';
}
testWebhook(webhook: WebhookResponseDto): void {
this.webhookService.testWebhook(webhook.id).subscribe({
next: (result) => {
if (result.success) {
this.notification.success(`Webhook test successful (${result.statusCode})`);
} else {
this.notification.error(`Webhook test failed: ${result.error || result.statusMessage}`);
}
},
});
if (!webhook?.id) return;
this.webhookService.testWebhook(webhook.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (result) => {
if (result?.success) {
this.notification.success(`Webhook test successful (${result.statusCode})`);
} else {
this.notification.error(`Webhook test failed: ${result?.error || result?.statusMessage || 'Unknown error'}`);
}
},
error: (err) => {
this.notification.error('Operation failed. Please try again.');
console.error('Error:', err);
},
});
}
deleteWebhook(webhook: WebhookResponseDto): void {
if (!webhook?.id) return;
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Delete Webhook',
@@ -214,15 +232,32 @@ export class WebhookListComponent implements OnInit {
},
});
dialogRef.afterClosed().subscribe((confirmed) => {
if (confirmed) {
this.webhookService.deleteWebhook(webhook.id).subscribe({
next: () => {
this.notification.success('Webhook deleted');
this.loadWebhooks();
},
});
}
});
dialogRef.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((confirmed) => {
if (confirmed) {
this.webhookService.deleteWebhook(webhook.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.notification.success('Webhook deleted');
this.loadWebhooks();
},
error: (err) => {
this.notification.error('Operation failed. Please try again.');
console.error('Error:', err);
},
});
}
});
}
ngOnDestroy(): void {
// Cleanup handled by DestroyRef/takeUntilDestroyed
}
/** Retry loading data - clears error state */
retryLoad(): void {
this.loadWebhooks();
}
}

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 { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
@@ -128,12 +129,14 @@ import { WebhookLogEntryDto } from '../../../api/models';
`,
],
})
export class WebhookLogsComponent implements OnInit {
export class WebhookLogsComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly webhookService = inject(WebhookService);
private readonly destroyRef = inject(DestroyRef);
readonly loading = signal(true);
readonly hasError = signal(false);
readonly logs = signal<WebhookLogEntryDto[]>([]);
readonly totalItems = signal(0);
readonly pageSize = signal(20);
@@ -156,15 +159,19 @@ export class WebhookLogsComponent implements OnInit {
if (!this.webhookId) return;
this.loading.set(true);
this.hasError.set(false);
this.webhookService
.getWebhookLogs(this.webhookId, this.pageIndex() + 1, this.pageSize())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
this.logs.set(response.data);
this.totalItems.set(response.total);
this.logs.set(response?.data ?? []);
this.totalItems.set(response?.total ?? 0);
this.loading.set(false);
},
error: () => {
this.hasError.set(true);
this.loading.set(false);
},
});
@@ -177,10 +184,19 @@ export class WebhookLogsComponent implements OnInit {
}
formatEvent(event: string): string {
return event.replace(/_/g, ' ').toLowerCase();
return event?.replace(/_/g, ' ').toLowerCase() ?? '';
}
isSuccess(statusCode: number): boolean {
return statusCode >= 200 && statusCode < 300;
}
ngOnDestroy(): void {
// Cleanup handled by DestroyRef/takeUntilDestroyed
}
/** Retry loading data - clears error state */
retryLoad(): void {
this.loadLogs();
}
}