@angular-helpers/security 21.1.0 → 21.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.es.md +356 -1
- package/README.md +197 -0
- package/fesm2022/angular-helpers-security-forms.mjs +110 -0
- package/fesm2022/angular-helpers-security-signal-forms.mjs +159 -0
- package/fesm2022/angular-helpers-security.mjs +920 -180
- package/package.json +15 -1
- package/types/angular-helpers-security-forms.d.ts +65 -0
- package/types/angular-helpers-security-signal-forms.d.ts +99 -0
- package/types/angular-helpers-security.d.ts +414 -10
package/README.es.md
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
# Angular Security Helpers
|
|
6
6
|
|
|
7
|
-
Paquete de seguridad para aplicaciones Angular que previene ataques comunes como ReDoS (Regular Expression Denial of Service) usando Web Workers para ejecución segura.
|
|
7
|
+
Paquete de seguridad para aplicaciones Angular que previene ataques comunes como ReDoS (Regular Expression Denial of Service), XSS, y proporciona utilidades criptográficas usando Web Workers y Web Crypto API para ejecución segura.
|
|
8
|
+
|
|
9
|
+
> **Versión**: 21.2.0 — [CHANGELOG](./CHANGELOG.md)
|
|
10
|
+
|
|
11
|
+
---
|
|
8
12
|
|
|
9
13
|
## 🛡️ Características
|
|
10
14
|
|
|
@@ -18,9 +22,63 @@ Paquete de seguridad para aplicaciones Angular que previene ataques comunes como
|
|
|
18
22
|
### **Web Crypto API**
|
|
19
23
|
|
|
20
24
|
- **Cifrado/Descifrado**: Soporte AES-GCM para manejo seguro de datos
|
|
25
|
+
- **Firmas HMAC**: Firmar y verificar datos con HMAC-SHA256/384/512
|
|
21
26
|
- **Hashing**: SHA-256 y otros algoritmos
|
|
22
27
|
- **Gestión de Claves**: Generar, importar y exportar claves criptográficas
|
|
23
28
|
- **Aleatorio Seguro**: Valores aleatorios criptográficamente seguros
|
|
29
|
+
- **Generación UUID**: UUIDs RFC4122 v4
|
|
30
|
+
|
|
31
|
+
### **Almacenamiento Seguro**
|
|
32
|
+
|
|
33
|
+
- **Cifrado Transparente**: Almacenamiento cifrado AES-GCM sobre localStorage/sessionStorage
|
|
34
|
+
- **Modo Efímero**: Claves en memoria para seguridad de sesión única
|
|
35
|
+
- **Modo Passphrase**: Claves derivadas PBKDF2 para persistencia entre sesiones
|
|
36
|
+
- **Aislamiento por Namespace**: Organiza datos almacenados con prefijos
|
|
37
|
+
|
|
38
|
+
### **Sanitización de Input**
|
|
39
|
+
|
|
40
|
+
- **Prevención XSS**: Limpieza de HTML con lista de permitidos
|
|
41
|
+
- **Validación de URLs**: Esquemas de URL seguros
|
|
42
|
+
- **Escape de HTML**: Caracteres especiales para interpolación segura
|
|
43
|
+
- **JSON Seguro**: Parseo seguro sin eval
|
|
44
|
+
|
|
45
|
+
### **Fuerza de Contraseña**
|
|
46
|
+
|
|
47
|
+
- **Puntuación basada en Entropía**: Calcular fuerza en bits de entropía
|
|
48
|
+
- **Detección de Contraseñas Comunes**: Bloquear contraseñas débiles conocidas
|
|
49
|
+
- **Detección de Patrones**: Detectar patrones de teclado y secuencias
|
|
50
|
+
- **Feedback Accionable**: Sugerencias específicas para mejorar
|
|
51
|
+
|
|
52
|
+
### **Validators de Formularios (sub-entries)**
|
|
53
|
+
|
|
54
|
+
- **`@angular-helpers/security/forms`**: puente para Reactive Forms — `SecurityValidators.strongPassword`, `safeHtml`, `safeUrl`, `noScriptInjection`, `noSqlInjectionHints`.
|
|
55
|
+
- **`@angular-helpers/security/signal-forms`**: puente para Signal Forms de Angular v21 — `strongPassword`, `safeHtml`, `safeUrl`, `noScriptInjection`, `noSqlInjectionHints`, y el async `hibpPassword`.
|
|
56
|
+
- **Core compartido**: ambos paradigmas delegan en los mismos helpers puros para garantizar paridad de comportamiento.
|
|
57
|
+
|
|
58
|
+
### **Inspección de JWT**
|
|
59
|
+
|
|
60
|
+
- **Decodificación client-side**: `decode`, `claim`, `isExpired`, `expiresIn`.
|
|
61
|
+
- **Explícitamente NO verifica firma**: la validación criptográfica se hace server-side.
|
|
62
|
+
|
|
63
|
+
### **Protección CSRF**
|
|
64
|
+
|
|
65
|
+
- **`CsrfService`**: double-submit con tokens generados por `WebCryptoService.generateRandomBytes`.
|
|
66
|
+
- **`withCsrfHeader()`**: interceptor funcional que inyecta el header en POST/PUT/PATCH/DELETE.
|
|
67
|
+
|
|
68
|
+
### **Rate Limiter**
|
|
69
|
+
|
|
70
|
+
- **Token-bucket** y **sliding-window**, configurables por clave.
|
|
71
|
+
- **Estado basado en signals**: `canExecute(key)` y `remaining(key)` devuelven `Signal<T>`.
|
|
72
|
+
|
|
73
|
+
### **HIBP Leaked Password Check**
|
|
74
|
+
|
|
75
|
+
- **k-anonymity**: sólo los primeros 5 caracteres hex del SHA-1 salen del navegador.
|
|
76
|
+
- **Fail-open**: los errores de red nunca bloquean el envío del formulario.
|
|
77
|
+
|
|
78
|
+
### **Clipboard Sensible**
|
|
79
|
+
|
|
80
|
+
- **Auto-clear verificado**: lee el clipboard antes de limpiar para no pisar contenido ajeno.
|
|
81
|
+
- **Estilo password-manager**: default 15 segundos, configurable.
|
|
24
82
|
|
|
25
83
|
### **Patrón Builder**
|
|
26
84
|
|
|
@@ -44,7 +102,16 @@ import { provideSecurity } from '@angular-helpers/security';
|
|
|
44
102
|
bootstrapApplication(AppComponent, {
|
|
45
103
|
providers: [
|
|
46
104
|
provideSecurity({
|
|
105
|
+
// Servicios core (habilitados por defecto)
|
|
47
106
|
enableRegexSecurity: true,
|
|
107
|
+
enableWebCrypto: true,
|
|
108
|
+
|
|
109
|
+
// Nuevos servicios (opt-in, deshabilitados por defecto)
|
|
110
|
+
enableSecureStorage: true,
|
|
111
|
+
enableInputSanitizer: true,
|
|
112
|
+
enablePasswordStrength: true,
|
|
113
|
+
|
|
114
|
+
// Configuración global
|
|
48
115
|
defaultTimeout: 5000,
|
|
49
116
|
safeMode: false,
|
|
50
117
|
}),
|
|
@@ -52,6 +119,27 @@ bootstrapApplication(AppComponent, {
|
|
|
52
119
|
});
|
|
53
120
|
```
|
|
54
121
|
|
|
122
|
+
### **Providers Individuales**
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
import {
|
|
126
|
+
provideRegexSecurity,
|
|
127
|
+
provideWebCrypto,
|
|
128
|
+
provideSecureStorage,
|
|
129
|
+
provideInputSanitizer,
|
|
130
|
+
providePasswordStrength,
|
|
131
|
+
} from '@angular-helpers/security';
|
|
132
|
+
|
|
133
|
+
// Usar solo los servicios que necesites
|
|
134
|
+
bootstrapApplication(AppComponent, {
|
|
135
|
+
providers: [
|
|
136
|
+
provideSecureStorage({ storage: 'session', pbkdf2Iterations: 600_000 }),
|
|
137
|
+
provideInputSanitizer({ allowedTags: ['b', 'i', 'em', 'strong'] }),
|
|
138
|
+
providePasswordStrength(),
|
|
139
|
+
],
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
55
143
|
### **Inyección de Servicios**
|
|
56
144
|
|
|
57
145
|
```typescript
|
|
@@ -142,9 +230,276 @@ export class SecureStorageComponent {
|
|
|
142
230
|
generateUUID(): string {
|
|
143
231
|
return this.cryptoService.randomUUID();
|
|
144
232
|
}
|
|
233
|
+
|
|
234
|
+
async signAndVerify(data: string): Promise<boolean> {
|
|
235
|
+
// Generar clave HMAC para SHA-256
|
|
236
|
+
const key = await this.cryptoService.generateHmacKey('HMAC-SHA-256');
|
|
237
|
+
|
|
238
|
+
// Firmar los datos
|
|
239
|
+
const signature = await this.cryptoService.sign(key, data);
|
|
240
|
+
|
|
241
|
+
// Verificar la firma
|
|
242
|
+
return await this.cryptoService.verify(key, data, signature);
|
|
243
|
+
}
|
|
145
244
|
}
|
|
146
245
|
```
|
|
147
246
|
|
|
247
|
+
### **SecureStorageService — Almacenamiento Cifrado**
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
import { SecureStorageService } from '@angular-helpers/security';
|
|
251
|
+
|
|
252
|
+
export class UserSettingsComponent {
|
|
253
|
+
private storage = inject(SecureStorageService);
|
|
254
|
+
|
|
255
|
+
async saveUserToken(token: string): Promise<void> {
|
|
256
|
+
// Modo efímero (por defecto): datos sobreviven solo esta sesión
|
|
257
|
+
await this.storage.set('authToken', { token, createdAt: Date.now() });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async getUserToken(): Promise<{ token: string; createdAt: number } | null> {
|
|
261
|
+
return await this.storage.get<{ token: string; createdAt: number }>('authToken');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async initWithPassphrase(passphrase: string): Promise<void> {
|
|
265
|
+
// Modo passphrase: datos sobreviven recargas de página
|
|
266
|
+
await this.storage.initWithPassphrase(passphrase);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async saveWithNamespace(userId: string, data: unknown): Promise<void> {
|
|
270
|
+
// Aislamiento por namespace
|
|
271
|
+
await this.storage.set('profile', data, `user:${userId}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
clearUserData(userId: string): void {
|
|
275
|
+
// Limpiar solo los datos de este usuario
|
|
276
|
+
this.storage.clear(`user:${userId}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### **InputSanitizerService — Prevención XSS**
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
import { InputSanitizerService } from '@angular-helpers/security';
|
|
285
|
+
|
|
286
|
+
export class CommentComponent {
|
|
287
|
+
private sanitizer = inject(InputSanitizerService);
|
|
288
|
+
|
|
289
|
+
sanitizeUserComment(html: string): string {
|
|
290
|
+
// Limpiar tags peligrosos, mantener seguros (b, i, em, a, etc.)
|
|
291
|
+
return this.sanitizer.sanitizeHtml(html);
|
|
292
|
+
// Ejemplo: '<b>Hola</b><script>alert(1)</script>' → '<b>Hola</b>'
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
validateUserLink(url: string): string | null {
|
|
296
|
+
// Solo permitir URLs http/https
|
|
297
|
+
return this.sanitizer.sanitizeUrl(url);
|
|
298
|
+
// Ejemplo: 'javascript:alert(1)' → null
|
|
299
|
+
// Ejemplo: 'https://example.com' → 'https://example.com/'
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
escapeForDisplay(text: string): string {
|
|
303
|
+
// Seguro para nodos de texto HTML
|
|
304
|
+
return this.sanitizer.escapeHtml(text);
|
|
305
|
+
// Ejemplo: '<b>hola</b>' → '<b>hola</b>'
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
parseUserJson(json: string): unknown | null {
|
|
309
|
+
// Parseo seguro de JSON sin eval
|
|
310
|
+
return this.sanitizer.sanitizeJson(json);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### **PasswordStrengthService — Evaluación de Contraseñas**
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
import { PasswordStrengthService } from '@angular-helpers/security';
|
|
319
|
+
|
|
320
|
+
export class RegistrationComponent {
|
|
321
|
+
private passwordStrength = inject(PasswordStrengthService);
|
|
322
|
+
|
|
323
|
+
checkPasswordStrength(password: string): void {
|
|
324
|
+
const result = this.passwordStrength.assess(password);
|
|
325
|
+
|
|
326
|
+
console.log(`Score: ${result.score}/4`); // 0-4
|
|
327
|
+
console.log(`Label: ${result.label}`); // 'very-weak' a 'very-strong'
|
|
328
|
+
console.log(`Entropía: ${result.entropy} bits`); // entropía calculada
|
|
329
|
+
console.log('Feedback:', result.feedback); // sugerencias de mejora
|
|
330
|
+
|
|
331
|
+
// Ejemplos de resultados:
|
|
332
|
+
// 'password' → score: 0, label: 'very-weak', feedback: ['Contraseña común']
|
|
333
|
+
// 'P@ssw0rd!' → score: 2, label: 'fair', feedback: ['Evita patrones de teclado']
|
|
334
|
+
// 'xK#9mZ$vLq2@rBnT7' → score: 4, label: 'very-strong', feedback: []
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### **SecurityValidators — Reactive Forms**
|
|
340
|
+
|
|
341
|
+
```typescript
|
|
342
|
+
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
|
343
|
+
import { SecurityValidators } from '@angular-helpers/security/forms';
|
|
344
|
+
|
|
345
|
+
export class SignupFormComponent {
|
|
346
|
+
form = new FormGroup({
|
|
347
|
+
password: new FormControl('', [
|
|
348
|
+
Validators.required,
|
|
349
|
+
SecurityValidators.strongPassword({ minScore: 3 }),
|
|
350
|
+
]),
|
|
351
|
+
bio: new FormControl('', [SecurityValidators.safeHtml()]),
|
|
352
|
+
homepage: new FormControl('', [SecurityValidators.safeUrl({ schemes: ['https:'] })]),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Los validators son factory functions estáticas — no hace falta registrar providers. Delegan en los
|
|
358
|
+
mismos helpers puros que usa la versión Signal Forms, así que ambos paradigmas devuelven resultados
|
|
359
|
+
equivalentes para el mismo input.
|
|
360
|
+
|
|
361
|
+
### **Validators para Signal Forms**
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
import { signal } from '@angular/core';
|
|
365
|
+
import { form, required } from '@angular/forms/signals';
|
|
366
|
+
import {
|
|
367
|
+
strongPassword,
|
|
368
|
+
hibpPassword,
|
|
369
|
+
safeHtml,
|
|
370
|
+
safeUrl,
|
|
371
|
+
} from '@angular-helpers/security/signal-forms';
|
|
372
|
+
|
|
373
|
+
export class SignupSignalFormsComponent {
|
|
374
|
+
model = signal({ email: '', password: '', bio: '', homepage: '' });
|
|
375
|
+
|
|
376
|
+
f = form(this.model, (p) => {
|
|
377
|
+
required(p.email);
|
|
378
|
+
required(p.password);
|
|
379
|
+
strongPassword(p.password, { minScore: 3 });
|
|
380
|
+
hibpPassword(p.password); // async — valida contra HIBP
|
|
381
|
+
safeHtml(p.bio);
|
|
382
|
+
safeUrl(p.homepage, { schemes: ['https:'] });
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
**Requisito del sub-entry**: instalar `@angular/forms`. El entry principal
|
|
388
|
+
`@angular-helpers/security` no depende de `@angular/forms`.
|
|
389
|
+
|
|
390
|
+
**Regla HIBP async**: `hibpPassword` requiere `provideHibp()` en la jerarquía del inyector. Falla
|
|
391
|
+
en modo open — errores de red nunca bloquean el submit del formulario.
|
|
392
|
+
|
|
393
|
+
### **JwtService — Inspección Client-Side**
|
|
394
|
+
|
|
395
|
+
```typescript
|
|
396
|
+
import { JwtService } from '@angular-helpers/security';
|
|
397
|
+
|
|
398
|
+
export class SessionGuard {
|
|
399
|
+
private jwt = inject(JwtService);
|
|
400
|
+
|
|
401
|
+
isAuthenticated(): boolean {
|
|
402
|
+
const token = localStorage.getItem('access_token');
|
|
403
|
+
if (!token) return false;
|
|
404
|
+
return !this.jwt.isExpired(token, 30); // 30 segundos de leeway
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
currentUserId(): string | null {
|
|
408
|
+
const token = localStorage.getItem('access_token');
|
|
409
|
+
return token ? this.jwt.claim<string>(token, 'sub') : null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
> **Nota de seguridad**: `JwtService` sólo decodifica payloads para inspección. **Nunca** confíes
|
|
415
|
+
> en el contenido para decisiones de autorización — la verificación de la firma va siempre
|
|
416
|
+
> server-side.
|
|
417
|
+
|
|
418
|
+
### **CsrfService + `withCsrfHeader()`**
|
|
419
|
+
|
|
420
|
+
```typescript
|
|
421
|
+
import { bootstrapApplication } from '@angular/platform-browser';
|
|
422
|
+
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
423
|
+
import { provideSecurity, CsrfService, withCsrfHeader } from '@angular-helpers/security';
|
|
424
|
+
|
|
425
|
+
bootstrapApplication(App, {
|
|
426
|
+
providers: [
|
|
427
|
+
provideSecurity({ enableCsrf: true }),
|
|
428
|
+
provideHttpClient(withInterceptors([withCsrfHeader()])),
|
|
429
|
+
],
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Tras el login:
|
|
433
|
+
const csrf = inject(CsrfService);
|
|
434
|
+
csrf.storeToken(response.csrfToken);
|
|
435
|
+
// A partir de ahora, todas las requests POST/PUT/PATCH/DELETE llevan X-CSRF-Token.
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### **RateLimiterService**
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
import { RateLimiterService, RateLimitExceededError } from '@angular-helpers/security';
|
|
442
|
+
|
|
443
|
+
export class SearchComponent {
|
|
444
|
+
private rateLimiter = inject(RateLimiterService);
|
|
445
|
+
|
|
446
|
+
constructor() {
|
|
447
|
+
this.rateLimiter.configure('search', {
|
|
448
|
+
type: 'token-bucket',
|
|
449
|
+
capacity: 5,
|
|
450
|
+
refillPerSecond: 1,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
canSearch = this.rateLimiter.canExecute('search'); // Signal<boolean>
|
|
455
|
+
remaining = this.rateLimiter.remaining('search'); // Signal<number>
|
|
456
|
+
|
|
457
|
+
async search(query: string) {
|
|
458
|
+
try {
|
|
459
|
+
await this.rateLimiter.consume('search');
|
|
460
|
+
return this.api.search(query);
|
|
461
|
+
} catch (err) {
|
|
462
|
+
if (err instanceof RateLimitExceededError) {
|
|
463
|
+
// Mostrar countdown usando err.retryAfterMs
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### **HibpService**
|
|
471
|
+
|
|
472
|
+
```typescript
|
|
473
|
+
import { HibpService } from '@angular-helpers/security';
|
|
474
|
+
|
|
475
|
+
export class RegistrationComponent {
|
|
476
|
+
private hibp = inject(HibpService);
|
|
477
|
+
|
|
478
|
+
async checkPassword(password: string) {
|
|
479
|
+
const { leaked, count, error } = await this.hibp.isPasswordLeaked(password);
|
|
480
|
+
if (error) return; // fail-open en errores de red
|
|
481
|
+
if (leaked) alert(`Esta contraseña apareció en ${count} brechas.`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### **SensitiveClipboardService**
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
import { SensitiveClipboardService } from '@angular-helpers/security';
|
|
490
|
+
|
|
491
|
+
export class ApiKeyPanel {
|
|
492
|
+
private sensitiveClipboard = inject(SensitiveClipboardService);
|
|
493
|
+
|
|
494
|
+
async copy(value: string) {
|
|
495
|
+
await this.sensitiveClipboard.copy(value, { clearAfterMs: 15_000 });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
El servicio lee el clipboard antes de limpiarlo y omite el clear si el contenido ya no coincide con
|
|
501
|
+
lo que escribió — así evita pisar copias que el usuario haya hecho en otra parte.
|
|
502
|
+
|
|
148
503
|
## 📊 Niveles de Riesgo
|
|
149
504
|
|
|
150
505
|
| Nivel | Descripción | Acción |
|
package/README.md
CHANGED
|
@@ -45,6 +45,37 @@ Security package for Angular applications that prevents common attacks like ReDo
|
|
|
45
45
|
- **Common Password Check**: Blocks frequently used passwords
|
|
46
46
|
- **Feedback Messages**: Actionable improvement suggestions
|
|
47
47
|
|
|
48
|
+
### **Forms Validators (sub-entries)**
|
|
49
|
+
|
|
50
|
+
- **`@angular-helpers/security/forms`**: Reactive Forms bridge — `SecurityValidators.strongPassword`, `safeHtml`, `safeUrl`, `noScriptInjection`, `noSqlInjectionHints`.
|
|
51
|
+
- **`@angular-helpers/security/signal-forms`**: Angular v21 Signal Forms bridge — `strongPassword`, `safeHtml`, `safeUrl`, `noScriptInjection`, `noSqlInjectionHints`, and async `hibpPassword`.
|
|
52
|
+
- **Shared core**: both paradigms delegate to the same pure helpers for guaranteed behavioural parity.
|
|
53
|
+
|
|
54
|
+
### **JWT Inspection**
|
|
55
|
+
|
|
56
|
+
- **Client-side decode**: `decode`, `claim`, `isExpired`, `expiresIn`.
|
|
57
|
+
- **Explicit non-verifying**: signature validation must happen server-side.
|
|
58
|
+
|
|
59
|
+
### **CSRF Protection**
|
|
60
|
+
|
|
61
|
+
- **`CsrfService`**: double-submit token helper backed by `WebCryptoService.generateRandomBytes`.
|
|
62
|
+
- **`withCsrfHeader()`**: functional HTTP interceptor that injects the token on POST/PUT/PATCH/DELETE.
|
|
63
|
+
|
|
64
|
+
### **Rate Limiter**
|
|
65
|
+
|
|
66
|
+
- **Token-bucket** and **sliding-window** policies.
|
|
67
|
+
- **Signal-based state**: `canExecute(key)`, `remaining(key)` return `Signal<T>`.
|
|
68
|
+
|
|
69
|
+
### **HIBP Leaked-Password Check**
|
|
70
|
+
|
|
71
|
+
- **k-anonymity**: only the first 5 hex chars of SHA-1 leave the browser.
|
|
72
|
+
- **Fail-open**: network errors never block form submissions.
|
|
73
|
+
|
|
74
|
+
### **Sensitive Clipboard**
|
|
75
|
+
|
|
76
|
+
- **Verified auto-clear**: reads back the clipboard before clearing to avoid clobbering unrelated content.
|
|
77
|
+
- **Password-manager semantics**: default 15-second clear, configurable.
|
|
78
|
+
|
|
48
79
|
### **Builder Pattern**
|
|
49
80
|
|
|
50
81
|
- **Fluent API**: Intuitively build regular expressions.
|
|
@@ -371,6 +402,172 @@ export class RegistrationComponent {
|
|
|
371
402
|
}
|
|
372
403
|
```
|
|
373
404
|
|
|
405
|
+
### **SecurityValidators (Reactive Forms)**
|
|
406
|
+
|
|
407
|
+
```typescript
|
|
408
|
+
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
|
409
|
+
import { SecurityValidators } from '@angular-helpers/security/forms';
|
|
410
|
+
|
|
411
|
+
export class SignupFormComponent {
|
|
412
|
+
form = new FormGroup({
|
|
413
|
+
password: new FormControl('', [
|
|
414
|
+
Validators.required,
|
|
415
|
+
SecurityValidators.strongPassword({ minScore: 3 }),
|
|
416
|
+
]),
|
|
417
|
+
bio: new FormControl('', [SecurityValidators.safeHtml()]),
|
|
418
|
+
homepage: new FormControl('', [SecurityValidators.safeUrl({ schemes: ['https:'] })]),
|
|
419
|
+
query: new FormControl('', [
|
|
420
|
+
SecurityValidators.noScriptInjection(),
|
|
421
|
+
SecurityValidators.noSqlInjectionHints(),
|
|
422
|
+
]),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
The validators are static factory functions — no provider registration required. They delegate to
|
|
428
|
+
shared pure helpers, so the Signal Forms variant below produces equivalent results for the same input.
|
|
429
|
+
|
|
430
|
+
### **Signal Forms validators**
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
import { signal } from '@angular/core';
|
|
434
|
+
import { form, required } from '@angular/forms/signals';
|
|
435
|
+
import {
|
|
436
|
+
strongPassword,
|
|
437
|
+
hibpPassword,
|
|
438
|
+
safeHtml,
|
|
439
|
+
safeUrl,
|
|
440
|
+
} from '@angular-helpers/security/signal-forms';
|
|
441
|
+
|
|
442
|
+
export class SignupSignalFormsComponent {
|
|
443
|
+
model = signal({ email: '', password: '', bio: '', homepage: '' });
|
|
444
|
+
|
|
445
|
+
f = form(this.model, (p) => {
|
|
446
|
+
required(p.email);
|
|
447
|
+
required(p.password);
|
|
448
|
+
strongPassword(p.password, { minScore: 3 });
|
|
449
|
+
hibpPassword(p.password); // async — calls HIBP via validateAsync
|
|
450
|
+
safeHtml(p.bio);
|
|
451
|
+
safeUrl(p.homepage, { schemes: ['https:'] });
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
**Sub-entry requirement**: ensure `@angular/forms` is installed. The main entry has zero runtime
|
|
457
|
+
dependency on `@angular/forms`; only the sub-entries need it.
|
|
458
|
+
|
|
459
|
+
**Async HIBP rule**: `hibpPassword` requires `provideHibp()` in the injector hierarchy. The rule
|
|
460
|
+
fails open — network errors never block form submission.
|
|
461
|
+
|
|
462
|
+
### **JwtService**
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
import { JwtService } from '@angular-helpers/security';
|
|
466
|
+
|
|
467
|
+
export class SessionGuard {
|
|
468
|
+
private jwt = inject(JwtService);
|
|
469
|
+
|
|
470
|
+
isAuthenticated(): boolean {
|
|
471
|
+
const token = localStorage.getItem('access_token');
|
|
472
|
+
if (!token) return false;
|
|
473
|
+
return !this.jwt.isExpired(token, /* leewaySeconds */ 30);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
currentUserId(): string | null {
|
|
477
|
+
const token = localStorage.getItem('access_token');
|
|
478
|
+
return token ? this.jwt.claim<string>(token, 'sub') : null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
> **Security note**: `JwtService` decodes payloads for client-side inspection only. **Never** trust
|
|
484
|
+
> the decoded contents for authorization decisions — signature verification must happen server-side.
|
|
485
|
+
|
|
486
|
+
### **CsrfService + `withCsrfHeader()`**
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
import { bootstrapApplication } from '@angular/platform-browser';
|
|
490
|
+
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
491
|
+
import { provideSecurity, CsrfService, withCsrfHeader } from '@angular-helpers/security';
|
|
492
|
+
|
|
493
|
+
bootstrapApplication(App, {
|
|
494
|
+
providers: [
|
|
495
|
+
provideSecurity({ enableCsrf: true }),
|
|
496
|
+
provideHttpClient(withInterceptors([withCsrfHeader()])),
|
|
497
|
+
],
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// After login:
|
|
501
|
+
const csrf = inject(CsrfService);
|
|
502
|
+
csrf.storeToken(response.csrfToken);
|
|
503
|
+
// Subsequent POST/PUT/PATCH/DELETE requests automatically carry X-CSRF-Token.
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### **RateLimiterService**
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
import { RateLimiterService, RateLimitExceededError } from '@angular-helpers/security';
|
|
510
|
+
|
|
511
|
+
export class SearchComponent {
|
|
512
|
+
private rateLimiter = inject(RateLimiterService);
|
|
513
|
+
|
|
514
|
+
constructor() {
|
|
515
|
+
this.rateLimiter.configure('search', {
|
|
516
|
+
type: 'token-bucket',
|
|
517
|
+
capacity: 5,
|
|
518
|
+
refillPerSecond: 1,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
canSearch = this.rateLimiter.canExecute('search'); // Signal<boolean>
|
|
523
|
+
remaining = this.rateLimiter.remaining('search'); // Signal<number>
|
|
524
|
+
|
|
525
|
+
async search(query: string) {
|
|
526
|
+
try {
|
|
527
|
+
await this.rateLimiter.consume('search');
|
|
528
|
+
return this.api.search(query);
|
|
529
|
+
} catch (err) {
|
|
530
|
+
if (err instanceof RateLimitExceededError) {
|
|
531
|
+
// Show countdown using err.retryAfterMs
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
### **HibpService**
|
|
539
|
+
|
|
540
|
+
```typescript
|
|
541
|
+
import { HibpService } from '@angular-helpers/security';
|
|
542
|
+
|
|
543
|
+
export class RegistrationComponent {
|
|
544
|
+
private hibp = inject(HibpService);
|
|
545
|
+
|
|
546
|
+
async checkPassword(password: string) {
|
|
547
|
+
const { leaked, count, error } = await this.hibp.isPasswordLeaked(password);
|
|
548
|
+
if (error) return; // fail-open on network failures
|
|
549
|
+
if (leaked) alert(`This password has appeared in ${count} data breaches.`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
### **SensitiveClipboardService**
|
|
555
|
+
|
|
556
|
+
```typescript
|
|
557
|
+
import { SensitiveClipboardService } from '@angular-helpers/security';
|
|
558
|
+
|
|
559
|
+
export class ApiKeyPanel {
|
|
560
|
+
private sensitiveClipboard = inject(SensitiveClipboardService);
|
|
561
|
+
|
|
562
|
+
async copy(value: string) {
|
|
563
|
+
await this.sensitiveClipboard.copy(value, { clearAfterMs: 15_000 });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
The service reads the clipboard before clearing and skips the clear if the content no longer
|
|
569
|
+
matches what was copied — so third-party copies by the user are never overwritten.
|
|
570
|
+
|
|
374
571
|
## 🔧 Advanced Configuration
|
|
375
572
|
|
|
376
573
|
### **Security Options**
|