From 8de890077908e9c9a72f2a27ef33c96c203d1369 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 4 Jun 2026 03:04:22 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(audit):=20client=20IP=20trust-proxy=20?= =?UTF-8?q?=EC=A0=95=ED=95=A9=20+=20=EA=B0=90=EC=82=AC=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20IP=20=EB=B0=B0=EC=84=A0=20(ALS=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tryClientIp: raw X-Forwarded-For/X-Real-IP 직접 신뢰 제거 → Express trust proxy 가 계산한 req.ip 단일 소스 사용. TRUST_PROXY_HOPS 설정이 비로소 유효해지고 IP 위조 차단 - RequestContextService(AsyncLocalStorage) + RequestContextMiddleware 신설: 요청 진입 시 client IP/UA 를 요청 컨텍스트에 적재 (REST·GraphQL 공통) - AuditLogRepository 가 단일 write 진입점에서 컨텍스트의 IP/UA 자동 보강 (명시 인자가 있으면 우선). seller 감사 로그 ip_address 가 항상 null 이던 문제 해소 - 도메인 서비스 시그니처 무변경 (transport 메타데이터 누수 없음) - testing-module.builder 가 RequestContextService 기본 제공 --- src/app.module.ts | 10 +++ src/common/utils/http-meta.spec.ts | 54 ++++++------ src/common/utils/http-meta.ts | 25 +++--- .../repositories/audit-log.repository.spec.ts | 66 ++++++++++++++ .../repositories/audit-log.repository.ts | 16 +++- src/global/request-context/index.ts | 6 ++ .../request-context.middleware.spec.ts | 86 +++++++++++++++++++ .../request-context.middleware.ts | 29 +++++++ .../request-context/request-context.module.ts | 16 ++++ .../request-context.service.spec.ts | 49 +++++++++++ .../request-context.service.ts | 55 ++++++++++++ src/test/modules/testing-module.builder.ts | 6 ++ 12 files changed, 376 insertions(+), 42 deletions(-) create mode 100644 src/global/request-context/index.ts create mode 100644 src/global/request-context/request-context.middleware.spec.ts create mode 100644 src/global/request-context/request-context.middleware.ts create mode 100644 src/global/request-context/request-context.module.ts create mode 100644 src/global/request-context/request-context.service.spec.ts create mode 100644 src/global/request-context/request-context.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index e5198a8..97ee56a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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'; @@ -44,6 +48,7 @@ import { PrismaModule } from '@/prisma'; }), CommonModule, PrismaModule, + RequestContextModule, LoggerModule, AuthGlobalModule, GraphqlGlobalModule, @@ -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( diff --git a/src/common/utils/http-meta.spec.ts b/src/common/utils/http-meta.spec.ts index 0a6c7ae..c9dd5f6 100644 --- a/src/common/utils/http-meta.spec.ts +++ b/src/common/utils/http-meta.spec.ts @@ -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'); }); @@ -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'); }); diff --git a/src/common/utils/http-meta.ts b/src/common/utils/http-meta.ts index d1cb696..e8bf008 100644 --- a/src/common/utils/http-meta.ts +++ b/src/common/utils/http-meta.ts @@ -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; } diff --git a/src/features/audit-log/repositories/audit-log.repository.spec.ts b/src/features/audit-log/repositories/audit-log.repository.spec.ts index bdab9bc..2a30e74 100644 --- a/src/features/audit-log/repositories/audit-log.repository.spec.ts +++ b/src/features/audit-log/repositories/audit-log.repository.spec.ts @@ -2,6 +2,7 @@ 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'; @@ -9,6 +10,7 @@ import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.bui describe('AuditLogRepository (real DB)', () => { let repo: AuditLogRepository; + let requestContext: RequestContextService; let prisma: PrismaClient; beforeAll(async () => { @@ -16,6 +18,7 @@ describe('AuditLogRepository (real DB)', () => { providers: [AuditLogRepository], }); repo = module.get(AuditLogRepository); + requestContext = module.get(RequestContextService); prisma = p; }); @@ -88,5 +91,68 @@ 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(); + }); }); }); diff --git a/src/features/audit-log/repositories/audit-log.repository.ts b/src/features/audit-log/repositories/audit-log.repository.ts index 17f3d18..41d7ec8 100644 --- a/src/features/audit-log/repositories/audit-log.repository.ts +++ b/src/features/audit-log/repositories/audit-log.repository.ts @@ -7,19 +7,28 @@ 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'; /** * 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; @@ -32,6 +41,7 @@ export class AuditLogRepository implements IAuditLogRepository { ipAddress?: string; userAgent?: string; }): Promise { + const ctx = this.requestContext.get(); return this.prisma.auditLog.create({ data: { actor_account_id: args.actorAccountId, @@ -42,8 +52,8 @@ 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: args.ipAddress ?? ctx?.clientIp ?? null, + user_agent: args.userAgent ?? ctx?.userAgent ?? null, }, }); } diff --git a/src/global/request-context/index.ts b/src/global/request-context/index.ts new file mode 100644 index 0000000..1ef3bf7 --- /dev/null +++ b/src/global/request-context/index.ts @@ -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'; diff --git a/src/global/request-context/request-context.middleware.spec.ts b/src/global/request-context/request-context.middleware.spec.ts new file mode 100644 index 0000000..cb6dcc1 --- /dev/null +++ b/src/global/request-context/request-context.middleware.spec.ts @@ -0,0 +1,86 @@ +import type { NextFunction, Request, Response } from 'express'; + +import { RequestContextMiddleware } from '@/global/request-context/request-context.middleware'; +import { RequestContextService } from '@/global/request-context/request-context.service'; + +function mockReq(overrides: Partial = {}): Request { + return { + headers: {}, + socket: { remoteAddress: '127.0.0.1' }, + ...overrides, + } as unknown as Request; +} + +describe('RequestContextMiddleware', () => { + let requestContext: RequestContextService; + let middleware: RequestContextMiddleware; + + beforeEach(() => { + requestContext = new RequestContextService(); + middleware = new RequestContextMiddleware(requestContext); + }); + + it('next() 콜백 실행 시점에 req.ip/UA 가 컨텍스트에 적재된다', () => { + const req = mockReq({ + ip: '203.0.113.9', + headers: { 'user-agent': 'jest-agent' }, + } as Partial); + + let seenIp: string | undefined; + let seenUa: string | undefined; + const next: NextFunction = () => { + seenIp = requestContext.getClientIp(); + seenUa = requestContext.getUserAgent(); + }; + + middleware.use(req, {} as Response, next); + + expect(seenIp).toBe('203.0.113.9'); + expect(seenUa).toBe('jest-agent'); + }); + + it('위조된 X-Forwarded-For 가 있어도 req.ip 만 적재한다 (spoofing 방어)', () => { + const req = mockReq({ + ip: '203.0.113.9', + headers: { 'x-forwarded-for': '1.2.3.4' }, + } as Partial); + + let seenIp: string | undefined; + middleware.use(req, {} as Response, () => { + seenIp = requestContext.getClientIp(); + }); + + expect(seenIp).toBe('203.0.113.9'); + }); + + it('async 핸들러(await)를 넘어도 컨텍스트가 유지된다', async () => { + const req = mockReq({ + ip: '198.51.100.7', + headers: {}, + } as Partial); + + let seenIp: string | undefined; + await new Promise((resolve) => { + middleware.use(req, {} as Response, () => { + void (async () => { + await Promise.resolve(); + seenIp = requestContext.getClientIp(); + resolve(); + })(); + }); + }); + + expect(seenIp).toBe('198.51.100.7'); + }); + + it('IP/UA 추출 실패 시 undefined 가 적재된다', () => { + const req = mockReq({ headers: {}, socket: {} } as Partial); + + let seen: { clientIp?: string; userAgent?: string } | undefined; + middleware.use(req, {} as Response, () => { + seen = requestContext.get(); + }); + + expect(seen).toEqual({ clientIp: undefined, userAgent: undefined }); + }); +}); diff --git a/src/global/request-context/request-context.middleware.ts b/src/global/request-context/request-context.middleware.ts new file mode 100644 index 0000000..95ceed0 --- /dev/null +++ b/src/global/request-context/request-context.middleware.ts @@ -0,0 +1,29 @@ +import { Injectable, type NestMiddleware } from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; + +import { tryClientIp, tryUserAgent } from '@/common/utils/http-meta'; +import { RequestContextService } from '@/global/request-context/request-context.service'; + +/** + * 요청 진입 시 client IP / User-Agent 를 요청 컨텍스트(ALS)에 적재한다. + * + * REST·GraphQL 모두 Express 미들웨어를 거치므로 한 곳에서 모든 요청을 덮는다. + * `next()` 를 `run()` 콜백 안에서 호출해야 이후 핸들러 체인이 컨텍스트 안에서 + * 실행된다(ALS 전파의 전제). + * + * IP 추출은 `tryClientIp`(trust proxy 기반) 단일 소스에 위임한다. + */ +@Injectable() +export class RequestContextMiddleware implements NestMiddleware { + constructor(private readonly requestContext: RequestContextService) {} + + use(req: Request, _res: Response, next: NextFunction): void { + this.requestContext.run( + { + clientIp: tryClientIp(req), + userAgent: tryUserAgent(req), + }, + () => next(), + ); + } +} diff --git a/src/global/request-context/request-context.module.ts b/src/global/request-context/request-context.module.ts new file mode 100644 index 0000000..ed7c0c3 --- /dev/null +++ b/src/global/request-context/request-context.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; + +import { RequestContextService } from '@/global/request-context/request-context.service'; + +/** + * 요청 컨텍스트(ALS) 전역 모듈. + * + * `RequestContextService` 를 전역 export 하여 어느 feature 에서도 주입받을 수 있게 한다. + * 미들웨어 적용은 `AppModule.configure()` 가 담당한다. + */ +@Global() +@Module({ + providers: [RequestContextService], + exports: [RequestContextService], +}) +export class RequestContextModule {} diff --git a/src/global/request-context/request-context.service.spec.ts b/src/global/request-context/request-context.service.spec.ts new file mode 100644 index 0000000..0b8c6a1 --- /dev/null +++ b/src/global/request-context/request-context.service.spec.ts @@ -0,0 +1,49 @@ +import { RequestContextService } from '@/global/request-context/request-context.service'; + +describe('RequestContextService', () => { + let service: RequestContextService; + + beforeEach(() => { + service = new RequestContextService(); + }); + + it('run() 안에서 get()/getClientIp()/getUserAgent() 가 컨텍스트를 반환한다', () => { + service.run({ clientIp: '203.0.113.9', userAgent: 'jest-ua' }, () => { + expect(service.get()).toEqual({ + clientIp: '203.0.113.9', + userAgent: 'jest-ua', + }); + expect(service.getClientIp()).toBe('203.0.113.9'); + expect(service.getUserAgent()).toBe('jest-ua'); + }); + }); + + it('run() 밖에서는 undefined 를 반환한다', () => { + expect(service.get()).toBeUndefined(); + expect(service.getClientIp()).toBeUndefined(); + expect(service.getUserAgent()).toBeUndefined(); + }); + + it('async 경계(await)를 넘어도 컨텍스트가 유지된다', async () => { + await service.run({ clientIp: '198.51.100.7' }, async () => { + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + expect(service.getClientIp()).toBe('198.51.100.7'); + }); + }); + + it('중첩 run() 은 안쪽 컨텍스트가 우선하고, 벗어나면 복원된다', () => { + service.run({ clientIp: 'outer' }, () => { + expect(service.getClientIp()).toBe('outer'); + service.run({ clientIp: 'inner' }, () => { + expect(service.getClientIp()).toBe('inner'); + }); + expect(service.getClientIp()).toBe('outer'); + }); + }); + + it('run() 콜백의 반환값을 그대로 전달한다', () => { + const result = service.run({ clientIp: 'x' }, () => 42); + expect(result).toBe(42); + }); +}); diff --git a/src/global/request-context/request-context.service.ts b/src/global/request-context/request-context.service.ts new file mode 100644 index 0000000..6540bb9 --- /dev/null +++ b/src/global/request-context/request-context.service.ts @@ -0,0 +1,55 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +import { Injectable } from '@nestjs/common'; + +/** + * 요청 단위로 전파되는 횡단 메타데이터. + * + * transport(HTTP) 계층에서만 알 수 있는 값이라 도메인 서비스 시그니처를 오염시키지 않고 + * AsyncLocalStorage 로 암묵 전파한다. 감사 로그(ip)·향후 requestId/UA 등이 여기 모인다. + */ +export interface RequestContextStore { + clientIp?: string; + userAgent?: string; +} + +/** + * 요청 컨텍스트 저장소(싱글톤). + * + * `RequestContextMiddleware` 가 요청 진입 시 `run()` 으로 컨텍스트를 연다. + * 이후 같은 async continuation 에서 실행되는 resolver/service/repository 는 + * `get()` 으로 컨텍스트를 읽는다. run() 밖에서 호출하면 undefined. + */ +@Injectable() +export class RequestContextService { + private readonly storage = new AsyncLocalStorage(); + + /** + * 주어진 컨텍스트로 callback 을 실행한다. callback 내부(및 그 async 후속)에서 + * `get()` 이 이 컨텍스트를 반환한다. + */ + run(store: RequestContextStore, callback: () => T): T { + return this.storage.run(store, callback); + } + + /** + * 현재 요청 컨텍스트. run() 밖이면 undefined. + */ + get(): RequestContextStore | undefined { + return this.storage.getStore(); + } + + /** + * 현재 요청의 client IP. 없으면 undefined. + */ + getClientIp(): string | undefined { + return this.storage.getStore()?.clientIp; + } + + /** + * 현재 요청의 User-Agent. 없으면 undefined. + */ + getUserAgent(): string | undefined { + return this.storage.getStore()?.userAgent; + } +} diff --git a/src/test/modules/testing-module.builder.ts b/src/test/modules/testing-module.builder.ts index eb9742e..ac6e4de 100644 --- a/src/test/modules/testing-module.builder.ts +++ b/src/test/modules/testing-module.builder.ts @@ -2,6 +2,7 @@ import type { ModuleMetadata } from '@nestjs/common'; import { Test, type TestingModule } from '@nestjs/testing'; import type { PrismaClient } from '@prisma/client'; +import { RequestContextService } from '@/global/request-context'; import { PrismaService } from '@/prisma/prisma.service'; import { getTestPrismaClient } from '@/test/db/prisma-test-client'; @@ -9,6 +10,10 @@ import { getTestPrismaClient } from '@/test/db/prisma-test-client'; * 실DB(Testcontainers) Prisma 클라이언트를 PrismaService 위치에 주입한 * TestingModule을 생성한다. * + * `RequestContextService`(ALS)도 기본 제공한다 — AuditLogRepository 등 요청 + * 컨텍스트를 주입받는 provider 가 어디서나 resolve 되도록. run() 밖이면 빈 컨텍스트라 + * 기존 동작(ip/ua null)에 영향 없다. + * * 사용 예: * ```ts * const { module, prisma } = await createTestingModuleWithRealDb({ @@ -29,6 +34,7 @@ export async function createTestingModuleWithRealDb( provide: PrismaService, useValue: prisma, }, + RequestContextService, ], }).compile(); From ab05fad628564e8f47b8343405e016bb1c63f472 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 4 Jun 2026 03:22:04 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(audit):=20=EA=B0=90=EC=82=AC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20IP/UA=20=EC=A0=80=EC=9E=A5=20=EC=A0=84=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=20(Codex=20P2=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ip_address(VarChar(64)) 저장 전 net.isIP 로 검증 — trust proxy 가 넘긴 malformed·overlong 값이 컬럼 길이 초과로 insert 를 깨뜨리는 것을 방지. 유효 IPv4/IPv6 가 아니면 null 로 저장 (쓰레기 IP 적재 방지) - user_agent(VarChar(512)) 도 저장 전 512 자로 절단 (defense-in-depth) --- .../repositories/audit-log.repository.spec.ts | 39 +++++++++++++++++++ .../repositories/audit-log.repository.ts | 36 ++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/features/audit-log/repositories/audit-log.repository.spec.ts b/src/features/audit-log/repositories/audit-log.repository.spec.ts index 2a30e74..c902f26 100644 --- a/src/features/audit-log/repositories/audit-log.repository.spec.ts +++ b/src/features/audit-log/repositories/audit-log.repository.spec.ts @@ -154,5 +154,44 @@ describe('AuditLogRepository (real DB)', () => { 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'); + }); }); }); diff --git a/src/features/audit-log/repositories/audit-log.repository.ts b/src/features/audit-log/repositories/audit-log.repository.ts index 41d7ec8..ba7fb2d 100644 --- a/src/features/audit-log/repositories/audit-log.repository.ts +++ b/src/features/audit-log/repositories/audit-log.repository.ts @@ -1,3 +1,5 @@ +import { isIP } from 'node:net'; + import { Injectable } from '@nestjs/common'; import { type AuditActionType, @@ -10,6 +12,9 @@ import type { IAuditLogRepository } from '@/features/audit-log/repositories/audi import { RequestContextService } from '@/global/request-context'; import { PrismaService } from '@/prisma'; +/** `audit_log.user_agent` 컬럼 길이(VarChar(512)) 상한. */ +const MAX_USER_AGENT_LENGTH = 512; + /** * AuditLog Repository 구체 구현. * @@ -52,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 ?? ctx?.clientIp ?? null, - user_agent: args.userAgent ?? ctx?.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); +}