From e40fa779438cff0637a2802559452f03adb35100 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Wed, 20 May 2026 14:43:14 +0200 Subject: [PATCH 1/8] [STEP-2.11-001] Add security module type definitions Define PiiFieldType, PiiMaskingMode, CsrfConfig, and SecurityConfig as the foundational type contracts for the security module (CSRF protection, input sanitization, and PII masking). --- .../core/src/lib/security/security.types.ts | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 packages/core/src/lib/security/security.types.ts diff --git a/packages/core/src/lib/security/security.types.ts b/packages/core/src/lib/security/security.types.ts new file mode 100644 index 0000000..1515253 --- /dev/null +++ b/packages/core/src/lib/security/security.types.ts @@ -0,0 +1,102 @@ +// --------------------------------------------------------------------------- +// PII field types +// --------------------------------------------------------------------------- + +/** + * Types of personally identifiable information (PII) that can be masked. + * + * Each type has a corresponding masking function in `pii.utils.ts` + * and is supported by the `FfPiiMaskPipe`. + * + * @example + * ```html + * {{ nifValue | ffPiiMask:'nif' }} + * {{ cardNumber | ffPiiMask:'card' }} + * ``` + */ +export type PiiFieldType = 'nif' | 'card' | 'phone' | 'email' | 'iban'; + +// --------------------------------------------------------------------------- +// PII masking mode +// --------------------------------------------------------------------------- + +/** + * Controls how PII masking behaves in the application. + * + * - `'auto'` — PII is masked by default; user can toggle to reveal + * - `'manual'` — PII is shown by default; masking applied only when explicitly requested + * - `'disabled'` — No masking applied; all data shown as-is + */ +export type PiiMaskingMode = 'auto' | 'manual' | 'disabled'; + +// --------------------------------------------------------------------------- +// CSRF configuration +// --------------------------------------------------------------------------- + +/** + * Configuration for the CSRF interceptor. + * + * The interceptor reads a CSRF token from a browser cookie and injects + * it as a header on mutation requests (POST, PUT, PATCH, DELETE). + * + * @example + * ```typescript + * provideSecurity({ + * csrf: { + * enabled: true, + * cookieName: 'XSRF-TOKEN', + * headerName: 'X-XSRF-TOKEN', + * }, + * }) + * ``` + */ +export interface CsrfConfig { + /** + * Whether the CSRF interceptor is active. + * When `false`, the interceptor passes requests through unchanged. + * Default: `true`. + */ + readonly enabled?: boolean; + + /** + * Name of the cookie that holds the CSRF token. + * The interceptor reads this cookie from `document.cookie`. + * Default: `'XSRF-TOKEN'`. + */ + readonly cookieName?: string; + + /** + * Name of the HTTP header to set on mutation requests. + * Default: `'X-XSRF-TOKEN'`. + */ + readonly headerName?: string; +} + +// --------------------------------------------------------------------------- +// Module configuration +// --------------------------------------------------------------------------- + +/** + * Configuration for `provideSecurity()`. + * + * Controls CSRF protection and PII masking behavior. + * All fields are optional — sensible defaults are applied. + * + * @example + * ```typescript + * provideSecurity({ + * csrf: { enabled: true, cookieName: 'XSRF-TOKEN' }, + * piiMasking: 'auto', + * }) + * ``` + */ +export interface SecurityConfig { + /** CSRF interceptor configuration. Default: enabled with standard cookie/header names. */ + readonly csrf?: CsrfConfig; + + /** + * PII masking mode. + * Default: `'manual'` — masking only when explicitly applied via pipe or directive. + */ + readonly piiMasking?: PiiMaskingMode; +} From c6133c4c29599780b7d07769662e60958901a852 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Wed, 20 May 2026 14:56:37 +0200 Subject: [PATCH 2/8] [STEP-2.11-002] Add PII masking pure functions Implement 5 masking functions (maskNif, maskCard, maskPhone, maskEmail, maskIban) with 35 unit tests covering standard inputs, edge cases, and null/undefined handling. --- .../src/lib/security/pii/pii.utils.spec.ts | 189 ++++++++++++++++++ .../core/src/lib/security/pii/pii.utils.ts | 126 ++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 packages/core/src/lib/security/pii/pii.utils.spec.ts create mode 100644 packages/core/src/lib/security/pii/pii.utils.ts diff --git a/packages/core/src/lib/security/pii/pii.utils.spec.ts b/packages/core/src/lib/security/pii/pii.utils.spec.ts new file mode 100644 index 0000000..90767d1 --- /dev/null +++ b/packages/core/src/lib/security/pii/pii.utils.spec.ts @@ -0,0 +1,189 @@ +import { maskNif, maskCard, maskPhone, maskEmail, maskIban } from './pii.utils'; + +describe('PII masking utils', () => { + // ----------------------------------------------------------------------- + // maskNif + // ----------------------------------------------------------------------- + describe('maskNif', () => { + it('should mask a standard NIF keeping last 3 chars', () => { + // '12345678A' → 9 chars, 9-3=6 asterisks + '78A' + expect(maskNif('12345678A')).toBe('******78A'); + }); + + it('should mask a NIE keeping last 3 chars', () => { + // 'X1234567L' → 9 chars, 9-3=6 asterisks + '67L' + expect(maskNif('X1234567L')).toBe('******67L'); + }); + + it('should mask a CIF', () => { + expect(maskNif('B12345678')).toBe('******678'); + }); + + it('should return original if less than 4 chars', () => { + expect(maskNif('AB')).toBe('AB'); + }); + + it('should return empty string for empty input', () => { + expect(maskNif('')).toBe(''); + }); + + it('should handle null-like values gracefully', () => { + expect(maskNif(null as unknown as string)).toBe(''); + expect(maskNif(undefined as unknown as string)).toBe(''); + }); + }); + + // ----------------------------------------------------------------------- + // maskCard + // ----------------------------------------------------------------------- + describe('maskCard', () => { + it('should mask a 16-digit card number', () => { + expect(maskCard('4111111111111111')).toBe('**** **** **** 1111'); + }); + + it('should mask a card number with dashes', () => { + expect(maskCard('4111-1111-1111-1111')).toBe('**** **** **** 1111'); + }); + + it('should mask a card number with spaces', () => { + expect(maskCard('4111 1111 1111 1111')).toBe('**** **** **** 1111'); + }); + + it('should mask a 15-digit card (Amex)', () => { + expect(maskCard('378282246310005')).toBe('**** **** **** 0005'); + }); + + it('should return original if less than 4 digits', () => { + expect(maskCard('123')).toBe('123'); + }); + + it('should return empty string for empty input', () => { + expect(maskCard('')).toBe(''); + }); + + it('should handle null-like values gracefully', () => { + expect(maskCard(null as unknown as string)).toBe(''); + }); + }); + + // ----------------------------------------------------------------------- + // maskPhone + // ----------------------------------------------------------------------- + describe('maskPhone', () => { + it('should mask a Spanish phone with country code', () => { + // +34 prefix detected (greedy 1-3 digits), rest masked keeping last 3 + const result = maskPhone('+34612345678'); + expect(result).toContain('+34'); + expect(result.endsWith('678')).toBe(true); + expect(result).toContain('***'); + }); + + it('should mask a phone without country code', () => { + // 612345678 → 9 digits, last 3 = 678, masked = 6 → ** *** + expect(maskPhone('612345678')).toBe('*** *** 678'); + }); + + it('should mask a US phone with +1', () => { + // +1 + 2025551234 (10 digits) → last 3 = 234, masked = 7 → ** *** * + const result = maskPhone('+12025551234'); + expect(result).toContain('+1'); + expect(result.endsWith('234')).toBe(true); + }); + + it('should mask a UK phone with +44', () => { + // +44 + 2071234567 (10 digits) → last 3 = 567, masked = 7 → ** *** * + const result = maskPhone('+442071234567'); + expect(result).toContain('+44'); + expect(result.endsWith('567')).toBe(true); + }); + + it('should return original if less than 4 digits', () => { + expect(maskPhone('12')).toBe('12'); + }); + + it('should return empty string for empty input', () => { + expect(maskPhone('')).toBe(''); + }); + + it('should handle null-like values gracefully', () => { + expect(maskPhone(null as unknown as string)).toBe(''); + }); + }); + + // ----------------------------------------------------------------------- + // maskEmail + // ----------------------------------------------------------------------- + describe('maskEmail', () => { + it('should mask a standard email', () => { + expect(maskEmail('user@mail.com')).toBe('u***@mail.com'); + }); + + it('should mask a short local part', () => { + expect(maskEmail('ab@example.org')).toBe('a***@example.org'); + }); + + it('should mask a single-char local part', () => { + expect(maskEmail('a@test.com')).toBe('a***@test.com'); + }); + + it('should mask a long local part', () => { + expect(maskEmail('john.doe.smith@company.co.uk')).toBe('j***@company.co.uk'); + }); + + it('should return original if no @ sign', () => { + expect(maskEmail('invalid-email')).toBe('invalid-email'); + }); + + it('should return original if @ is first char', () => { + expect(maskEmail('@domain.com')).toBe('@domain.com'); + }); + + it('should return empty string for empty input', () => { + expect(maskEmail('')).toBe(''); + }); + + it('should handle null-like values gracefully', () => { + expect(maskEmail(null as unknown as string)).toBe(''); + }); + }); + + // ----------------------------------------------------------------------- + // maskIban + // ----------------------------------------------------------------------- + describe('maskIban', () => { + it('should mask a Spanish IBAN (24 chars, no spaces)', () => { + // ES9121000418450200051332 → 24 chars, middle = 16, 4 groups + expect(maskIban('ES9121000418450200051332')).toBe('ES91 **** **** **** **** 1332'); + }); + + it('should mask a Spanish IBAN (with spaces)', () => { + expect(maskIban('ES91 2100 0418 4502 0005 1332')).toBe('ES91 **** **** **** **** 1332'); + }); + + it('should mask a German IBAN (22 chars)', () => { + // DE89370400440532013000 → 22 chars, middle = 14, ceil(14/4) = 4 groups + const result = maskIban('DE89370400440532013000'); + expect(result.startsWith('DE89')).toBe(true); + expect(result.endsWith('3000')).toBe(true); + expect(result).toContain('****'); + }); + + it('should mask a UK IBAN (22 chars)', () => { + const result = maskIban('GB29NWBK60161331926819'); + expect(result.startsWith('GB29')).toBe(true); + expect(result.endsWith('6819')).toBe(true); + }); + + it('should return original if less than 8 chars', () => { + expect(maskIban('ES91210')).toBe('ES91210'); + }); + + it('should return empty string for empty input', () => { + expect(maskIban('')).toBe(''); + }); + + it('should handle null-like values gracefully', () => { + expect(maskIban(null as unknown as string)).toBe(''); + }); + }); +}); diff --git a/packages/core/src/lib/security/pii/pii.utils.ts b/packages/core/src/lib/security/pii/pii.utils.ts new file mode 100644 index 0000000..b5fd782 --- /dev/null +++ b/packages/core/src/lib/security/pii/pii.utils.ts @@ -0,0 +1,126 @@ +/** + * Masks a Spanish NIF/NIE/CIF. + * + * Keeps the last 3 characters visible. + * + * @example + * ```typescript + * maskNif('12345678A'); // '******78A' + * maskNif('X1234567L'); // '******67L' + * ``` + */ +export function maskNif(value: string): string { + if (!value || value.length < 4) return value ?? ''; + const visible = value.slice(-3); + const masked = '*'.repeat(value.length - 3); + return masked + visible; +} + +/** + * Masks a credit/debit card number. + * + * Shows only the last 4 digits in standard card format. + * + * @example + * ```typescript + * maskCard('4111111111111111'); // '**** **** **** 1111' + * maskCard('4111-1111-1111-1111'); // '**** **** **** 1111' + * ``` + */ +export function maskCard(value: string): string { + if (!value) return ''; + const digits = value.replace(/\D/g, ''); + if (digits.length < 4) return value; + const last4 = digits.slice(-4); + return `**** **** **** ${last4}`; +} + +/** + * Masks a phone number. + * + * Keeps the country code prefix and last 3 digits visible. + * + * @example + * ```typescript + * maskPhone('+34612345678'); // '+34 *** *** 678' + * maskPhone('612345678'); // '*** *** 678' + * ``` + */ +export function maskPhone(value: string): string { + if (!value) return ''; + const digits = value.replace(/\D/g, ''); + if (digits.length < 4) return value; + + // Detect country code prefix (starts with + in original value) + if (value.startsWith('+')) { + const prefixMatch = value.match(/^\+(\d{1,3})/); + if (prefixMatch) { + const prefix = `+${prefixMatch[1]}`; + const rest = digits.slice(prefixMatch[1].length); + if (rest.length < 3) return value; + const last3 = rest.slice(-3); + const maskedLen = rest.length - 3; + return `${prefix} ${formatMaskedGroups(maskedLen)} ${last3}`; + } + } + + // No country code + const last3 = digits.slice(-3); + const maskedLen = digits.length - 3; + return `${formatMaskedGroups(maskedLen)} ${last3}`; +} + +/** Formats masked characters into groups of 3 separated by spaces. */ +function formatMaskedGroups(count: number): string { + const full = Math.floor(count / 3); + const remainder = count % 3; + const groups: string[] = []; + for (let i = 0; i < full; i++) groups.push('***'); + if (remainder > 0) groups.push('*'.repeat(remainder)); + return groups.join(' '); +} + +/** + * Masks an email address. + * + * Shows the first character of the local part and the full domain. + * + * @example + * ```typescript + * maskEmail('user@mail.com'); // 'u***@mail.com' + * maskEmail('ab@example.org'); // 'a***@example.org' + * ``` + */ +export function maskEmail(value: string): string { + if (!value) return ''; + const atIndex = value.indexOf('@'); + if (atIndex < 1) return value; + const local = value.slice(0, atIndex); + const domain = value.slice(atIndex); + const firstChar = local[0]; + return `${firstChar}***${domain}`; +} + +/** + * Masks an IBAN (International Bank Account Number). + * + * Shows the country code + check digits (first 4) and last 4 characters. + * + * @example + * ```typescript + * maskIban('ES9121000418450200051332'); // 'ES91 **** **** **** 1332' + * maskIban('ES91 2100 0418 4502 0005 1332'); // 'ES91 **** **** **** 1332' + * ``` + */ +export function maskIban(value: string): string { + if (!value) return ''; + const clean = value.replace(/\s/g, ''); + if (clean.length < 8) return value; + const prefix = clean.slice(0, 4); + const last4 = clean.slice(-4); + const middleLen = clean.length - 8; + // Group middle chars in blocks of 4, each replaced with **** + const groups = Math.ceil(middleLen / 4); + const maskedGroups = Array(groups).fill('****').join(' '); + return `${prefix} ${maskedGroups} ${last4}`; +} From fb7199962bf13e7fd28f114e5c8358e40fed1036 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Wed, 20 May 2026 15:00:00 +0200 Subject: [PATCH 3/8] [STEP-2.11-003] Add FfPiiMaskPipe standalone pipe Standalone pipe mapping PiiFieldType to masking functions via Record lookup. Handles null/undefined/empty. 8 unit tests. --- .../lib/security/pii/pii-mask.pipe.spec.ts | 59 +++++++++++++++++++ .../src/lib/security/pii/pii-mask.pipe.ts | 30 ++++++++++ 2 files changed, 89 insertions(+) create mode 100644 packages/core/src/lib/security/pii/pii-mask.pipe.spec.ts create mode 100644 packages/core/src/lib/security/pii/pii-mask.pipe.ts diff --git a/packages/core/src/lib/security/pii/pii-mask.pipe.spec.ts b/packages/core/src/lib/security/pii/pii-mask.pipe.spec.ts new file mode 100644 index 0000000..0526d96 --- /dev/null +++ b/packages/core/src/lib/security/pii/pii-mask.pipe.spec.ts @@ -0,0 +1,59 @@ +import { FfPiiMaskPipe } from './pii-mask.pipe'; + +describe('FfPiiMaskPipe', () => { + let pipe: FfPiiMaskPipe; + + beforeEach(() => { + pipe = new FfPiiMaskPipe(); + }); + + // --------------------------------------------------------------------- + // NIF masking + // --------------------------------------------------------------------- + it('should mask a NIF', () => { + expect(pipe.transform('12345678A', 'nif')).toBe('******78A'); + }); + + // --------------------------------------------------------------------- + // Card masking + // --------------------------------------------------------------------- + it('should mask a card number', () => { + expect(pipe.transform('4111111111111111', 'card')).toBe('**** **** **** 1111'); + }); + + // --------------------------------------------------------------------- + // Phone masking + // --------------------------------------------------------------------- + it('should mask a phone number', () => { + expect(pipe.transform('612345678', 'phone')).toBe('*** *** 678'); + }); + + // --------------------------------------------------------------------- + // Email masking + // --------------------------------------------------------------------- + it('should mask an email', () => { + expect(pipe.transform('user@mail.com', 'email')).toBe('u***@mail.com'); + }); + + // --------------------------------------------------------------------- + // IBAN masking + // --------------------------------------------------------------------- + it('should mask an IBAN', () => { + expect(pipe.transform('ES9121000418450200051332', 'iban')).toBe('ES91 **** **** **** **** 1332'); + }); + + // --------------------------------------------------------------------- + // Null / undefined / empty handling + // --------------------------------------------------------------------- + it('should return empty string for null', () => { + expect(pipe.transform(null, 'nif')).toBe(''); + }); + + it('should return empty string for undefined', () => { + expect(pipe.transform(undefined, 'card')).toBe(''); + }); + + it('should return empty string for empty string', () => { + expect(pipe.transform('', 'email')).toBe(''); + }); +}); diff --git a/packages/core/src/lib/security/pii/pii-mask.pipe.ts b/packages/core/src/lib/security/pii/pii-mask.pipe.ts new file mode 100644 index 0000000..2707d00 --- /dev/null +++ b/packages/core/src/lib/security/pii/pii-mask.pipe.ts @@ -0,0 +1,30 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { PiiFieldType } from '../security.types'; +import { maskNif, maskCard, maskPhone, maskEmail, maskIban } from './pii.utils'; + +const MASK_FN: Record string> = { + nif: maskNif, + card: maskCard, + phone: maskPhone, + email: maskEmail, + iban: maskIban, +}; + +/** + * Masks a PII value according to its field type. + * + * @example + * ```html + * {{ nifValue | ffPiiMask:'nif' }} + * {{ cardNumber | ffPiiMask:'card' }} + * {{ userEmail | ffPiiMask:'email' }} + * ``` + */ +@Pipe({ name: 'ffPiiMask', standalone: true }) +export class FfPiiMaskPipe implements PipeTransform { + transform(value: string | null | undefined, type: PiiFieldType): string { + if (!value) return ''; + return MASK_FN[type](value); + } +} From 651159f10ac3cac65e523d6eccdaa00ab52a3287 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Wed, 20 May 2026 15:54:57 +0200 Subject: [PATCH 4/8] [STEP-2.11-004] Add FfPiiMaskDirective with toggle show/hide Attribute directive that displays masked PII and supports toggle between masked/unmasked states. Emits visibility events via ffPiiMaskToggled output. 11 unit tests. --- .../security/pii/pii-mask.directive.spec.ts | 105 ++++++++++++++++++ .../lib/security/pii/pii-mask.directive.ts | 74 ++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 packages/core/src/lib/security/pii/pii-mask.directive.spec.ts create mode 100644 packages/core/src/lib/security/pii/pii-mask.directive.ts diff --git a/packages/core/src/lib/security/pii/pii-mask.directive.spec.ts b/packages/core/src/lib/security/pii/pii-mask.directive.spec.ts new file mode 100644 index 0000000..21ec65d --- /dev/null +++ b/packages/core/src/lib/security/pii/pii-mask.directive.spec.ts @@ -0,0 +1,105 @@ +import { Component, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FfPiiMaskDirective } from './pii-mask.directive'; + +@Component({ + template: ` + + + `, + standalone: true, + imports: [FfPiiMaskDirective], +}) +class TestHostComponent { + @ViewChild(FfPiiMaskDirective) directive!: FfPiiMaskDirective; + type: 'nif' | 'card' | 'phone' | 'email' | 'iban' = 'nif'; + value = '12345678A'; + lastEvent: 'visible' | 'hidden' | null = null; +} + +describe('FfPiiMaskDirective', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + }); + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + }); + + it('should show masked value initially', () => { + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('span').textContent).toBe('******78A'); + }); + + it('should reveal real value after toggle', () => { + fixture.detectChanges(); + host.directive.toggle(); + expect(fixture.nativeElement.querySelector('span').textContent).toBe('12345678A'); + }); + + it('should re-mask after second toggle', () => { + fixture.detectChanges(); + host.directive.toggle(); // reveal + host.directive.toggle(); // mask again + expect(fixture.nativeElement.querySelector('span').textContent).toBe('******78A'); + }); + + it('should emit "visible" when revealing', () => { + fixture.detectChanges(); + host.directive.toggle(); + expect(host.lastEvent).toBe('visible'); + }); + + it('should emit "hidden" when re-masking', () => { + fixture.detectChanges(); + host.directive.toggle(); // visible + host.directive.toggle(); // hidden + expect(host.lastEvent).toBe('hidden'); + }); + + it('should report isMasked correctly', () => { + fixture.detectChanges(); + expect(host.directive.isMasked).toBe(true); + host.directive.toggle(); + expect(host.directive.isMasked).toBe(false); + }); + + it('should render masked value for a different NIF', () => { + host.value = 'X1234567L'; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('span').textContent).toBe('******67L'); + }); + + it('should render masked value for an IBAN', () => { + host.type = 'iban'; + host.value = 'ES9121000418450200051332'; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('span').textContent).toBe('ES91 **** **** **** **** 1332'); + }); + + it('should update when type changes', () => { + host.type = 'email'; + host.value = 'user@mail.com'; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('span').textContent).toBe('u***@mail.com'); + }); + + it('should mask a card number', () => { + host.type = 'card'; + host.value = '4111111111111111'; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('span').textContent).toBe('**** **** **** 1111'); + }); + + it('should handle empty value gracefully', () => { + host.value = ''; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('span').textContent).toBe(''); + }); +}); diff --git a/packages/core/src/lib/security/pii/pii-mask.directive.ts b/packages/core/src/lib/security/pii/pii-mask.directive.ts new file mode 100644 index 0000000..1e7f905 --- /dev/null +++ b/packages/core/src/lib/security/pii/pii-mask.directive.ts @@ -0,0 +1,74 @@ +import { + Directive, + ElementRef, + EventEmitter, + Input, + OnChanges, + Output, + Renderer2, + inject, +} from '@angular/core'; + +import { PiiFieldType } from '../security.types'; +import { maskNif, maskCard, maskPhone, maskEmail, maskIban } from './pii.utils'; + +const MASK_FN: Record string> = { + nif: maskNif, + card: maskCard, + phone: maskPhone, + email: maskEmail, + iban: maskIban, +}; + +/** + * Attribute directive that displays PII data in masked form + * and allows toggling to reveal the real value. + * + * @example + * ```html + * + * + * ``` + */ +@Directive({ selector: '[ffPiiMask]', standalone: true }) +export class FfPiiMaskDirective implements OnChanges { + private readonly el = inject(ElementRef); + private readonly renderer = inject(Renderer2); + + /** The PII field type used to select the masking function. */ + @Input({ required: true }) ffPiiMask!: PiiFieldType; + + /** The raw (unmasked) value. */ + @Input({ required: true }) ffPiiMaskValue!: string; + + /** Emits `'visible'` or `'hidden'` when the masked state changes. */ + @Output() ffPiiMaskToggled = new EventEmitter<'visible' | 'hidden'>(); + + private masked = true; + + ngOnChanges(): void { + this.render(); + } + + /** Toggles between masked and unmasked display. */ + toggle(): void { + this.masked = !this.masked; + this.render(); + this.ffPiiMaskToggled.emit(this.masked ? 'hidden' : 'visible'); + } + + /** Whether the value is currently masked. */ + get isMasked(): boolean { + return this.masked; + } + + private render(): void { + const text = this.masked + ? MASK_FN[this.ffPiiMask](this.ffPiiMaskValue ?? '') + : (this.ffPiiMaskValue ?? ''); + this.renderer.setProperty(this.el.nativeElement, 'textContent', text); + } +} From aa9d766525f687b9610ceb632a1c7e464cfe20d7 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Wed, 20 May 2026 15:57:59 +0200 Subject: [PATCH 5/8] [STEP-2.11-005] Add HTML sanitization pure functions Three sanitization utilities: sanitizeHtml (removes dangerous tags/attributes/URIs), escapeXss (HTML entity encoding), stripTags (all tags removed). 32 tests with real XSS vectors. --- .../sanitization/sanitize.utils.spec.ts | 161 ++++++++++++++++++ .../security/sanitization/sanitize.utils.ts | 86 ++++++++++ 2 files changed, 247 insertions(+) create mode 100644 packages/core/src/lib/security/sanitization/sanitize.utils.spec.ts create mode 100644 packages/core/src/lib/security/sanitization/sanitize.utils.ts diff --git a/packages/core/src/lib/security/sanitization/sanitize.utils.spec.ts b/packages/core/src/lib/security/sanitization/sanitize.utils.spec.ts new file mode 100644 index 0000000..0e90b34 --- /dev/null +++ b/packages/core/src/lib/security/sanitization/sanitize.utils.spec.ts @@ -0,0 +1,161 @@ +import { sanitizeHtml, escapeXss, stripTags } from './sanitize.utils'; + +describe('sanitize.utils', () => { + // ------------------------------------------------------------------- + // sanitizeHtml + // ------------------------------------------------------------------- + describe('sanitizeHtml', () => { + it('should preserve safe HTML tags', () => { + expect(sanitizeHtml('

