diff --git a/src/app.module.ts b/src/app.module.ts index e762e5f825..bb0dad5ba0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { GetConfig } from './config/config'; import { IntegrationModule } from './integration/integration.module'; +import { SignatureVisibilityInterceptor } from './shared/auth/signature-visibility.interceptor'; import { TfaEnforcementInterceptor } from './shared/auth/tfa-enforcement.interceptor'; import { SharedModule } from './shared/shared.module'; import { SubdomainsModule } from './subdomains/subdomains.module'; @@ -11,9 +12,13 @@ import { SubdomainsModule } from './subdomains/subdomains.module'; @Module({ imports: [TypeOrmModule.forRoot(GetConfig().database), SharedModule, IntegrationModule, SubdomainsModule], controllers: [AppController], - // Global backstop enforcing STRICT app-2FA on every route for a mail-origin staff session (tfaRequired). - // Only needs ModuleRef + Reflector (both globally available), so no extra module imports are required. - providers: [{ provide: APP_INTERCEPTOR, useClass: TfaEnforcementInterceptor }], + providers: [ + // Global backstop enforcing STRICT app-2FA on every route for a mail-origin staff session (tfaRequired). + // Only needs ModuleRef + Reflector (both globally available), so no extra module imports are required. + { provide: APP_INTERCEPTOR, useClass: TfaEnforcementInterceptor }, + // Strips User.signature from every HTTP response whose caller is not ADMIN (or SUPER_ADMIN). + { provide: APP_INTERCEPTOR, useClass: SignatureVisibilityInterceptor }, + ], exports: [], }) export class AppModule {} diff --git a/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts b/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts new file mode 100644 index 0000000000..edb09ae9e4 --- /dev/null +++ b/src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts @@ -0,0 +1,123 @@ +import { createMock } from '@golevelup/ts-jest'; +import { CallHandler, ExecutionContext } from '@nestjs/common'; +import { MODULE_METADATA } from '@nestjs/common/constants'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { firstValueFrom, Observable, of } from 'rxjs'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { SignatureVisibilityInterceptor } from '../signature-visibility.interceptor'; + +// Self-referencing shape for the cycle case - the interceptor must terminate on it. +interface Cyclic { + ref?: Cyclic; +} + +describe('SignatureVisibilityInterceptor', () => { + let interceptor: SignatureVisibilityInterceptor; + let next: CallHandler; + let handle: jest.Mock; + + const context = (request: unknown): ExecutionContext => + createMock({ switchToHttp: () => ({ getRequest: () => request }) as never }); + + // The interceptor is typed against `unknown` payloads, so each case states the shape it put in. + const run = async (request: unknown): Promise => + firstValueFrom((await interceptor.intercept(context(request), next)) as Observable); + + beforeEach(() => { + handle = jest.fn(); + next = { handle } as CallHandler; + interceptor = new SignatureVisibilityInterceptor(); + }); + + it('passes a User entity through unchanged when the caller role is ADMIN (fast path)', async () => { + const user = Object.assign(new User(), { id: 1, signature: 'secret-sig', address: 'addr-1' }); + handle.mockReturnValue(of(user)); + + const result = await run({ user: { role: UserRole.ADMIN } }); + + expect(handle).toHaveBeenCalled(); + expect(result).toBe(user); + expect(result.signature).toBe('secret-sig'); + }); + + it('passes a User entity through unchanged when the caller role is SUPER_ADMIN', async () => { + const user = Object.assign(new User(), { id: 2, signature: 'secret-sig', address: 'addr-2' }); + handle.mockReturnValue(of(user)); + + const result = await run({ user: { role: UserRole.SUPER_ADMIN } }); + + expect(handle).toHaveBeenCalled(); + expect(result).toBe(user); + expect(result.signature).toBe('secret-sig'); + }); + + it('strips signature from a User entity when the caller role is SUPPORT, keeping other properties', async () => { + const user = Object.assign(new User(), { id: 3, signature: 'secret-sig', address: 'addr-3' }); + handle.mockReturnValue(of(user)); + + const result = await run({ user: { role: UserRole.SUPPORT } }); + + expect(result.signature).toBeUndefined(); + expect(result.address).toBe('addr-3'); + }); + + it('strips signature from a User entity when there is no request.user (unauthenticated)', async () => { + const user = Object.assign(new User(), { id: 4, signature: 'secret-sig', address: 'addr-4' }); + handle.mockReturnValue(of(user)); + + const result = await run({}); + + expect(result.signature).toBeUndefined(); + expect(result.address).toBe('addr-4'); + }); + + it('recursively strips signature from nested User entities (userData.users[])', async () => { + const userA = Object.assign(new User(), { id: 10, signature: 'sig-a' }); + const userB = Object.assign(new User(), { id: 11, signature: 'sig-b' }); + const payload = { userData: { users: [userA, userB] } }; + handle.mockReturnValue(of(payload)); + + const result = await run<{ userData: { users: User[] } }>({ user: { role: UserRole.SUPPORT } }); + + expect(result.userData.users[0].signature).toBeUndefined(); + expect(result.userData.users[1].signature).toBeUndefined(); + }); + + it('leaves a non-User object with its own signature property untouched (must not filter by field name)', async () => { + // Critical case: other entities (e.g. RealUnit registrations) also carry a `signature` field and + // must not be stripped — only `instanceof User` may trigger removal. + const other = { id: 1, signature: 'not-a-login-credential' }; + handle.mockReturnValue(of(other)); + + const result = await run<{ signature: string }>({ user: { role: UserRole.SUPPORT } }); + + expect(result.signature).toBe('not-a-login-credential'); + }); + + it('does not loop infinitely on a cyclic object graph', async () => { + const a: Cyclic = {}; + const b: Cyclic = { ref: a }; + a.ref = b; + handle.mockReturnValue(of(a)); + + const result = await run({ user: { role: UserRole.SUPPORT } }); + + expect(result).toBe(a); + expect(result.ref).toBe(b); + }); + + // The cases above exercise the interceptor directly, so none of them would notice if the global + // registration were dropped - the protection would be silently gone. This pins the wiring itself. + it('is registered globally as an APP_INTERCEPTOR', async () => { + const { AppModule } = await import('src/app.module'); + const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AppModule) as { + provide?: unknown; + useClass?: unknown; + }[]; + + expect(providers.some((p) => p.provide === APP_INTERCEPTOR && p.useClass === SignatureVisibilityInterceptor)).toBe( + true, + ); + }); +}); diff --git a/src/shared/auth/signature-visibility.interceptor.ts b/src/shared/auth/signature-visibility.interceptor.ts new file mode 100644 index 0000000000..e74a1a8be1 --- /dev/null +++ b/src/shared/auth/signature-visibility.interceptor.ts @@ -0,0 +1,58 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { hasRoleAccess } from 'src/shared/auth/role.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; + +// Global strip of the login credential on `User.signature` for non-admin HTTP responses. +// +// At least 13 endpoints surface this column today through relations; `Transaction.user` is eager and +// carries it even when no user relation was requested. Filtering per endpoint would be incomplete and +// would be forgotten on every new admin read path — this interceptor closes the leak in ONE place. +// +// Filtering is by entity type (`instanceof User`), never by property name: other entities in the repo +// also carry a field named `signature` (e.g. RealUnit registrations) and must not be stripped. +// Missing `request.user` is treated as non-admin (fail-closed), not as a special pass-through. +// A WeakSet tracks visited objects so cyclic entity graphs (bidirectional relations) do not loop. +// +// Registered as an APP_INTERCEPTOR next to TfaEnforcementInterceptor. +@Injectable() +export class SignatureVisibilityInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const role: UserRole | undefined = request.user?.role; + + // FAST PATH: ADMIN / SUPER_ADMIN traffic keeps the full response; no map, no walk. + // This is the bulk of admin-endpoint traffic and must stay a single role check. + if (hasRoleAccess(UserRole.ADMIN, role)) return next.handle(); + + return next.handle().pipe(map((data) => this.stripSignatures(data))); + } + + private stripSignatures(value: unknown, seen: WeakSet = new WeakSet()): unknown { + if (typeof value !== 'object' || value === null) return value; + if (value instanceof Date) return value; + if (value instanceof Buffer) return value; + if (seen.has(value as object)) return value; + + seen.add(value as object); + + if (Array.isArray(value)) { + for (const element of value) { + this.stripSignatures(element, seen); + } + return value; + } + + if (value instanceof User) { + delete (value as { signature?: string }).signature; + } + + for (const key of Object.keys(value as object)) { + this.stripSignatures((value as Record)[key], seen); + } + + return value; + } +} diff --git a/src/subdomains/generic/user/models/user/user.entity.ts b/src/subdomains/generic/user/models/user/user.entity.ts index 0699ce0e24..269e0d3580 100644 --- a/src/subdomains/generic/user/models/user/user.entity.ts +++ b/src/subdomains/generic/user/models/user/user.entity.ts @@ -28,6 +28,10 @@ export class User extends IEntity { @Column({ length: 256, nullable: true }) addressType?: UserAddressType; + // Login credential: for DeFiChain and custodial Lightning the stored value IS the secret that + // authenticates a sign-in (see AuthService.verifySignature). Loaded normally like any other column; + // `SignatureVisibilityInterceptor` (registered as a global APP_INTERCEPTOR) strips it from every HTTP + // response whose caller does not satisfy the ADMIN role requirement. @Column({ type: 'text', nullable: true }) signature?: string;