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
10 changes: 10 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import { AuthGlobalModule } from '@/global/auth/auth-global.module';
import { GraphqlGlobalModule } from '@/global/graphql/graphql.module';
import { LoggerModule } from '@/global/logger/logger.module';
import { DocsAccessMiddleware } from '@/global/middlewares/docs-access.middleware';
import {
RequestContextMiddleware,
RequestContextModule,
} from '@/global/request-context';
import { StorageModule } from '@/global/storage/storage.module';
import { PrismaModule } from '@/prisma';

Expand All @@ -44,6 +48,7 @@ import { PrismaModule } from '@/prisma';
}),
CommonModule,
PrismaModule,
RequestContextModule,
LoggerModule,
AuthGlobalModule,
GraphqlGlobalModule,
Expand Down Expand Up @@ -88,6 +93,11 @@ import { PrismaModule } from '@/prisma';
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
// 모든 요청(REST·GraphQL)에 대해 요청 컨텍스트(client IP/UA)를 가장 먼저 연다.
consumer
.apply(RequestContextMiddleware)
.forRoutes({ path: '*path', method: RequestMethod.ALL });

consumer
.apply(DocsAccessMiddleware)
.forRoutes(
Expand Down
54 changes: 30 additions & 24 deletions src/common/utils/http-meta.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,25 @@ describe('http-meta', () => {
});

describe('clientIpOf', () => {
it('X-Forwarded-For 첫 번째 IP를 반환한다', () => {
expect(
clientIpOf(mockReq({ 'x-forwarded-for': '1.2.3.4, 5.6.7.8' })),
).toBe('1.2.3.4');
});

it('X-Forwarded-For가 없으면 X-Real-IP를 반환한다', () => {
expect(clientIpOf(mockReq({ 'x-real-ip': '10.0.0.1' }))).toBe('10.0.0.1');
});

it('둘 다 없으면 req.ip를 반환한다', () => {
it('req.ip (trust proxy 가 계산한 값) 를 반환한다', () => {
expect(clientIpOf(mockReq({}, { ip: '192.168.0.1' }))).toBe(
'192.168.0.1',
);
});

it('모두 없으면 socket.remoteAddress를 반환한다', () => {
it('raw X-Forwarded-For 를 신뢰하지 않고 req.ip 를 반환한다 (spoofing 방어)', () => {
// 클라이언트가 XFF 를 위조해도 Express 가 계산한 req.ip 만 사용한다.
expect(
clientIpOf(
mockReq(
{ 'x-forwarded-for': '1.2.3.4, 5.6.7.8' },
{ ip: '203.0.113.9' },
),
),
).toBe('203.0.113.9');
});

it('req.ip 가 없으면 socket.remoteAddress 를 반환한다', () => {
expect(clientIpOf(mockReq())).toBe('127.0.0.1');
});

Expand Down Expand Up @@ -81,25 +83,29 @@ describe('http-meta', () => {
});

describe('tryClientIp (DB persistence 용 — 추출 실패 시 undefined)', () => {
it('X-Forwarded-For 첫 번째 IP를 반환한다', () => {
expect(
tryClientIp(mockReq({ 'x-forwarded-for': '1.2.3.4, 5.6.7.8' })),
).toBe('1.2.3.4');
it('req.ip (trust proxy 가 계산한 값) 를 반환한다', () => {
expect(tryClientIp(mockReq({}, { ip: '192.168.0.1' }))).toBe(
'192.168.0.1',
);
});

it('X-Real-IP 를 반환한다', () => {
expect(tryClientIp(mockReq({ 'x-real-ip': '10.0.0.1' }))).toBe(
'10.0.0.1',
);
it('raw X-Forwarded-For 를 신뢰하지 않고 req.ip 를 반환한다 (spoofing 방어)', () => {
// 위조된 XFF 가 있어도 Express 가 계산한 req.ip 만 채택한다.
expect(
tryClientIp(
mockReq({ 'x-forwarded-for': '1.2.3.4' }, { ip: '203.0.113.9' }),
),
).toBe('203.0.113.9');
});

it('req.ip 를 반환한다', () => {
expect(tryClientIp(mockReq({}, { ip: '192.168.0.1' }))).toBe(
'192.168.0.1',
it('raw X-Real-IP 를 신뢰하지 않는다 (spoofing 방어)', () => {
// X-Real-IP 만 있고 req.ip 가 없으면 헤더가 아니라 socket fallback 으로 간다.
expect(tryClientIp(mockReq({ 'x-real-ip': '10.0.0.1' }))).toBe(
'127.0.0.1',
);
});

it('socket.remoteAddress 를 반환한다', () => {
it('req.ip 가 없으면 socket.remoteAddress 를 반환한다', () => {
expect(tryClientIp(mockReq())).toBe('127.0.0.1');
});

Expand Down
25 changes: 10 additions & 15 deletions src/common/utils/http-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,19 @@ export function tryUserAgent(req: Request): string | undefined {
}

/**
* Client IP 를 추출한다 (X-Forwarded-For → X-Real-IP → req.ip → socket.remoteAddress 순).
* 추출 실패 시 undefined (DB nullable persistence 용).
* Client IP 를 추출한다.
*
* Express 의 `trust proxy` 설정(main.ts, `TRUST_PROXY_HOPS`)으로 계산된 `req.ip` 를
* 단일 진실 소스로 사용한다. trust proxy 가 설정되면 Express 가 X-Forwarded-For 를
* hop 수만큼 신뢰해 실제 client IP 를 `req.ip` 에 반영하고, 미설정 시 socket 주소를 쓴다.
*
* raw `X-Forwarded-For` / `X-Real-IP` 헤더를 **직접 신뢰하지 않는다** — 클라이언트가
* 임의로 위조할 수 있어 trust proxy 검증을 우회하기 때문(IP spoofing). hop 수 적용은
* 전적으로 Express 에 위임한다.
*
* 운영 환경에서 정확한 client IP 를 얻으려면 main.ts 의 trust proxy 설정 필요
* (Express 가 X-Forwarded-For 를 신뢰해서 req.ip 에 반영).
* 추출 실패 시 undefined (DB nullable persistence 용).
*/
export function tryClientIp(req: Request): string | undefined {
const forwardedFor = req.headers['x-forwarded-for'];
if (typeof forwardedFor === 'string' && forwardedFor.trim().length > 0) {
const forwardedIp = forwardedFor.split(',').map((ip) => ip.trim())[0];
if (forwardedIp) return forwardedIp;
}

const realIp = req.headers['x-real-ip'];
if (typeof realIp === 'string' && realIp.trim().length > 0) {
return realIp;
}

const ip = req.ip ?? req.socket?.remoteAddress;
return typeof ip === 'string' && ip.length > 0 ? ip : undefined;
}
Expand Down
105 changes: 105 additions & 0 deletions src/features/audit-log/repositories/audit-log.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,23 @@ import type { PrismaClient } from '@prisma/client';
import { AuditActionType, AuditTargetType } from '@prisma/client';

import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository';
import { RequestContextService } from '@/global/request-context';
import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client';
import { closeTruncateConnection, truncateAll } from '@/test/db/truncate';
import { createAccount } from '@/test/factories';
import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder';

describe('AuditLogRepository (real DB)', () => {
let repo: AuditLogRepository;
let requestContext: RequestContextService;
let prisma: PrismaClient;

beforeAll(async () => {
const { module, prisma: p } = await createTestingModuleWithRealDb({
providers: [AuditLogRepository],
});
repo = module.get(AuditLogRepository);
requestContext = module.get(RequestContextService);
prisma = p;
});

Expand Down Expand Up @@ -88,5 +91,107 @@ describe('AuditLogRepository (real DB)', () => {
});
expect(logs[0].store_id).toBe(storeId);
});

it('ipAddress/userAgent 를 명시하지 않으면 요청 컨텍스트(ALS)에서 보강한다', async () => {
const account = await createAccount(prisma);

await requestContext.run(
{ clientIp: '203.0.113.7', userAgent: 'ctx-agent' },
async () => {
await repo.createAuditLog({
actorAccountId: account.id,
targetType: AuditTargetType.STORE,
targetId: account.id,
action: AuditActionType.UPDATE,
});
},
);

const logs = await prisma.auditLog.findMany({
where: { actor_account_id: account.id },
});
expect(logs[0].ip_address).toBe('203.0.113.7');
expect(logs[0].user_agent).toBe('ctx-agent');
});

it('명시된 ipAddress/userAgent 가 요청 컨텍스트보다 우선한다', async () => {
const account = await createAccount(prisma);

await requestContext.run(
{ clientIp: '203.0.113.7', userAgent: 'ctx-agent' },
async () => {
await repo.createAuditLog({
actorAccountId: account.id,
targetType: AuditTargetType.STORE,
targetId: account.id,
action: AuditActionType.UPDATE,
ipAddress: '10.0.0.1',
userAgent: 'explicit-agent',
});
},
);

const logs = await prisma.auditLog.findMany({
where: { actor_account_id: account.id },
});
expect(logs[0].ip_address).toBe('10.0.0.1');
expect(logs[0].user_agent).toBe('explicit-agent');
});

it('요청 컨텍스트 밖에서는 ip/ua 가 null 로 저장된다', async () => {
const account = await createAccount(prisma);

await repo.createAuditLog({
actorAccountId: account.id,
targetType: AuditTargetType.STORE,
targetId: account.id,
action: AuditActionType.UPDATE,
});

const logs = await prisma.auditLog.findMany({
where: { actor_account_id: account.id },
});
expect(logs[0].ip_address).toBeNull();
expect(logs[0].user_agent).toBeNull();
});

it('malformed·overlong IP 는 컬럼에 쓰지 않고 null 로 저장한다 (insert 실패 방지)', async () => {
const account = await createAccount(prisma);
// ip_address VarChar(64) 초과 + 비정상 형식. trust proxy 가 넘길 수 있는 위험값.
const overlong = `not-an-ip-${'x'.repeat(80)}`;

await requestContext.run({ clientIp: overlong }, async () => {
await repo.createAuditLog({
actorAccountId: account.id,
targetType: AuditTargetType.STORE,
targetId: account.id,
action: AuditActionType.UPDATE,
});
});

const logs = await prisma.auditLog.findMany({
where: { actor_account_id: account.id },
});
expect(logs).toHaveLength(1);
expect(logs[0].ip_address).toBeNull();
});

it('IPv6 client IP 도 유효하면 그대로 저장한다', async () => {
const account = await createAccount(prisma);

await requestContext.run({ clientIp: '2001:db8::1' }, async () => {
await repo.createAuditLog({
actorAccountId: account.id,
targetType: AuditTargetType.STORE,
targetId: account.id,
action: AuditActionType.UPDATE,
});
});

const logs = await prisma.auditLog.findMany({
where: { actor_account_id: account.id },
});
expect(logs[0].ip_address).toBe('2001:db8::1');
});
});
});
48 changes: 45 additions & 3 deletions src/features/audit-log/repositories/audit-log.repository.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isIP } from 'node:net';

import { Injectable } from '@nestjs/common';
import {
type AuditActionType,
Expand All @@ -7,19 +9,31 @@ import {
} from '@prisma/client';

import type { IAuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository.interface';
import { RequestContextService } from '@/global/request-context';
import { PrismaService } from '@/prisma';

/** `audit_log.user_agent` 컬럼 길이(VarChar(512)) 상한. */
const MAX_USER_AGENT_LENGTH = 512;

/**
* AuditLog Repository 구체 구현.
*
* `audit_log` 테이블 write 전용. read 가 필요하면 별도 메서드를 추가한다.
*
* ip/ua 는 단일 write 진입점에서 요청 컨텍스트(ALS)로부터 자동 보강한다 —
* 도메인 서비스가 transport 메타데이터를 인자로 들고 다니지 않게 한다.
* 명시적으로 전달된 `args.ipAddress`/`args.userAgent` 가 있으면 그쪽이 우선한다.
*/
@Injectable()
export class AuditLogRepository implements IAuditLogRepository {
/**
* @param prisma PrismaService
* @param requestContext 요청 컨텍스트(ALS) — ip/ua 자동 보강용
*/
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly requestContext: RequestContextService,
) {}

async createAuditLog(args: {
actorAccountId: bigint;
Expand All @@ -32,6 +46,7 @@ export class AuditLogRepository implements IAuditLogRepository {
ipAddress?: string;
userAgent?: string;
}): Promise<AuditLog> {
const ctx = this.requestContext.get();
return this.prisma.auditLog.create({
data: {
actor_account_id: args.actorAccountId,
Expand All @@ -42,9 +57,36 @@ export class AuditLogRepository implements IAuditLogRepository {
before_json:
args.beforeJson === null ? Prisma.JsonNull : args.beforeJson,
after_json: args.afterJson === null ? Prisma.JsonNull : args.afterJson,
ip_address: args.ipAddress ?? null,
user_agent: args.userAgent ?? null,
ip_address: normalizeIpForPersistence(args.ipAddress ?? ctx?.clientIp),
user_agent: normalizeUserAgentForPersistence(
args.userAgent ?? ctx?.userAgent,
),
},
});
}
}

/**
* 감사 로그 컬럼(`ip_address` VarChar(64))에 저장 가능한 IP 로 정규화한다.
*
* trust proxy 환경에서 `req.ip` 는 프록시가 넘긴 값을 반영하므로, malformed·overlong
* 값이 그대로 들어오면 컬럼 길이 초과로 insert 가 실패할 수 있다. 유효한 IPv4/IPv6 가
* 아니면 null 로 떨어뜨려, 감사 로그에 쓰레기 IP 가 쌓이거나 mutation 이 깨지는 것을 막는다.
*/
function normalizeIpForPersistence(
value: string | null | undefined,
): string | null {
if (!value) return null;
return isIP(value) !== 0 ? value : null;
}

/**
* 감사 로그 컬럼(`user_agent` VarChar(512))에 저장 가능한 UA 로 정규화한다.
* 컬럼 길이 초과 방지를 위해 512 자로 자른다.
*/
function normalizeUserAgentForPersistence(
value: string | null | undefined,
): string | null {
if (!value) return null;
return value.slice(0, MAX_USER_AGENT_LENGTH);
}
6 changes: 6 additions & 0 deletions src/global/request-context/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* 요청 컨텍스트(ALS) 인프라 Export
*/
export * from '@/global/request-context/request-context.service';
export * from '@/global/request-context/request-context.middleware';
export * from '@/global/request-context/request-context.module';
Loading
Loading