Hello world

')) + .toBe('

Hello world

'); + }); + + it('should remove ')) + .toBe('

Hi

'); + }); + + it('should remove ')) + .toBe(''); + }); + + it('should remove ')) + .toBe(''); + }); + + it('should remove self-closing dangerous tags', () => { + expect(sanitizeHtml('')) + .toBe(''); + }); + + it('should remove tags', () => { + expect(sanitizeHtml('')) + .toBe(''); + }); + + it('should remove

Hi

')) + .toBe('

Hi

'); + }); + + it('should remove
tags and content', () => { + expect(sanitizeHtml('
')) + .toBe(''); + }); + + it('should remove event handler attributes', () => { + expect(sanitizeHtml('

Click

')) + .toBe('

Click

'); + }); + + it('should remove onerror attribute', () => { + expect(sanitizeHtml('')) + .toBe(''); + }); + + it('should remove onload attribute', () => { + expect(sanitizeHtml('Hi')) + .toBe('Hi'); + }); + + it('should remove javascript: URIs in href', () => { + const result = sanitizeHtml('Click'); + expect(result).not.toContain('javascript:'); + }); + + it('should remove data: URIs in src', () => { + const result = sanitizeHtml(''); + expect(result).not.toContain('data:'); + }); + + it('should handle multiple dangerous elements', () => { + const input = '

