Skip to content
Open
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
11 changes: 8 additions & 3 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,21 @@ 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';

@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 {}
123 changes: 123 additions & 0 deletions src/shared/auth/__tests__/signature-visibility.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ExecutionContext>({ switchToHttp: () => ({ getRequest: () => request }) as never });

// The interceptor is typed against `unknown` payloads, so each case states the shape it put in.
const run = async <T>(request: unknown): Promise<T> =>
firstValueFrom((await interceptor.intercept(context(request), next)) as Observable<T>);

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>({ 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>({ 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>({ 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<User>({});

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<Cyclic>({ 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,
);
});
});
58 changes: 58 additions & 0 deletions src/shared/auth/signature-visibility.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
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<object> = 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<string, unknown>)[key], seen);
}

return value;
}
}
4 changes: 4 additions & 0 deletions src/subdomains/generic/user/models/user/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down