@angular-helpers/security 21.2.0 → 21.4.1
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 +195 -0
- package/README.md +291 -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 +1163 -181
- package/package.json +23 -3
- 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 +498 -10
package/README.es.md
CHANGED
|
@@ -49,6 +49,37 @@ Paquete de seguridad para aplicaciones Angular que previene ataques comunes como
|
|
|
49
49
|
- **Detección de Patrones**: Detectar patrones de teclado y secuencias
|
|
50
50
|
- **Feedback Accionable**: Sugerencias específicas para mejorar
|
|
51
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.
|
|
82
|
+
|
|
52
83
|
### **Patrón Builder**
|
|
53
84
|
|
|
54
85
|
- **API Fluida**: Construye expresiones regulares de forma intuitiva.
|
|
@@ -305,6 +336,170 @@ export class RegistrationComponent {
|
|
|
305
336
|
}
|
|
306
337
|
```
|
|
307
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
|
+
|
|
308
503
|
## 📊 Niveles de Riesgo
|
|
309
504
|
|
|
310
505
|
| Nivel | Descripción | Acción |
|
package/README.md
CHANGED
|
@@ -45,6 +45,50 @@ 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
|
+
|
|
79
|
+
### **Session Inactivity Monitor**
|
|
80
|
+
|
|
81
|
+
- **NgZone-optimized**: DOM events tracked outside Angular change detection.
|
|
82
|
+
- **Security interop**: Can automatically clear SecureStorage and SensitiveClipboard upon timeout.
|
|
83
|
+
- **Warning states**: Configurable thresholds to warn users before expiration.
|
|
84
|
+
|
|
85
|
+
### **Secure Cross-Window Messaging**
|
|
86
|
+
|
|
87
|
+
- **HMAC-SHA-256 signatures**: Every message is signed; tampered payloads are discarded.
|
|
88
|
+
- **Origin whitelist**: Messages from non-allowed origins are rejected before any crypto work.
|
|
89
|
+
- **Anti-replay protection**: Envelope includes `timestamp + nonce`; messages older than 30s are discarded.
|
|
90
|
+
- **SSR-safe**: No-op on the server — no `window` access.
|
|
91
|
+
|
|
48
92
|
### **Builder Pattern**
|
|
49
93
|
|
|
50
94
|
- **Fluent API**: Intuitively build regular expressions.
|
|
@@ -371,6 +415,253 @@ export class RegistrationComponent {
|
|
|
371
415
|
}
|
|
372
416
|
```
|
|
373
417
|
|
|
418
|
+
### **SecurityValidators (Reactive Forms)**
|
|
419
|
+
|
|
420
|
+
```typescript
|
|
421
|
+
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
|
422
|
+
import { SecurityValidators } from '@angular-helpers/security/forms';
|
|
423
|
+
|
|
424
|
+
export class SignupFormComponent {
|
|
425
|
+
form = new FormGroup({
|
|
426
|
+
password: new FormControl('', [
|
|
427
|
+
Validators.required,
|
|
428
|
+
SecurityValidators.strongPassword({ minScore: 3 }),
|
|
429
|
+
]),
|
|
430
|
+
bio: new FormControl('', [SecurityValidators.safeHtml()]),
|
|
431
|
+
homepage: new FormControl('', [SecurityValidators.safeUrl({ schemes: ['https:'] })]),
|
|
432
|
+
query: new FormControl('', [
|
|
433
|
+
SecurityValidators.noScriptInjection(),
|
|
434
|
+
SecurityValidators.noSqlInjectionHints(),
|
|
435
|
+
]),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
The validators are static factory functions — no provider registration required. They delegate to
|
|
441
|
+
shared pure helpers, so the Signal Forms variant below produces equivalent results for the same input.
|
|
442
|
+
|
|
443
|
+
### **Signal Forms validators**
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
446
|
+
import { signal } from '@angular/core';
|
|
447
|
+
import { form, required } from '@angular/forms/signals';
|
|
448
|
+
import {
|
|
449
|
+
strongPassword,
|
|
450
|
+
hibpPassword,
|
|
451
|
+
safeHtml,
|
|
452
|
+
safeUrl,
|
|
453
|
+
} from '@angular-helpers/security/signal-forms';
|
|
454
|
+
|
|
455
|
+
export class SignupSignalFormsComponent {
|
|
456
|
+
model = signal({ email: '', password: '', bio: '', homepage: '' });
|
|
457
|
+
|
|
458
|
+
f = form(this.model, (p) => {
|
|
459
|
+
required(p.email);
|
|
460
|
+
required(p.password);
|
|
461
|
+
strongPassword(p.password, { minScore: 3 });
|
|
462
|
+
hibpPassword(p.password); // async — calls HIBP via validateAsync
|
|
463
|
+
safeHtml(p.bio);
|
|
464
|
+
safeUrl(p.homepage, { schemes: ['https:'] });
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**Sub-entry requirement**: ensure `@angular/forms` is installed. The main entry has zero runtime
|
|
470
|
+
dependency on `@angular/forms`; only the sub-entries need it.
|
|
471
|
+
|
|
472
|
+
**Async HIBP rule**: `hibpPassword` requires `provideHibp()` in the injector hierarchy. The rule
|
|
473
|
+
fails open — network errors never block form submission.
|
|
474
|
+
|
|
475
|
+
### **JwtService**
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
import { JwtService } from '@angular-helpers/security';
|
|
479
|
+
|
|
480
|
+
export class SessionGuard {
|
|
481
|
+
private jwt = inject(JwtService);
|
|
482
|
+
|
|
483
|
+
isAuthenticated(): boolean {
|
|
484
|
+
const token = localStorage.getItem('access_token');
|
|
485
|
+
if (!token) return false;
|
|
486
|
+
return !this.jwt.isExpired(token, /* leewaySeconds */ 30);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
currentUserId(): string | null {
|
|
490
|
+
const token = localStorage.getItem('access_token');
|
|
491
|
+
return token ? this.jwt.claim<string>(token, 'sub') : null;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
> **Security note**: `JwtService` decodes payloads for client-side inspection only. **Never** trust
|
|
497
|
+
> the decoded contents for authorization decisions — signature verification must happen server-side.
|
|
498
|
+
|
|
499
|
+
### **CsrfService + `withCsrfHeader()`**
|
|
500
|
+
|
|
501
|
+
```typescript
|
|
502
|
+
import { bootstrapApplication } from '@angular/platform-browser';
|
|
503
|
+
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
504
|
+
import { provideSecurity, CsrfService, withCsrfHeader } from '@angular-helpers/security';
|
|
505
|
+
|
|
506
|
+
bootstrapApplication(App, {
|
|
507
|
+
providers: [
|
|
508
|
+
provideSecurity({ enableCsrf: true }),
|
|
509
|
+
provideHttpClient(withInterceptors([withCsrfHeader()])),
|
|
510
|
+
],
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// After login:
|
|
514
|
+
const csrf = inject(CsrfService);
|
|
515
|
+
csrf.storeToken(response.csrfToken);
|
|
516
|
+
// Subsequent POST/PUT/PATCH/DELETE requests automatically carry X-CSRF-Token.
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### **RateLimiterService**
|
|
520
|
+
|
|
521
|
+
```typescript
|
|
522
|
+
import { RateLimiterService, RateLimitExceededError } from '@angular-helpers/security';
|
|
523
|
+
|
|
524
|
+
export class SearchComponent {
|
|
525
|
+
private rateLimiter = inject(RateLimiterService);
|
|
526
|
+
|
|
527
|
+
constructor() {
|
|
528
|
+
this.rateLimiter.configure('search', {
|
|
529
|
+
type: 'token-bucket',
|
|
530
|
+
capacity: 5,
|
|
531
|
+
refillPerSecond: 1,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
canSearch = this.rateLimiter.canExecute('search'); // Signal<boolean>
|
|
536
|
+
remaining = this.rateLimiter.remaining('search'); // Signal<number>
|
|
537
|
+
|
|
538
|
+
async search(query: string) {
|
|
539
|
+
try {
|
|
540
|
+
await this.rateLimiter.consume('search');
|
|
541
|
+
return this.api.search(query);
|
|
542
|
+
} catch (err) {
|
|
543
|
+
if (err instanceof RateLimitExceededError) {
|
|
544
|
+
// Show countdown using err.retryAfterMs
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
### **HibpService**
|
|
552
|
+
|
|
553
|
+
```typescript
|
|
554
|
+
import { HibpService } from '@angular-helpers/security';
|
|
555
|
+
|
|
556
|
+
export class RegistrationComponent {
|
|
557
|
+
private hibp = inject(HibpService);
|
|
558
|
+
|
|
559
|
+
async checkPassword(password: string) {
|
|
560
|
+
const { leaked, count, error } = await this.hibp.isPasswordLeaked(password);
|
|
561
|
+
if (error) return; // fail-open on network failures
|
|
562
|
+
if (leaked) alert(`This password has appeared in ${count} data breaches.`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### **SensitiveClipboardService**
|
|
568
|
+
|
|
569
|
+
```typescript
|
|
570
|
+
import { SensitiveClipboardService } from '@angular-helpers/security';
|
|
571
|
+
|
|
572
|
+
export class ApiKeyPanel {
|
|
573
|
+
private sensitiveClipboard = inject(SensitiveClipboardService);
|
|
574
|
+
|
|
575
|
+
async copy(value: string) {
|
|
576
|
+
await this.sensitiveClipboard.copy(value, { clearAfterMs: 15_000 });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
The service reads the clipboard before clearing and skips the clear if the content no longer
|
|
582
|
+
matches what was copied — so third-party copies by the user are never overwritten.
|
|
583
|
+
|
|
584
|
+
### **SessionIdleService**
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
import { SessionIdleService } from '@angular-helpers/security';
|
|
588
|
+
|
|
589
|
+
export class AppComponent {
|
|
590
|
+
private sessionIdle = inject(SessionIdleService);
|
|
591
|
+
|
|
592
|
+
ngOnInit() {
|
|
593
|
+
this.sessionIdle.start({
|
|
594
|
+
timeoutMs: 15 * 60 * 1000, // 15 minutes
|
|
595
|
+
warningThresholdMs: 60 * 1000, // 1 minute warning
|
|
596
|
+
autoClearStorage: true,
|
|
597
|
+
autoClearClipboard: true,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// React to states
|
|
601
|
+
effect(() => {
|
|
602
|
+
if (this.sessionIdle.isWarning()) {
|
|
603
|
+
console.warn(`Session will expire in ${this.sessionIdle.timeRemaining()}ms`);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// React to timeout
|
|
608
|
+
this.sessionIdle.onTimeout.subscribe(() => {
|
|
609
|
+
this.authService.logout();
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
The service tracks DOM events (`mousemove`, `keydown`, etc.) outside the Angular Zone to prevent change detection spam. It can automatically clear `SecureStorageService` and `SensitiveClipboardService` when the session times out.
|
|
616
|
+
|
|
617
|
+
### **SecureMessageService**
|
|
618
|
+
|
|
619
|
+
```typescript
|
|
620
|
+
import { SecureMessageService, provideSecureMessage } from '@angular-helpers/security';
|
|
621
|
+
|
|
622
|
+
// app.config.ts
|
|
623
|
+
bootstrapApplication(AppComponent, {
|
|
624
|
+
providers: [provideSecureMessage()],
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// parent-app.component.ts
|
|
628
|
+
export class ParentComponent {
|
|
629
|
+
private channel = inject(SecureMessageService);
|
|
630
|
+
|
|
631
|
+
async ngOnInit() {
|
|
632
|
+
// 1. Generate a shared key (transport it to the iframe via a secure channel)
|
|
633
|
+
const key = await this.channel.generateChannelKey();
|
|
634
|
+
|
|
635
|
+
// 2. Configure: only accept messages from the iframe origin
|
|
636
|
+
this.channel.configure({
|
|
637
|
+
allowedOrigins: ['https://child-app.example.com'],
|
|
638
|
+
signingKey: key,
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// 3. React to incoming messages
|
|
642
|
+
this.channel.messages$<{ type: string; payload: unknown }>().subscribe(({ data, origin }) => {
|
|
643
|
+
console.log('Verified message from', origin, data);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// 4. Or use the Signal for reactive UI
|
|
647
|
+
effect(() => {
|
|
648
|
+
const msg = this.channel.lastMessage()();
|
|
649
|
+
if (msg) console.log('Last message:', msg.data);
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async sendToChild(iframe: HTMLIFrameElement) {
|
|
654
|
+
await this.channel.send(
|
|
655
|
+
iframe.contentWindow!,
|
|
656
|
+
{ type: 'INIT', payload: { userId: 42 } },
|
|
657
|
+
'https://child-app.example.com', // targetOrigin — never '*'
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
> **Key exchange**: `SecureMessageService` does not perform automatic key negotiation — transport the `CryptoKey` to the other context via a secure out-of-band channel (e.g., derive it from a shared secret using `WebCryptoService.generateHmacKey()` seeded by a passphrase). Once both sides share the key, all message integrity is handled automatically.
|
|
664
|
+
|
|
374
665
|
## 🔧 Advanced Configuration
|
|
375
666
|
|
|
376
667
|
### **Security Options**
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { assessPasswordStrength, isHtmlSafe, isUrlSafe, containsScriptInjection, containsSqlInjectionHints } from '@angular-helpers/security';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collection of Reactive Forms validators that bridge the shared security helpers into
|
|
5
|
+
* the Angular `ValidatorFn` contract. All validators are static factory functions and do not
|
|
6
|
+
* require provider registration.
|
|
7
|
+
*
|
|
8
|
+
* For the Signal Forms equivalents see `@angular-helpers/security/signal-forms`.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const form = new FormGroup({
|
|
12
|
+
* password: new FormControl('', [
|
|
13
|
+
* Validators.required,
|
|
14
|
+
* SecurityValidators.strongPassword({ minScore: 3 }),
|
|
15
|
+
* ]),
|
|
16
|
+
* bio: new FormControl('', [SecurityValidators.safeHtml()]),
|
|
17
|
+
* homepage: new FormControl('', [SecurityValidators.safeUrl({ schemes: ['https:'] })]),
|
|
18
|
+
* });
|
|
19
|
+
*/
|
|
20
|
+
class SecurityValidators {
|
|
21
|
+
/**
|
|
22
|
+
* Validates password strength using the shared entropy-based scoring logic.
|
|
23
|
+
* Returns `{ weakPassword: { score, required } }` when the score is below `minScore`.
|
|
24
|
+
*
|
|
25
|
+
* @param options.minScore Minimum acceptable score (0..4). Default: `2` (fair).
|
|
26
|
+
*/
|
|
27
|
+
static strongPassword(options = {}) {
|
|
28
|
+
const required = options.minScore ?? 2;
|
|
29
|
+
return (control) => {
|
|
30
|
+
const value = control.value;
|
|
31
|
+
if (value === null || value === undefined || value === '')
|
|
32
|
+
return null;
|
|
33
|
+
if (typeof value !== 'string')
|
|
34
|
+
return null;
|
|
35
|
+
const { score } = assessPasswordStrength(value);
|
|
36
|
+
return score < required ? { weakPassword: { score, required } } : null;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Validates that the given HTML string contains no tags or attributes outside the allowlist.
|
|
41
|
+
* Returns `{ unsafeHtml: true }` when sanitization would alter the value.
|
|
42
|
+
*
|
|
43
|
+
* Requires a browser environment (DOMParser). In SSR contexts the validator returns `null`
|
|
44
|
+
* (no error) to avoid blocking forms server-side; re-validation happens automatically on
|
|
45
|
+
* hydration.
|
|
46
|
+
*/
|
|
47
|
+
static safeHtml(options = {}) {
|
|
48
|
+
return (control) => {
|
|
49
|
+
const value = control.value;
|
|
50
|
+
if (value === null || value === undefined || value === '')
|
|
51
|
+
return null;
|
|
52
|
+
if (typeof value !== 'string')
|
|
53
|
+
return null;
|
|
54
|
+
if (typeof DOMParser === 'undefined')
|
|
55
|
+
return null;
|
|
56
|
+
return isHtmlSafe(value, options) ? null : { unsafeHtml: true };
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Validates that the given URL is well-formed and uses an allowed scheme.
|
|
61
|
+
* Returns `{ unsafeUrl: true }` for `javascript:`, `data:`, relative URLs, and other
|
|
62
|
+
* non-allowlisted protocols.
|
|
63
|
+
*/
|
|
64
|
+
static safeUrl(options = {}) {
|
|
65
|
+
const schemes = options.schemes ?? ['http:', 'https:'];
|
|
66
|
+
return (control) => {
|
|
67
|
+
const value = control.value;
|
|
68
|
+
if (value === null || value === undefined || value === '')
|
|
69
|
+
return null;
|
|
70
|
+
if (typeof value !== 'string')
|
|
71
|
+
return null;
|
|
72
|
+
return isUrlSafe(value, schemes) ? null : { unsafeUrl: true };
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Rejects values that look like script injection attempts (`<script>`, `javascript:`,
|
|
77
|
+
* or inline event handlers). Lightweight pattern check — NOT a substitute for a full
|
|
78
|
+
* HTML sanitizer.
|
|
79
|
+
*/
|
|
80
|
+
static noScriptInjection() {
|
|
81
|
+
return (control) => {
|
|
82
|
+
const value = control.value;
|
|
83
|
+
if (value === null || value === undefined || value === '')
|
|
84
|
+
return null;
|
|
85
|
+
if (typeof value !== 'string')
|
|
86
|
+
return null;
|
|
87
|
+
return containsScriptInjection(value) ? { scriptInjection: true } : null;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Heuristic check for common SQL-injection sentinel strings. Intended as defense-in-depth
|
|
92
|
+
* for user-facing inputs. Use parameterized queries on the server as the primary defense.
|
|
93
|
+
*/
|
|
94
|
+
static noSqlInjectionHints() {
|
|
95
|
+
return (control) => {
|
|
96
|
+
const value = control.value;
|
|
97
|
+
if (value === null || value === undefined || value === '')
|
|
98
|
+
return null;
|
|
99
|
+
if (typeof value !== 'string')
|
|
100
|
+
return null;
|
|
101
|
+
return containsSqlInjectionHints(value) ? { sqlInjectionHint: true } : null;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Generated bundle index. Do not edit.
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
export { SecurityValidators };
|