Safe

Also safe

'; + expect(sanitizeHtml(input)).toBe('

Safe

Also safe

'); + }); + + it('should return empty string for empty input', () => { + expect(sanitizeHtml('')).toBe(''); + }); + + it('should handle null gracefully', () => { + expect(sanitizeHtml(null as unknown as string)).toBe(''); + }); + + it('should handle undefined gracefully', () => { + expect(sanitizeHtml(undefined as unknown as string)).toBe(''); + }); + }); + + // ------------------------------------------------------------------- + // escapeXss + // ------------------------------------------------------------------- + describe('escapeXss', () => { + it('should escape < and >', () => { + expect(escapeXss('')) + .toBe('<script>alert("xss")</script>'); + }); + + it('should leave safe text unchanged', () => { + expect(escapeXss('Hello world 123')).toBe('Hello world 123'); + }); + + it('should return empty string for empty input', () => { + expect(escapeXss('')).toBe(''); + }); + + it('should handle null gracefully', () => { + expect(escapeXss(null as unknown as string)).toBe(''); + }); + }); + + // ------------------------------------------------------------------- + // stripTags + // ------------------------------------------------------------------- + describe('stripTags', () => { + it('should strip all HTML tags', () => { + expect(stripTags('

Hello world

')).toBe('Hello world'); + }); + + it('should handle self-closing tags', () => { + expect(stripTags('Line 1
Line 2')).toBe('Line 1Line 2'); + }); + + it('should strip nested tags', () => { + expect(stripTags('')).toBe('link'); + }); + + it('should handle tags with attributes', () => { + expect(stripTags('

