Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fireflyframework/core",
"version": "0.11.0",
"version": "0.12.0",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
143 changes: 143 additions & 0 deletions packages/core/src/lib/security/csrf/csrf.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
43 changes: 43 additions & 0 deletions packages/core/src/lib/security/csrf/csrf.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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);
};
19 changes: 19 additions & 0 deletions packages/core/src/lib/security/index.ts
Original file line number Diff line number Diff line change
@@ -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';
105 changes: 105 additions & 0 deletions packages/core/src/lib/security/pii/pii-mask.directive.spec.ts
Original file line number Diff line number Diff line change
@@ -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: `
<span
[ffPiiMask]="type"
[ffPiiMaskValue]="value"
(ffPiiMaskToggled)="lastEvent = $event">
</span>
`,
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<TestHostComponent>;
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('');
});
});
Loading