Text

')).toBe('Text'); + }); + + it('should return plain text unchanged', () => { + expect(stripTags('No tags here')).toBe('No tags here'); + }); + + it('should return empty string for empty input', () => { + expect(stripTags('')).toBe(''); + }); + + it('should handle null gracefully', () => { + expect(stripTags(null as unknown as string)).toBe(''); + }); + }); +}); diff --git a/packages/core/src/lib/security/sanitization/sanitize.utils.ts b/packages/core/src/lib/security/sanitization/sanitize.utils.ts new file mode 100644 index 0000000..3bf6546 --- /dev/null +++ b/packages/core/src/lib/security/sanitization/sanitize.utils.ts @@ -0,0 +1,86 @@ +/** Tags considered dangerous — removed entirely by `sanitizeHtml`. */ +const DANGEROUS_TAGS = new Set([ + 'script', 'iframe', 'object', 'embed', 'applet', + 'form', 'input', 'textarea', 'select', 'button', + 'link', 'style', 'meta', 'base', +]); + +/** Regex matching any HTML event-handler attribute (onclick, onerror, etc.). */ +const EVENT_ATTR_RE = /\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi; + +/** Regex matching `javascript:` / `vbscript:` / `data:` URIs in href/src attributes. */ +const DANGEROUS_URI_RE = /\s+(href|src|action)\s*=\s*(?:"(?:javascript|vbscript|data):[^"]*"|'(?:javascript|vbscript|data):[^']*')/gi; + +/** + * Sanitises an HTML string by removing dangerous tags, event-handler + * attributes, and dangerous URI schemes. + * + * Safe tags (p, span, div, a, strong, em, etc.) are preserved. + * + * @example + * ```typescript + * sanitizeHtml('

Hello

'); + * // → '

Hello

' + * ``` + */ +export function sanitizeHtml(input: string): string { + if (!input) return input ?? ''; + + let result = input; + + // 1. Remove dangerous tags and their content + for (const tag of DANGEROUS_TAGS) { + const openClose = new RegExp(`<${tag}[^>]*>[\\s\\S]*?`, 'gi'); + const selfClose = new RegExp(`<${tag}[^>]*/?>`, 'gi'); + result = result.replace(openClose, ''); + result = result.replace(selfClose, ''); + } + + // 2. Remove event-handler attributes from remaining tags + result = result.replace(EVENT_ATTR_RE, ''); + + // 3. Remove dangerous URI schemes + result = result.replace(DANGEROUS_URI_RE, ''); + + return result; +} + +/** HTML entity map for escaping. */ +const ESCAPE_MAP: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +const ESCAPE_RE = /[&<>"']/g; + +/** + * Escapes HTML special characters to prevent XSS when + * inserting user content into HTML context. + * + * @example + * ```typescript + * escapeXss(''); + * // → '<script>alert(1)</script>' + * ``` + */ +export function escapeXss(input: string): string { + if (!input) return input ?? ''; + return input.replace(ESCAPE_RE, (char) => ESCAPE_MAP[char]); +} + +/** + * Strips ALL HTML tags from a string, returning plain text only. + * + * @example + * ```typescript + * stripTags('

Hello world

'); + * // → 'Hello world' + * ``` + */ +export function stripTags(input: string): string { + if (!input) return input ?? ''; + return input.replace(/<[^>]*>/g, ''); +} From 8f819f55d932481722fb7af4edd1c9e16aea1403 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Wed, 20 May 2026 16:09:10 +0200 Subject: [PATCH 6/8] [STEP-2.11-006] Add SecurityService and csrfInterceptor SecurityService manages CSRF tokens (cookie or manual) and module config via signal. csrfInterceptor adds CSRF header on mutation requests only, with pass-through for disabled config or missing token. 18 unit tests. --- .../security/csrf/csrf.interceptor.spec.ts | 143 ++++++++++++++++++ .../src/lib/security/csrf/csrf.interceptor.ts | 43 ++++++ .../src/lib/security/security.service.spec.ts | 81 ++++++++++ .../core/src/lib/security/security.service.ts | 81 ++++++++++ 4 files changed, 348 insertions(+) create mode 100644 packages/core/src/lib/security/csrf/csrf.interceptor.spec.ts create mode 100644 packages/core/src/lib/security/csrf/csrf.interceptor.ts create mode 100644 packages/core/src/lib/security/security.service.spec.ts create mode 100644 packages/core/src/lib/security/security.service.ts diff --git a/packages/core/src/lib/security/csrf/csrf.interceptor.spec.ts b/packages/core/src/lib/security/csrf/csrf.interceptor.spec.ts new file mode 100644 index 0000000..35747ad --- /dev/null +++ b/packages/core/src/lib/security/csrf/csrf.interceptor.spec.ts @@ -0,0 +1,143 @@ +import { TestBed } from '@angular/core/testing'; +import { + HttpClient, + provideHttpClient, + withInterceptors, + withNoXsrfProtection, +} from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { DOCUMENT } from '@angular/common'; +import { csrfInterceptor } from './csrf.interceptor'; +import { SecurityService, SECURITY_CONFIG } from '../security.service'; + +describe('csrfInterceptor', () => { + let http: HttpClient; + let httpMock: HttpTestingController; + let security: SecurityService; + + function setup(config?: object, cookieStr = '') { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + SecurityService, + { provide: DOCUMENT, useValue: { cookie: cookieStr } }, + ...(config ? [{ provide: SECURITY_CONFIG, useValue: config }] : []), + provideHttpClient( + withInterceptors([csrfInterceptor]), + withNoXsrfProtection(), + ), + provideHttpClientTesting(), + ], + }); + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + security = TestBed.inject(SecurityService); + } + + afterEach(() => { + httpMock.verify(); + }); + + // ----------------------------------------------------------------- + // Adds header on mutations + // ----------------------------------------------------------------- + it('should add CSRF header on POST', () => { + setup(undefined, 'XSRF-TOKEN=tok123'); + http.post('/api/data', {}).subscribe(); + + const req = httpMock.expectOne('/api/data'); + expect(req.request.headers.get('X-XSRF-TOKEN')).toBe('tok123'); + req.flush({}); + }); + + it('should add CSRF header on PUT', () => { + setup(undefined, 'XSRF-TOKEN=tok123'); + http.put('/api/data', {}).subscribe(); + + const req = httpMock.expectOne('/api/data'); + expect(req.request.headers.get('X-XSRF-TOKEN')).toBe('tok123'); + req.flush({}); + }); + + it('should add CSRF header on PATCH', () => { + setup(undefined, 'XSRF-TOKEN=tok123'); + http.patch('/api/data', {}).subscribe(); + + const req = httpMock.expectOne('/api/data'); + expect(req.request.headers.get('X-XSRF-TOKEN')).toBe('tok123'); + req.flush({}); + }); + + it('should add CSRF header on DELETE', () => { + setup(undefined, 'XSRF-TOKEN=tok123'); + http.delete('/api/data').subscribe(); + + const req = httpMock.expectOne('/api/data'); + expect(req.request.headers.get('X-XSRF-TOKEN')).toBe('tok123'); + req.flush({}); + }); + + // ----------------------------------------------------------------- + // Skips GET + // ----------------------------------------------------------------- + it('should NOT add header on GET', () => { + setup(undefined, 'XSRF-TOKEN=tok123'); + http.get('/api/data').subscribe(); + + const req = httpMock.expectOne('/api/data'); + expect(req.request.headers.has('X-XSRF-TOKEN')).toBe(false); + req.flush({}); + }); + + // ----------------------------------------------------------------- + // Pass-through when disabled + // ----------------------------------------------------------------- + it('should pass-through when CSRF is disabled', () => { + setup({ csrf: { enabled: false } }, 'XSRF-TOKEN=tok123'); + http.post('/api/data', {}).subscribe(); + + const req = httpMock.expectOne('/api/data'); + expect(req.request.headers.has('X-XSRF-TOKEN')).toBe(false); + req.flush({}); + }); + + // ----------------------------------------------------------------- + // Pass-through when no token + // ----------------------------------------------------------------- + it('should pass-through when no token available', () => { + setup(undefined, ''); + http.post('/api/data', {}).subscribe(); + + const req = httpMock.expectOne('/api/data'); + expect(req.request.headers.has('X-XSRF-TOKEN')).toBe(false); + req.flush({}); + }); + + // ----------------------------------------------------------------- + // Custom header name + // ----------------------------------------------------------------- + it('should use custom header name from config', () => { + setup({ csrf: { headerName: 'X-MY-CSRF' } }, 'XSRF-TOKEN=tok123'); + http.post('/api/data', {}).subscribe(); + + const req = httpMock.expectOne('/api/data'); + expect(req.request.headers.get('X-MY-CSRF')).toBe('tok123'); + req.flush({}); + }); + + // ----------------------------------------------------------------- + // Manual token + // ----------------------------------------------------------------- + it('should use manually set token over cookie', () => { + setup(undefined, 'XSRF-TOKEN=cookie-tok'); + security.setCsrfToken('manual-tok'); + http.post('/api/data', {}).subscribe(); + + const req = httpMock.expectOne('/api/data'); + expect(req.request.headers.get('X-XSRF-TOKEN')).toBe('manual-tok'); + req.flush({}); + }); +}); diff --git a/packages/core/src/lib/security/csrf/csrf.interceptor.ts b/packages/core/src/lib/security/csrf/csrf.interceptor.ts new file mode 100644 index 0000000..07fc552 --- /dev/null +++ b/packages/core/src/lib/security/csrf/csrf.interceptor.ts @@ -0,0 +1,43 @@ +import { inject } from '@angular/core'; +import { HttpInterceptorFn } from '@angular/common/http'; +import { SecurityService } from '../security.service'; + +/** HTTP methods that mutate state and require CSRF protection. */ +const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +/** + * Functional HTTP interceptor that attaches a CSRF token header + * on mutation requests (POST, PUT, PATCH, DELETE). + * + * Reads the token and header name from `SecurityService`. + * Pass-through if CSRF is disabled in config or no token is available. + * + * Registration: + * ```typescript + * provideHttpClient(withInterceptors([csrfInterceptor])) + * ``` + */ +export const csrfInterceptor: HttpInterceptorFn = (req, next) => { + const security = inject(SecurityService); + + if (!MUTATION_METHODS.has(req.method)) { + return next(req); + } + + const csrfConfig = security.config().csrf; + if (csrfConfig?.enabled === false) { + return next(req); + } + + const token = security.getCsrfToken(); + if (!token) { + return next(req); + } + + const headerName = csrfConfig?.headerName ?? 'X-XSRF-TOKEN'; + const cloned = req.clone({ + setHeaders: { [headerName]: token }, + }); + + return next(cloned); +}; diff --git a/packages/core/src/lib/security/security.service.spec.ts b/packages/core/src/lib/security/security.service.spec.ts new file mode 100644 index 0000000..7f985cc --- /dev/null +++ b/packages/core/src/lib/security/security.service.spec.ts @@ -0,0 +1,81 @@ +import { TestBed } from '@angular/core/testing'; +import { DOCUMENT } from '@angular/common'; +import { SecurityService, SECURITY_CONFIG } from './security.service'; + +describe('SecurityService', () => { + function setup(config?: object, cookieStr = '') { + TestBed.configureTestingModule({ + providers: [ + SecurityService, + { provide: DOCUMENT, useValue: { cookie: cookieStr } }, + ...(config ? [{ provide: SECURITY_CONFIG, useValue: config }] : []), + ], + }); + return TestBed.inject(SecurityService); + } + + // ----------------------------------------------------------------- + // Config resolution + // ----------------------------------------------------------------- + it('should apply defaults when no config provided', () => { + const svc = setup(); + expect(svc.config().csrf?.enabled).toBe(true); + expect(svc.config().csrf?.cookieName).toBe('XSRF-TOKEN'); + expect(svc.config().csrf?.headerName).toBe('X-XSRF-TOKEN'); + expect(svc.config().piiMasking).toBe('manual'); + }); + + it('should merge user config with defaults', () => { + const svc = setup({ csrf: { cookieName: 'MY-TOKEN' }, piiMasking: 'auto' }); + expect(svc.config().csrf?.cookieName).toBe('MY-TOKEN'); + expect(svc.config().csrf?.headerName).toBe('X-XSRF-TOKEN'); // default preserved + expect(svc.config().piiMasking).toBe('auto'); + }); + + it('should allow disabling CSRF', () => { + const svc = setup({ csrf: { enabled: false } }); + expect(svc.config().csrf?.enabled).toBe(false); + }); + + // ----------------------------------------------------------------- + // CSRF token — cookie + // ----------------------------------------------------------------- + it('should read CSRF token from cookie', () => { + const svc = setup(undefined, 'XSRF-TOKEN=abc123; other=xyz'); + expect(svc.getCsrfToken()).toBe('abc123'); + }); + + it('should return null when cookie not present', () => { + const svc = setup(undefined, 'other=xyz'); + expect(svc.getCsrfToken()).toBeNull(); + }); + + it('should read from custom cookie name', () => { + const svc = setup({ csrf: { cookieName: 'MY-CSRF' } }, 'MY-CSRF=custom123'); + expect(svc.getCsrfToken()).toBe('custom123'); + }); + + // ----------------------------------------------------------------- + // CSRF token — manual override + // ----------------------------------------------------------------- + it('should return manual token when set', () => { + const svc = setup(undefined, 'XSRF-TOKEN=cookie-val'); + svc.setCsrfToken('manual-val'); + expect(svc.getCsrfToken()).toBe('manual-val'); + }); + + it('should fall back to cookie after clearing manual token', () => { + const svc = setup(undefined, 'XSRF-TOKEN=cookie-val'); + svc.setCsrfToken('manual-val'); + svc.clearCsrfToken(); + expect(svc.getCsrfToken()).toBe('cookie-val'); + }); + + // ----------------------------------------------------------------- + // SSR safety + // ----------------------------------------------------------------- + it('should return null when document has no cookie', () => { + const svc = setup(undefined, ''); + expect(svc.getCsrfToken()).toBeNull(); + }); +}); diff --git a/packages/core/src/lib/security/security.service.ts b/packages/core/src/lib/security/security.service.ts new file mode 100644 index 0000000..44e9715 --- /dev/null +++ b/packages/core/src/lib/security/security.service.ts @@ -0,0 +1,81 @@ +import { Injectable, InjectionToken, inject, signal } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { SecurityConfig, CsrfConfig } from './security.types'; + +const DEFAULT_CSRF: Required = { + enabled: true, + cookieName: 'XSRF-TOKEN', + headerName: 'X-XSRF-TOKEN', +}; + +/** + * Injection token for the security module configuration. + * + * Provided by `provideSecurity()`. When absent, defaults apply. + */ +export const SECURITY_CONFIG = new InjectionToken( + 'SECURITY_CONFIG', +); + +/** + * Central service for security concerns: CSRF token management + * and module configuration. + * + * Usage: + * ```typescript + * const security = inject(SecurityService); + * const token = security.getCsrfToken(); + * ``` + */ +@Injectable() +export class SecurityService { + private readonly doc = inject(DOCUMENT, { optional: true }); + private readonly inputConfig = inject(SECURITY_CONFIG, { optional: true }); + private readonly _manualToken = signal(null); + + /** Resolved security configuration (defaults + overrides). */ + readonly config = signal({ + csrf: { + ...DEFAULT_CSRF, + ...this.inputConfig?.csrf, + }, + piiMasking: this.inputConfig?.piiMasking ?? 'manual', + }).asReadonly(); + + /** + * Returns the current CSRF token. + * + * Resolution order: + * 1. Manual token set via `setCsrfToken()` (SSR / testing) + * 2. Cookie value read from `document.cookie` + * 3. `null` if neither available + */ + getCsrfToken(): string | null { + const manual = this._manualToken(); + if (manual) return manual; + return this.readCookie(this.config().csrf?.cookieName ?? DEFAULT_CSRF.cookieName); + } + + /** + * Manually set the CSRF token. + * + * Useful in SSR environments or testing where cookies aren't available. + */ + setCsrfToken(token: string): void { + this._manualToken.set(token); + } + + /** Clear manually set token — falls back to cookie. */ + clearCsrfToken(): void { + this._manualToken.set(null); + } + + /** Read a cookie value by name from `document.cookie`. SSR-safe. */ + private readCookie(name: string): string | null { + if (!this.doc) return null; + const cookies = this.doc.cookie; + if (!cookies) return null; + const match = cookies.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; + } +} From ea921099557c8a8e02b40cee0110821cc1b94a56 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Wed, 20 May 2026 16:14:22 +0200 Subject: [PATCH 7/8] [STEP-2.11-007] Add provideSecurity factory, barrel exports, and re-export from core - Create provideSecurity(config?) with SECURITY_CONFIG token - Create security/index.ts barrel exporting full public API - Re-export security module from packages/core/src/index.ts - Build OK, 620/620 tests passing --- packages/core/src/index.ts | 1 + packages/core/src/lib/security/index.ts | 19 ++++++++++ .../core/src/lib/security/provide-security.ts | 35 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 packages/core/src/lib/security/index.ts create mode 100644 packages/core/src/lib/security/provide-security.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bfc9064..8eb002b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,3 +12,4 @@ export * from './lib/error-handling'; export * from './lib/feature-flags'; export * from './lib/storage'; export * from './lib/environment'; +export * from './lib/security'; diff --git a/packages/core/src/lib/security/index.ts b/packages/core/src/lib/security/index.ts new file mode 100644 index 0000000..646395c --- /dev/null +++ b/packages/core/src/lib/security/index.ts @@ -0,0 +1,19 @@ +// Types +export type { PiiFieldType, PiiMaskingMode, CsrfConfig, SecurityConfig } from './security.types'; + +// Provider factory +export { provideSecurity } from './provide-security'; + +// Service + token +export { SecurityService, SECURITY_CONFIG } from './security.service'; + +// CSRF interceptor +export { csrfInterceptor } from './csrf/csrf.interceptor'; + +// PII masking +export { maskNif, maskCard, maskPhone, maskEmail, maskIban } from './pii/pii.utils'; +export { FfPiiMaskPipe } from './pii/pii-mask.pipe'; +export { FfPiiMaskDirective } from './pii/pii-mask.directive'; + +// Sanitization +export { sanitizeHtml, escapeXss, stripTags } from './sanitization/sanitize.utils'; diff --git a/packages/core/src/lib/security/provide-security.ts b/packages/core/src/lib/security/provide-security.ts new file mode 100644 index 0000000..8ae85c7 --- /dev/null +++ b/packages/core/src/lib/security/provide-security.ts @@ -0,0 +1,35 @@ +import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core'; +import { SecurityConfig } from './security.types'; +import { SecurityService, SECURITY_CONFIG } from './security.service'; + +/** + * Configure the security module. + * + * Provides `SecurityService` and optionally sets `SecurityConfig` + * for CSRF protection and PII masking behavior. + * + * **Important:** This does NOT register `csrfInterceptor`. CSRF + * protection requires the interceptor to be added separately: + * + * ```ts + * import { csrfInterceptor, provideSecurity } from '@fireflyframework/core'; + * + * export const appConfig: ApplicationConfig = { + * providers: [ + * provideHttpClient(withInterceptors([csrfInterceptor])), + * provideSecurity({ csrf: { enabled: true } }), + * ], + * }; + * ``` + * + * @param config - Optional security configuration. Defaults apply if omitted. + * @returns EnvironmentProviders to register in the application config + */ +export function provideSecurity( + config?: SecurityConfig, +): EnvironmentProviders { + return makeEnvironmentProviders([ + SecurityService, + ...(config ? [{ provide: SECURITY_CONFIG, useValue: config }] : []), + ]); +} From 6163bd9a5d1ab652dff4fd3258a52769206d1ab1 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Wed, 20 May 2026 16:18:48 +0200 Subject: [PATCH 8/8] [STEP-2.11-008] Release @fireflyframework/core 0.12.0 Add security module: provideSecurity(), SecurityService, csrfInterceptor, PII masking (5 types), FfPiiMaskPipe, FfPiiMaskDirective, HTML sanitization. Build OK, 620/620 tests pass. --- packages/core/CHANGELOG.md | 12 ++++++++++++ packages/core/package.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index fd32c70..204b8ea 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.0] - 2026-05-20 + +### Added +- Security module: `provideSecurity(config?)` factory with `SECURITY_CONFIG` injection token +- `SecurityService` — signal-based CSRF config management, cookie/manual token read, SSR-safe +- `csrfInterceptor` — functional HTTP interceptor, adds CSRF header on mutation requests (POST/PUT/PATCH/DELETE) +- PII masking utilities: `maskNif()`, `maskCard()`, `maskPhone()`, `maskEmail()`, `maskIban()` +- `FfPiiMaskPipe` — standalone pipe for template-based PII masking (`{{ value | ffPiiMask:'nif' }}`) +- `FfPiiMaskDirective` — attribute directive with toggle show/hide and event emission +- HTML sanitization utilities: `sanitizeHtml()`, `escapeXss()`, `stripTags()` +- Types: `PiiFieldType`, `PiiMaskingMode`, `CsrfConfig`, `SecurityConfig` + ## [0.11.0] - 2026-05-20 ### Changed diff --git a/packages/core/package.json b/packages/core/package.json index fd0dcdd..d541bdd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@fireflyframework/core", - "version": "0.11.0", + "version": "0.12.0", "publishConfig": { "registry": "https://npm.pkg.github.com" },