diff --git a/Caddyfile b/Caddyfile index e3721d6..e6bb341 100644 --- a/Caddyfile +++ b/Caddyfile @@ -42,6 +42,11 @@ http://promptplace.kro.kr { } } + # Redis keepalive는 Caddy 응답이 아니라 Express의 실제 PING까지 전달 + handle /health/redis { + reverse_proxy app:3000 + } + # (선택) ALB 헬스체크 편의 엔드포인트 @health path /health handle @health { @@ -83,6 +88,11 @@ http://promptplace-dev.kro.kr { } } + # Redis keepalive는 app-dev의 실제 Redis PING까지 전달 + handle /health/redis { + reverse_proxy app-dev:3000 + } + # 로드밸런서 헬스체크용 @health path /health handle @health { @@ -93,4 +103,4 @@ http://promptplace-dev.kro.kr { output stdout format console } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 0272c45..745905b 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,11 @@ perf/#이슈번호 # 성능 개선 - **GitHub Secrets**를 통한 보안 정보 관리 - **Docker 컨테이너화** 준비 가능한 구조 +보안 관련 필수 운영 설정: + +- `PAYPLE_PAYOUT_WEBHOOK_ALLOWED_IPS`: Payple 고객센터에서 확인한 웹훅 원본 IP 또는 CIDR을 쉼표로 구분합니다. 누락되거나 잘못되면 지급 웹훅은 안전하게 `503`으로 거절됩니다. +- S3 버킷은 Block Public Access를 켜고 `business-licenses/*` 공개 정책을 두지 않습니다. 사업자등록증 조회는 인증된 본인 또는 관리자 API가 발급한 5분 만료 presigned URL만 사용합니다. + ## ⚡ 성능 최적화 ### **데이터베이스 최적화** diff --git a/prisma/migrations/20261005000000_store_business_license_object_keys/migration.sql b/prisma/migrations/20261005000000_store_business_license_object_keys/migration.sql new file mode 100644 index 0000000..e836978 --- /dev/null +++ b/prisma/migrations/20261005000000_store_business_license_object_keys/migration.sql @@ -0,0 +1,8 @@ +-- 사업자등록증은 공개/직접 URL 대신 S3 객체 키만 저장한다. +-- 기존 업로드 코드가 생성한 virtual-hosted S3 URL만 안전하게 변환한다. +UPDATE `SettlementAccount` +SET `business_license_url` = SUBSTRING( + `business_license_url`, + LOCATE('business-licenses/', `business_license_url`) +) +WHERE `business_license_url` LIKE 'https://%.amazonaws.com/business-licenses/%'; diff --git a/src/announcements/controllers/announcement.controller.ts b/src/announcements/controllers/announcement.controller.ts index 3c98eac..410e383 100644 --- a/src/announcements/controllers/announcement.controller.ts +++ b/src/announcements/controllers/announcement.controller.ts @@ -15,6 +15,8 @@ interface RawPaginationQuery { size?: string; } +type FlatParamsRequest = Request>; + //비회원 이용 가능 export const getAnnouncementList = async ( req: Request, @@ -35,7 +37,7 @@ export const getAnnouncementList = async ( } }; -export const getAnnouncement = async (req: Request, res: Response) => { +export const getAnnouncement = async (req: FlatParamsRequest, res: Response) => { try { const result = await getAnnouncementService(req.params.announcementId); return res.success({ @@ -51,7 +53,7 @@ export const getAnnouncement = async (req: Request, res: Response) => { } }; -export const createAnnouncement = async (req: Request, res: Response) => { +export const createAnnouncement = async (req: FlatParamsRequest, res: Response) => { if (!req.user) { res.fail({ statusCode: 401, @@ -76,7 +78,7 @@ export const createAnnouncement = async (req: Request, res: Response) => { } }; -export const patchAnnouncement = async (req: Request, res: Response) => { +export const patchAnnouncement = async (req: FlatParamsRequest, res: Response) => { try { const result = await patchAnnouncementService(req.params.announcementId, req.body); return res.success({ @@ -92,7 +94,7 @@ export const patchAnnouncement = async (req: Request, res: Response) => { } }; -export const deleteAnnouncement = async (req: Request, res: Response) => { +export const deleteAnnouncement = async (req: FlatParamsRequest, res: Response) => { try { const result = await deleteAnnouncementService(req.params.announcementId); return res.success({ diff --git a/src/index.ts b/src/index.ts index 78cfba3..d1c0bb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,9 @@ import morgan = require('morgan'); const PORT = 3000; const app = express(); +// 앱 컨테이너 앞의 Caddy/ALB 사설망 hop만 신뢰해 req.ip를 원본 IP로 복원한다. +app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']); + // Express 앱을 http 서버로 감싸기 const server = http.createServer(app); diff --git a/src/inquiries/controllers/inquiry.controller.ts b/src/inquiries/controllers/inquiry.controller.ts index 9245e65..065f72f 100644 --- a/src/inquiries/controllers/inquiry.controller.ts +++ b/src/inquiries/controllers/inquiry.controller.ts @@ -10,6 +10,8 @@ import { GetInquiriesDto } from "../dtos/get-inquiries.dto"; import { CreateReplyDto } from "../dtos/create-reply.dto"; import { MessageRepository } from "../../messages/repositories/message.repository"; +type FlatParamsRequest = Request>; + export class InquiryController { private inquiryService: InquiryService; @@ -22,7 +24,7 @@ export class InquiryController { } public async createInquiry( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -54,7 +56,7 @@ export class InquiryController { } public async getInquiryById( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -78,7 +80,7 @@ export class InquiryController { } public async getReceivedInquiries( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -110,7 +112,7 @@ export class InquiryController { } public async createInquiryReply( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -144,7 +146,7 @@ export class InquiryController { } public async markInquiryAsRead( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -168,7 +170,7 @@ export class InquiryController { } public async deleteInquiry( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { diff --git a/src/members/controllers/member.controller.ts b/src/members/controllers/member.controller.ts index 990e7e0..5e83703 100644 --- a/src/members/controllers/member.controller.ts +++ b/src/members/controllers/member.controller.ts @@ -12,6 +12,8 @@ import { UpdateHistoryDto } from "../dtos/update-history.dto"; import { CreateSnsDto } from "../dtos/create-sns.dto"; import { UpdateSnsDto } from "../dtos/update-sns.dto"; +type FlatParamsRequest = Request>; + export class MemberController { private memberService: MemberService; @@ -20,7 +22,7 @@ export class MemberController { } public async getFollowers( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -38,7 +40,7 @@ export class MemberController { } public async followUser( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -53,7 +55,7 @@ export class MemberController { } public async unfollowUser( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -71,7 +73,7 @@ export class MemberController { } public async getFollowings( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -89,7 +91,7 @@ export class MemberController { } public async getMyPrompts( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -133,7 +135,7 @@ export class MemberController { } public async getMemberPrompts( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -193,7 +195,7 @@ export class MemberController { } public async getMemberById( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -214,7 +216,7 @@ export class MemberController { } public async updateMember( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -246,7 +248,7 @@ export class MemberController { } public async createOrUpdateIntro( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -281,7 +283,7 @@ export class MemberController { } public async updateIntro( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -316,7 +318,7 @@ export class MemberController { } public async createHistory( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -351,7 +353,7 @@ export class MemberController { } public async updateHistory( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -388,7 +390,7 @@ export class MemberController { } public async deleteHistory( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -412,7 +414,7 @@ export class MemberController { } public async adminDeleteHistory( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -435,7 +437,7 @@ export class MemberController { } public async getHistories( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -460,7 +462,7 @@ export class MemberController { } public async createSns( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -495,7 +497,7 @@ export class MemberController { } public async updateSns( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -536,7 +538,7 @@ export class MemberController { } public async deleteSns( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -556,7 +558,7 @@ export class MemberController { } public async getSnsList( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -578,7 +580,7 @@ export class MemberController { * 프로필 이미지 업로드 (S3) */ public async uploadProfileImage( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -608,7 +610,7 @@ export class MemberController { } public async followMember( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -632,7 +634,7 @@ export class MemberController { } public async unfollowMember( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -652,7 +654,7 @@ export class MemberController { } public async withdrawMember( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -671,7 +673,7 @@ export class MemberController { } public async getAllMembers( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -692,7 +694,7 @@ export class MemberController { } public async adminBanUser( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -715,7 +717,7 @@ export class MemberController { } public async adminUnBanUser( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { @@ -738,7 +740,7 @@ export class MemberController { } public async adminDeleteUser( - req: Request, + req: FlatParamsRequest, res: Response, next: NextFunction ): Promise { diff --git a/src/messages/controllers/message.controller.ts b/src/messages/controllers/message.controller.ts index 3d6f7d7..5dfed2c 100644 --- a/src/messages/controllers/message.controller.ts +++ b/src/messages/controllers/message.controller.ts @@ -2,11 +2,13 @@ import { Request, Response, NextFunction } from "express"; import { Service } from "typedi"; import { MessageService } from "../services/message.service"; +type FlatParamsRequest = Request>; + @Service() export class MessageController { constructor(private readonly messageService: MessageService) {} - getMessageById = async (req: Request, res: Response, next: NextFunction) => { + getMessageById = async (req: FlatParamsRequest, res: Response, next: NextFunction) => { try { const message_id = parseInt(req.params.message_id, 10); const currentUserId = (req.user as any).user_id; @@ -19,7 +21,7 @@ export class MessageController { } }; - getReceivedMessages = async (req: Request, res: Response, next: NextFunction) => { + getReceivedMessages = async (req: FlatParamsRequest, res: Response, next: NextFunction) => { try { const currentUserId = (req.user as any).user_id; const { limit, cursor, is_read } = req.query; @@ -36,7 +38,7 @@ export class MessageController { } }; -markAsRead = async (req: Request, res: Response, next: NextFunction) => { +markAsRead = async (req: FlatParamsRequest, res: Response, next: NextFunction) => { try { const message_id = parseInt(req.params.message_id, 10); const user_id = (req.user as any).user_id; @@ -48,7 +50,7 @@ markAsRead = async (req: Request, res: Response, next: NextFunction) => { } }; -deleteMessage = async (req: Request, res: Response, next: NextFunction) => { +deleteMessage = async (req: FlatParamsRequest, res: Response, next: NextFunction) => { try { const message_id = parseInt(req.params.message_id, 10); const user_id = (req.user as any).user_id; @@ -61,7 +63,7 @@ deleteMessage = async (req: Request, res: Response, next: NextFunction) => { } }; -sendMessage = async (req: Request, res: Response, next: NextFunction) => { +sendMessage = async (req: FlatParamsRequest, res: Response, next: NextFunction) => { try { const currentUserId = (req.user as any).user_id; const data = req.body; @@ -73,4 +75,4 @@ sendMessage = async (req: Request, res: Response, next: NextFunction) => { next(error); } }; -} \ No newline at end of file +} diff --git a/src/prompts/controllers/prompt.like.controller.ts b/src/prompts/controllers/prompt.like.controller.ts index e027d42..ce9cd41 100644 --- a/src/prompts/controllers/prompt.like.controller.ts +++ b/src/prompts/controllers/prompt.like.controller.ts @@ -1,7 +1,9 @@ import { Request, Response } from 'express'; import { PromptLikeService } from '../services/prompt.like.service'; -export const likePrompt = async (req: Request, res: Response): Promise => { +type FlatParamsRequest = Request>; + +export const likePrompt = async (req: FlatParamsRequest, res: Response): Promise => { const user = req.user; if (!user) { res.fail({ @@ -30,7 +32,7 @@ export const likePrompt = async (req: Request, res: Response): Promise => } }; -export const getLikedPrompts = async (req: Request, res: Response): Promise => { +export const getLikedPrompts = async (req: FlatParamsRequest, res: Response): Promise => { const user = req.user; if (!user) { res.fail({ @@ -55,7 +57,7 @@ export const getLikedPrompts = async (req: Request, res: Response): Promise => { +export const unlikePrompt = async (req: FlatParamsRequest, res: Response): Promise => { if (!req.user) { res.fail({ statusCode: 401, @@ -78,4 +80,4 @@ export const unlikePrompt = async (req: Request, res: Response): Promise = message: err.message || '서버 오류가 발생했습니다.', }); } -}; \ No newline at end of file +}; diff --git a/src/refunds/controllers/admin-refund.controller.ts b/src/refunds/controllers/admin-refund.controller.ts index f7c020c..865347b 100644 --- a/src/refunds/controllers/admin-refund.controller.ts +++ b/src/refunds/controllers/admin-refund.controller.ts @@ -8,9 +8,12 @@ import { } from '../services/admin-refund.service'; import { RefundStatusValue } from '../utils/refund-policy'; +type FlatParamsRequest = Request>; + const VALID_STATUSES: RefundStatusValue[] = ['REQUESTED', 'APPROVED', 'REJECTED', 'COMPLETED']; -const getAdminId = (req: Request): number => (req.user as { user_id: number }).user_id; +const getAdminId = (req: FlatParamsRequest): number => + (req.user as { user_id: number }).user_id; const parseRefundId = (raw: string): number | null => { const n = Number(raw); @@ -33,7 +36,7 @@ const badRefundId = (res: Response) => statusCode: 400, }); -export const getRefundListHandler = async (req: Request, res: Response) => { +export const getRefundListHandler = async (req: FlatParamsRequest, res: Response) => { const rawStatus = req.query.status as string | undefined; if (rawStatus && !VALID_STATUSES.includes(rawStatus as RefundStatusValue)) { return res.status(400).json({ @@ -56,7 +59,7 @@ export const getRefundListHandler = async (req: Request, res: Response) => { }; // 검토 대기 목록 — 목록 핸들러에 status를 고정한 얇은 래퍼. -export const getPendingRefundListHandler = async (req: Request, res: Response) => { +export const getPendingRefundListHandler = async (req: FlatParamsRequest, res: Response) => { try { const result = await listRefunds({ status: 'REQUESTED', @@ -69,7 +72,7 @@ export const getPendingRefundListHandler = async (req: Request, res: Response) = } }; -export const getRefundDetailHandler = async (req: Request, res: Response) => { +export const getRefundDetailHandler = async (req: FlatParamsRequest, res: Response) => { const refundId = parseRefundId(req.params.refundId); if (!refundId) return badRefundId(res); try { @@ -79,7 +82,7 @@ export const getRefundDetailHandler = async (req: Request, res: Response) => { } }; -export const approveRefundHandler = async (req: Request, res: Response) => { +export const approveRefundHandler = async (req: FlatParamsRequest, res: Response) => { const refundId = parseRefundId(req.params.refundId); if (!refundId) return badRefundId(res); try { @@ -89,7 +92,7 @@ export const approveRefundHandler = async (req: Request, res: Response) => { } }; -export const rejectRefundHandler = async (req: Request, res: Response) => { +export const rejectRefundHandler = async (req: FlatParamsRequest, res: Response) => { const refundId = parseRefundId(req.params.refundId); if (!refundId) return badRefundId(res); try { @@ -99,7 +102,7 @@ export const rejectRefundHandler = async (req: Request, res: Response) => { } }; -export const completeManualRefundHandler = async (req: Request, res: Response) => { +export const completeManualRefundHandler = async (req: FlatParamsRequest, res: Response) => { const refundId = parseRefundId(req.params.refundId); if (!refundId) return badRefundId(res); try { diff --git a/src/refunds/controllers/refund.controller.ts b/src/refunds/controllers/refund.controller.ts index 7def65e..2d4fd38 100644 --- a/src/refunds/controllers/refund.controller.ts +++ b/src/refunds/controllers/refund.controller.ts @@ -5,7 +5,9 @@ import { requestManualRefund, } from '../services/refund.service'; -const getUserId = (req: Request): number | null => { +type FlatParamsRequest = Request>; + +const getUserId = (req: FlatParamsRequest): number | null => { if (!req.user) return null; return (req.user as { user_id: number }).user_id; }; @@ -16,7 +18,7 @@ const parsePurchaseId = (raw: string): number | null => { return n; }; -export const checkRefundEligibility = async (req: Request, res: Response) => { +export const checkRefundEligibility = async (req: FlatParamsRequest, res: Response) => { const userId = getUserId(req); if (!userId) { return res.status(401).json({ error: 'Unauthorized', message: '로그인이 필요합니다.', statusCode: 401 }); @@ -38,7 +40,7 @@ export const checkRefundEligibility = async (req: Request, res: Response) => { } }; -export const refundPurchaseHandler = async (req: Request, res: Response) => { +export const refundPurchaseHandler = async (req: FlatParamsRequest, res: Response) => { const userId = getUserId(req); if (!userId) { return res.status(401).json({ error: 'Unauthorized', message: '로그인이 필요합니다.', statusCode: 401 }); @@ -61,7 +63,7 @@ export const refundPurchaseHandler = async (req: Request, res: Response) => { }; // 열람 후 수동 환불 신청 — 담당자 검토 대기 상태로 접수만 한다. (#533) -export const requestManualRefundHandler = async (req: Request, res: Response) => { +export const requestManualRefundHandler = async (req: FlatParamsRequest, res: Response) => { const userId = getUserId(req); if (!userId) { return res.status(401).json({ error: 'Unauthorized', message: '로그인이 필요합니다.', statusCode: 401 }); diff --git a/src/reports/controllers/report.controller.ts b/src/reports/controllers/report.controller.ts index 9a05656..8211aa0 100644 --- a/src/reports/controllers/report.controller.ts +++ b/src/reports/controllers/report.controller.ts @@ -5,9 +5,11 @@ import { getReportedPromptByIdService, } from '../services/report.service'; +type FlatParamsRequest = Request>; + // 신고 등록 export const postReport = async ( - req: Request, + req: FlatParamsRequest, res: Response ): Promise => { if (!req.user) { @@ -48,7 +50,7 @@ export const postReport = async ( // 신고된 프롬프트 목록 조회 export const getReportedPrompts = async ( - req: Request, + req: FlatParamsRequest, res: Response ): Promise => { if (!req.user) { @@ -88,7 +90,7 @@ export const getReportedPrompts = async ( // 특정 신고 조회 export const getReportedPromptById = async ( - req: Request, + req: FlatParamsRequest, res: Response ): Promise => { if (!req.user) { diff --git a/src/settlements/controllers/admin-seller.controller.ts b/src/settlements/controllers/admin-seller.controller.ts index 82fdb21..108a27f 100644 --- a/src/settlements/controllers/admin-seller.controller.ts +++ b/src/settlements/controllers/admin-seller.controller.ts @@ -38,6 +38,7 @@ export const getPendingSellerList = async ( req.query.page, req.query.limit, ); + res.set('Cache-Control', 'private, no-store'); return res.success(result, '승인 대기 사업자 판매자 목록을 조회했습니다.'); } catch (error) { return next(error); @@ -52,6 +53,7 @@ export const getPendingSellerDetail = async ( try { const userId = parseUserIdParam(req.params.userId); const result = await getPendingBusinessSellerDetail(userId); + res.set('Cache-Control', 'private, no-store'); return res.success(result, '승인 대기 사업자 판매자 상세 정보를 조회했습니다.'); } catch (error) { return next(error); @@ -142,6 +144,7 @@ export const getBusinessSellerDetailHandler = async ( try { const userId = parseUserIdParam(req.params.userId); const result = await getBusinessSellerDetail(userId); + res.set('Cache-Control', 'private, no-store'); return res.success(result, '사업자 판매자 상세 정보를 조회했습니다.'); } catch (error) { return next(error); diff --git a/src/settlements/controllers/payout-webhook.controller.ts b/src/settlements/controllers/payout-webhook.controller.ts index 0f8a461..4cf4b9e 100644 --- a/src/settlements/controllers/payout-webhook.controller.ts +++ b/src/settlements/controllers/payout-webhook.controller.ts @@ -3,20 +3,41 @@ import { SettlementPayoutRepository } from '../repositories/settlement-payout.re import { redactPaypleLog } from '../utils/payple'; // Payple 정산지급대행 이체 결과 webhook 수신 (#491 PR D). -// 명세상 인증 방식 미명시 — 운영 전 IP allowlist 또는 shared-secret 별도 적용 권장. +// 원본 IP allowlist는 route middleware에서 먼저 검증한다. // 본 컨트롤러는 group_key로 SettlementPayout을 찾아 status를 Succeed/Failed로 마감. // 멱등성: 이미 Pending이 아니면 200으로 응답하고 변경 안 함. export const handlePayoutWebhook = async (req: Request, res: Response) => { const body = req.body ?? {}; const result = body.result as string | undefined; + const cstId = body.cst_id as string | undefined; const groupKey = body.group_key as string | undefined; + const billingTranId = body.billing_tran_id as string | undefined; const apiTranId = body.api_tran_id as string | undefined; const message = body.message as string | undefined; - if (!groupKey) { - console.error('[payout-webhook] missing group_key', { body: redactPaypleLog(body) }); - return res.status(400).json({ error: 'BadRequest', message: 'group_key 누락', statusCode: 400 }); + const expectedCstId = process.env.PAYPLE_CST_ID; + if (!expectedCstId) { + console.error('[payout-webhook] PAYPLE_CST_ID is not configured'); + return res.status(503).json({ + error: 'WebhookSecurityNotConfigured', + statusCode: 503, + }); + } + if (!cstId || cstId !== expectedCstId) { + console.warn('[payout-webhook] cst_id mismatch'); + return res.status(403).json({ error: 'Forbidden', statusCode: 403 }); + } + + if (!groupKey || !billingTranId) { + console.error('[payout-webhook] missing required field', { + body: redactPaypleLog(body), + }); + return res.status(400).json({ + error: 'BadRequest', + message: 'group_key 또는 billing_tran_id 누락', + statusCode: 400, + }); } const payout = await SettlementPayoutRepository.findPayoutByGroupKey(groupKey); @@ -26,6 +47,24 @@ export const handlePayoutWebhook = async (req: Request, res: Response) => { return res.status(200).json({ ok: true }); } + if (payout.billing_tran_id !== billingTranId) { + console.warn('[payout-webhook] billing_tran_id mismatch', { + payoutId: payout.payout_id, + }); + return res.status(400).json({ + error: 'PayoutDataMismatch', + statusCode: 400, + }); + } + + if (result === 'A0000' && !apiTranId) { + return res.status(400).json({ + error: 'BadRequest', + message: 'api_tran_id 누락', + statusCode: 400, + }); + } + try { if (result === 'A0000') { await SettlementPayoutRepository.markSucceeded(payout.payout_id, apiTranId ?? ''); diff --git a/src/settlements/controllers/settlement.account.controller.ts b/src/settlements/controllers/settlement.account.controller.ts index e3785a1..4572f87 100644 --- a/src/settlements/controllers/settlement.account.controller.ts +++ b/src/settlements/controllers/settlement.account.controller.ts @@ -72,6 +72,7 @@ export const ViewAccountDetail = async (req: Request, res: Response) => { } const userId = (req.user as { user_id: number }).user_id; const data = await getSellerAccountDetail(userId); + res.set('Cache-Control', 'private, no-store'); return res.status(200).json({ message: '판매자 정보 조회가 완료되었습니다.', data, diff --git a/src/settlements/controllers/settlement.seller.controller.ts b/src/settlements/controllers/settlement.seller.controller.ts index 700cd3c..7fce553 100644 --- a/src/settlements/controllers/settlement.seller.controller.ts +++ b/src/settlements/controllers/settlement.seller.controller.ts @@ -123,12 +123,13 @@ export const uploadLicense = async (req: Request, res: Response) => { }); } - const result = await uploadBusinessLicenseFile(req.file); + const result = await uploadBusinessLicenseFile(user.user_id, req.file); return res.status(200).json({ message: result.message, fileKey: result.fileKey, - fileUrl: result.fileUrl, + // 이전 클라이언트 호환용이며 실제 URL이 아닌 동일한 private 객체 키다. + fileUrl: result.fileKey, statusCode: 200, }); } catch (error: any) { @@ -168,7 +169,7 @@ export const registerBusiness = async (req: Request, res: Response) => { if (tokenError) { return res.status(tokenError.status).json(tokenError.body); } - if (error.name === 'ValidationError') { + if (error.name === 'ValidationError' || error.error === 'ValidationError') { return res.status(400).json({ error: 'ValidationError', message: error.message, diff --git a/src/settlements/dtos/settlement.dto.ts b/src/settlements/dtos/settlement.dto.ts index 347fb5d..fc3ce40 100644 --- a/src/settlements/dtos/settlement.dto.ts +++ b/src/settlements/dtos/settlement.dto.ts @@ -29,7 +29,9 @@ export interface RegisterIndividualSellerRequestDto { export interface RegisterBusinessSellerRequestDto { registerToken: string; companyName: string; - // 정보 변경 시 새 파일 업로드 안 하고 기존 등록증 유지하고 싶으면 생략 가능 + // 업로드 API가 반환한 private S3 객체 키. 정보 변경 시에는 생략 가능. + businessLicenseKey?: string; + // 이전 클라이언트 호환용. 값은 URL이 아니라 객체 키여야 하며, 과거 같은 버킷 URL만 변환한다. businessLicenseUrl?: string; isTermsAgreed: boolean; } diff --git a/src/settlements/middlewares/payout-webhook-auth.ts b/src/settlements/middlewares/payout-webhook-auth.ts new file mode 100644 index 0000000..f790e37 --- /dev/null +++ b/src/settlements/middlewares/payout-webhook-auth.ts @@ -0,0 +1,36 @@ +import { NextFunction, Request, Response } from 'express'; +import { checkWebhookSourceIp } from '../utils/payout-webhook-security'; + +export const authorizePayoutWebhook = ( + req: Request, + res: Response, + next: NextFunction, +) => { + const sourceIp = req.ip || req.socket.remoteAddress; + const result = checkWebhookSourceIp( + sourceIp, + process.env.PAYPLE_PAYOUT_WEBHOOK_ALLOWED_IPS, + ); + + if (result === 'misconfigured') { + console.error( + '[payout-webhook] PAYPLE_PAYOUT_WEBHOOK_ALLOWED_IPS is missing or invalid', + ); + return res.status(503).json({ + error: 'WebhookSecurityNotConfigured', + message: '지급 웹훅 보안 설정이 완료되지 않았습니다.', + statusCode: 503, + }); + } + + if (result === 'denied') { + console.warn('[payout-webhook] rejected source IP', { sourceIp }); + return res.status(403).json({ + error: 'Forbidden', + message: '허용되지 않은 웹훅 요청입니다.', + statusCode: 403, + }); + } + + return next(); +}; diff --git a/src/settlements/repositories/settlement.repository.ts b/src/settlements/repositories/settlement.repository.ts index da0c90e..6094165 100644 --- a/src/settlements/repositories/settlement.repository.ts +++ b/src/settlements/repositories/settlement.repository.ts @@ -31,7 +31,7 @@ export interface UpdateBusinessAccountInput { businessNumber: string; businessType: BusinessKind; companyName: string; - // optional — 빈 값이면 기존 URL 유지 + // DB 컬럼명은 레거시 URL이지만 값은 private S3 객체 키. 빈 값이면 기존 키 유지. businessLicenseUrl?: string | null; birthDate?: string; // BUSINESS+PERSONAL일 때만 존재 billingTranId?: string | null; // Payple 정산지급대행 빌링키 (#491) @@ -108,7 +108,7 @@ export const SettlementRepository = { // 사업자 → 사업자 정보변경. // 같은 row 덮어쓰기 + status=PENDING + is_active=false (관리자 승인 전까지 일시 비활성화). - // businessLicenseUrl이 undefined면 기존 URL 유지. + // businessLicenseUrl이 undefined면 기존 객체 키 유지. updateBusinessAccountForApproval: async ( userId: number, dto: UpdateBusinessAccountInput, diff --git a/src/settlements/routes/admin-seller.route.ts b/src/settlements/routes/admin-seller.route.ts index fe07413..0fd6d29 100644 --- a/src/settlements/routes/admin-seller.route.ts +++ b/src/settlements/routes/admin-seller.route.ts @@ -86,7 +86,7 @@ const router = Router(); * business_license_url: * type: string * nullable: true - * example: https://s3.example.com/licenses/12.pdf + * description: 5분 만료 presigned GET URL * created_at: { type: string, format: date-time } * pagination: * type: object @@ -147,7 +147,7 @@ router.get('/pending', authenticateJwt, isAdmin, getPendingSellerList); * business_number: { type: string, nullable: true } * company_name: { type: string, nullable: true } * representative_name: { type: string, nullable: true } - * business_license_url: { type: string, nullable: true } + * business_license_url: { type: string, nullable: true, description: 5분 만료 presigned GET URL } * bank_code: { type: string } * account_number: { type: string } * account_holder: { type: string } @@ -484,7 +484,7 @@ router.get( * /api/admin/sellers/business/{userId}: * get: * summary: 사업자 판매자 상세 조회 - * description: 관리자가 승인 완료(`APPROVED`)된 사업자 판매자의 상세 정보를 조회합니다. 사업자등록증 URL과 사업자 정보를 포함합니다. + * description: 관리자가 승인 완료(`APPROVED`)된 사업자 판매자의 상세 정보를 조회합니다. 사업자등록증은 5분 만료 presigned GET URL로 제공합니다. * tags: [AdminSeller] * security: * - jwt: [] @@ -525,7 +525,7 @@ router.get( * business_license_url: * type: string * nullable: true - * example: https://s3.example.com/licenses/12.pdf + * description: 5분 만료 presigned GET URL * settlement_account: * type: object * properties: diff --git a/src/settlements/routes/payout-webhook.route.ts b/src/settlements/routes/payout-webhook.route.ts index b36b6ab..46e12d8 100644 --- a/src/settlements/routes/payout-webhook.route.ts +++ b/src/settlements/routes/payout-webhook.route.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import { handlePayoutWebhook } from '../controllers/payout-webhook.controller'; +import { authorizePayoutWebhook } from '../middlewares/payout-webhook-auth'; const router = Router(); @@ -13,7 +14,8 @@ const router = Router(); * group_key로 SettlementPayout을 찾아 status를 Succeed/Failed로 마감. * 멱등: 이미 Pending이 아니면 200 OK 응답만 (재전송 방지). * - * 운영 전 IP allowlist 또는 shared-secret 인증 별도 적용 권장 (Payple 명세 미명시). + * PAYPLE_PAYOUT_WEBHOOK_ALLOWED_IPS에 등록된 Payple 원본 IP/CIDR만 허용하며, + * payload의 cst_id와 billing_tran_id도 서버 값과 교차 검증. * tags: [Settlement] * requestBody: * required: true @@ -32,10 +34,14 @@ const router = Router(); * 200: * description: 멱등 처리 완료 * 400: - * description: group_key 누락 + * description: 필수값 누락 또는 지급 데이터 불일치 + * 403: + * description: 허용되지 않은 원본 IP 또는 가맹점 ID + * 503: + * description: 웹훅 IP allowlist 미설정/오설정 * 500: * description: 서버 오류 */ -router.post('/webhook', handlePayoutWebhook); +router.post('/webhook', authorizePayoutWebhook, handlePayoutWebhook); export default router; diff --git a/src/settlements/routes/settlement.route.ts b/src/settlements/routes/settlement.route.ts index ede687d..facd5ea 100644 --- a/src/settlements/routes/settlement.route.ts +++ b/src/settlements/routes/settlement.route.ts @@ -208,6 +208,7 @@ router.get("/accounts", authenticateJwt, ViewAccount); * description: | * 정보 변경 화면에서 기존 등록 데이터를 prefill 하기 위한 상세 조회 API. * 사업자는 추가 필드(businessType/businessNumber/companyName/representativeName/businessLicenseUrl/status) 포함. + * businessLicenseUrl은 인증된 본인에게만 발급되는 5분 만료 presigned GET URL입니다. * businessNumber는 마스킹(`123-45-****0`)되어 응답되며, 변경 시 사용자가 실제 값을 다시 입력해야 함. * birthDate는 본인 조회용 평문으로 응답 (정보 변경 시 계좌 재인증을 위해 prefill 필요). * tags: [Settlement] @@ -237,7 +238,7 @@ router.get("/accounts", authenticateJwt, ViewAccount); * businessNumber: { type: string, nullable: true, description: 마스킹된 사업자번호, example: "123-45-****0" } * representativeName: { type: string, nullable: true } * companyName: { type: string, nullable: true } - * businessLicenseUrl: { type: string, nullable: true } + * businessLicenseUrl: { type: string, nullable: true, description: 5분 만료 presigned GET URL } * statusCode: { type: integer, example: 200 } * 401: * description: 로그인 필요 @@ -324,7 +325,8 @@ router.post("/register/individual", authenticateJwt, registerIndividual); * description: | * 사업자등록증 파일(jpg/jpeg/png/pdf, 최대 20MB)을 업로드합니다. * 업로드 시 magic-byte로 실제 파일 형식을 검증하므로 확장자 위장 파일은 415로 거부됩니다. - * S3 객체 키는 예측 불가한 UUID로 생성됩니다. + * S3 객체는 사용자별 prefix와 예측 불가한 UUID로 생성되며 private으로 저장됩니다. + * 직접 URL은 반환하지 않고 등록 API에 전달할 객체 키만 반환합니다. * tags: [Settlement] * security: * - jwt: [] @@ -349,8 +351,8 @@ router.post("/register/individual", authenticateJwt, registerIndividual); * type: object * properties: * message: { type: string, example: 사업자등록증 업로드가 완료되었습니다. } - * fileKey: { type: string, description: S3 객체 키 (DB/Register API에 전달용), example: "business-licenses/0e5b9d7a-...-.pdf" } - * fileUrl: { type: string, description: 현재 형식의 S3 URL (버킷 private 전환 시 presigned로 교체 예정) } + * fileKey: { type: string, description: S3 객체 키 (DB/Register API에 전달용), example: "business-licenses/12/0e5b9d7a-...-.pdf" } + * fileUrl: { type: string, deprecated: true, description: 이전 클라이언트 호환용 alias. 실제 URL이 아니라 fileKey와 같은 객체 키 } * statusCode: { type: integer, example: 200 } * 400: * description: 파일 누락 @@ -379,7 +381,7 @@ router.post("/upload/business-license", authenticateJwt, uploadLicense); * - 개인 → 사업자 전환: 기존 INDIVIDUAL row 삭제 + 신규 BUSINESS row 생성 (PENDING) * - 사업자 → 사업자 정보 변경: 같은 row 덮어쓰기 + status=PENDING + is_active=false (승인 전까지 일시 비활성화) * - * 사업자등록증(businessLicenseUrl)은 사업자 → 사업자 변경 시에만 생략 가능 (기존 URL 유지). + * 사업자등록증 객체 키(businessLicenseKey)는 사업자 → 사업자 변경 시에만 생략 가능 (기존 파일 유지). * 최초 등록 / 개인→사업자 전환에는 필수. * tags: [Settlement] * security: @@ -401,9 +403,10 @@ router.post("/upload/business-license", authenticateJwt, uploadLicense); * companyName: * type: string * example: (주)프롬프트팩토리 - * businessLicenseUrl: + * businessLicenseKey: * type: string - * description: /upload/business-license 응답으로 받은 fileUrl. 사업자→사업자 변경 시에만 생략 가능 + * description: /upload/business-license 응답으로 받은 fileKey. 사업자→사업자 변경 시에만 생략 가능 + * example: business-licenses/12/0e5b9d7a-...-.pdf * isTermsAgreed: * type: boolean * example: true diff --git a/src/settlements/services/admin-seller.service.ts b/src/settlements/services/admin-seller.service.ts index 2e6a9cd..b8a51ab 100644 --- a/src/settlements/services/admin-seller.service.ts +++ b/src/settlements/services/admin-seller.service.ts @@ -11,6 +11,7 @@ import { SellerCancellationResult, SellerListResponse, } from '../dtos/admin-seller.dto'; +import { getBusinessLicenseDownloadUrl } from '../utils/business-license-storage'; const DEFAULT_PAGE = 1; const DEFAULT_LIMIT = 10; @@ -34,9 +35,9 @@ type SellerWithUser = Awaited< ReturnType >; -const toListItem = ( +const toListItem = async ( account: NonNullable, -): PendingSellerListItem => ({ +): Promise => ({ user: { user_id: account.user.user_id, name: account.user.name, @@ -47,14 +48,16 @@ const toListItem = ( business_number: account.business_number, company_name: account.company_name, representative_name: account.representative_name, - business_license_url: account.business_license_url, + business_license_url: await getBusinessLicenseDownloadUrl( + account.business_license_url, + ), created_at: account.created_at, }); -const toDetail = ( +const toDetail = async ( account: NonNullable, -): PendingSellerDetail => ({ - ...toListItem(account), +): Promise => ({ + ...(await toListItem(account)), bank_code: account.bank_code, account_number: account.account_number, account_holder: account.account_holder, @@ -74,7 +77,7 @@ export const listPendingBusinessSellers = async ( const totalPages = total === 0 ? 0 : Math.ceil(total / limit); return { - items: accounts.map(toListItem), + items: await Promise.all(accounts.map(toListItem)), pagination: { page, limit, @@ -99,7 +102,7 @@ export const getPendingBusinessSellerDetail = async ( ); } - return toDetail(account); + return await toDetail(account); }; export const approvePendingBusinessSeller = async (userId: number) => { @@ -286,7 +289,9 @@ export const getBusinessSellerDetail = async ( business_number: account.business_number, representative_name: account.representative_name, company_name: account.company_name, - business_license_url: account.business_license_url, + business_license_url: await getBusinessLicenseDownloadUrl( + account.business_license_url, + ), settlement_account: { bank_code: account.bank_code, account_number: account.account_number, diff --git a/src/settlements/services/settlement-payout.service.ts b/src/settlements/services/settlement-payout.service.ts index b8b6e1b..3cfa707 100644 --- a/src/settlements/services/settlement-payout.service.ts +++ b/src/settlements/services/settlement-payout.service.ts @@ -2,6 +2,7 @@ import redisClient from '../../config/redis'; import { k } from '../../utils/redis-key'; import { SettlementPayoutRepository } from '../repositories/settlement-payout.repository'; import { requestPayoutStandby, executePayout } from '../utils/payple-payout'; +import { isWebhookIpAllowlistConfigured } from '../utils/payout-webhook-security'; // 정산 사이클 cron — 매월 15일 KST 09:00. // 정책 (#491): @@ -136,6 +137,17 @@ export const runPayoutCycle = async (): Promise => { return; } + if ( + !isWebhookIpAllowlistConfigured( + process.env.PAYPLE_PAYOUT_WEBHOOK_ALLOWED_IPS, + ) + ) { + console.error( + '[payout-cycle] disabled: PAYPLE_PAYOUT_WEBHOOK_ALLOWED_IPS is missing or invalid', + ); + return; + } + const lockAcquired = await redisClient.set(k(CYCLE_LOCK_KEY), '1', { NX: true, EX: CYCLE_LOCK_TTL_SECONDS, diff --git a/src/settlements/services/settlement.account.service.ts b/src/settlements/services/settlement.account.service.ts index f190d7a..e2b2ca8 100644 --- a/src/settlements/services/settlement.account.service.ts +++ b/src/settlements/services/settlement.account.service.ts @@ -14,6 +14,7 @@ import { } from '../utils/payple'; import { issueRegisterToken } from '../utils/register-token'; import { recordSellerRegistrationConsent } from './seller-consent.service'; +import { getBusinessLicenseDownloadUrl } from '../utils/business-license-storage'; const ALLOWED_SELLER_TYPES: readonly SellerKind[] = ['INDIVIDUAL', 'BUSINESS']; const ALLOWED_BUSINESS_TYPES: readonly BusinessKind[] = ['PERSONAL', 'CORPORATE']; @@ -167,13 +168,16 @@ export const getSellerAccountDetail = async ( }; if (sellerType === 'BUSINESS') { + const businessLicenseUrl = await getBusinessLicenseDownloadUrl( + account.business_license_url, + ); return { ...base, businessType: (account.business_type ?? undefined) as BusinessKind | undefined, businessNumber: maskBusinessNumber(account.business_number) ?? undefined, representativeName: account.representative_name ?? undefined, companyName: account.company_name ?? undefined, - businessLicenseUrl: account.business_license_url ?? undefined, + businessLicenseUrl: businessLicenseUrl ?? undefined, name: account.representative_name ?? account.account_holder, }; } diff --git a/src/settlements/services/settlement.seller.service.ts b/src/settlements/services/settlement.seller.service.ts index f73ce50..23ca669 100644 --- a/src/settlements/services/settlement.seller.service.ts +++ b/src/settlements/services/settlement.seller.service.ts @@ -8,8 +8,10 @@ import { SettlementRepository } from '../repositories/settlement.repository'; import { consumeRegisterToken } from '../utils/register-token'; import { uploadFileToS3 } from '../utils/s3-client'; import { detectBusinessLicenseFileType, isClaimedMimeMatch } from '../utils/file-signature'; +import { normalizeBusinessLicenseObjectKey } from '../utils/business-license-storage'; export const uploadBusinessLicenseFile = async ( + userId: number, file: Express.Multer.File, ) => { const detected = detectBusinessLicenseFileType(file.buffer); @@ -29,14 +31,13 @@ export const uploadBusinessLicenseFile = async ( } try { - // userId 노출/예측 가능성 차단을 위해 uuid 사용 - const fileKey = `business-licenses/${uuidv4()}${detected.ext}`; - const fileUrl = await uploadFileToS3(fileKey, file.buffer, detected.mime); + // 사용자별 prefix로 다른 사용자의 키 재사용을 막고 파일명은 UUID로 예측을 어렵게 한다. + const fileKey = `business-licenses/${userId}/${uuidv4()}${detected.ext}`; + await uploadFileToS3(fileKey, file.buffer, detected.mime); return { message: '사업자등록증 업로드가 완료되었습니다.', fileKey, - fileUrl, }; } catch (error) { console.error('S3 업로드 에러'); @@ -111,6 +112,12 @@ export const registerBusinessSeller = async ( throw error; } + const submittedLicenseValue = + dto.businessLicenseKey ?? dto.businessLicenseUrl; + const businessLicenseKey = submittedLicenseValue + ? normalizeBusinessLicenseObjectKey(submittedLicenseValue, userId) + : undefined; + const payload = await consumeRegisterToken(dto.registerToken); if (payload.userId !== userId) { @@ -136,10 +143,10 @@ export const registerBusinessSeller = async ( throw error; } - // 최초 사업자 등록 시에는 사업자등록증 URL 필수. - // 사업자 → 사업자 정보변경 시에만 생략 허용 (기존 URL 유지). + // 최초 사업자 등록 시에는 사업자등록증 객체 키 필수. + // 사업자 → 사업자 정보변경 시에만 생략 허용 (기존 객체 키 유지). const isBusinessUpdate = !!existingAccount && existingAccount.seller_type === 'BUSINESS'; - if (!isBusinessUpdate && !dto.businessLicenseUrl) { + if (!isBusinessUpdate && !businessLicenseKey) { const error = new Error('사업자등록증 파일을 업로드해 주세요.'); error.name = 'ValidationError'; throw error; @@ -155,7 +162,7 @@ export const registerBusinessSeller = async ( businessNumber: payload.businessNumber, businessType: payload.businessType, companyName: dto.companyName, - businessLicenseUrl: dto.businessLicenseUrl!, + businessLicenseUrl: businessLicenseKey!, birthDate: payload.birthDate, billingTranId: payload.billingTranId, }); @@ -177,7 +184,7 @@ export const registerBusinessSeller = async ( businessNumber: payload.businessNumber, businessType: payload.businessType, companyName: dto.companyName, - businessLicenseUrl: dto.businessLicenseUrl!, + businessLicenseUrl: businessLicenseKey!, birthDate: payload.birthDate, billingTranId: payload.billingTranId, }); @@ -197,7 +204,7 @@ export const registerBusinessSeller = async ( businessNumber: payload.businessNumber, businessType: payload.businessType, companyName: dto.companyName, - businessLicenseUrl: dto.businessLicenseUrl, + businessLicenseUrl: businessLicenseKey, birthDate: payload.birthDate, billingTranId: payload.billingTranId, }); diff --git a/src/settlements/utils/business-license-storage.ts b/src/settlements/utils/business-license-storage.ts new file mode 100644 index 0000000..877500a --- /dev/null +++ b/src/settlements/utils/business-license-storage.ts @@ -0,0 +1,94 @@ +import { AppError } from '../../errors/AppError'; +import { getPresignedDownloadUrl } from './s3-presign'; + +const BUSINESS_LICENSE_PREFIX = 'business-licenses/'; +const ALLOWED_FILE_EXTENSION = /\.(?:jpe?g|png|pdf)$/i; + +const validationError = () => + new AppError( + '유효하지 않은 사업자등록증 파일 키입니다.', + 400, + 'ValidationError', + ); + +const objectKeyFromLegacyUrl = (value: string): string => { + let url: URL; + try { + url = new URL(value); + } catch { + throw validationError(); + } + + const bucket = process.env.S3_BUCKET; + const region = process.env.S3_REGION; + if (!bucket || !region || url.protocol !== 'https:') { + throw validationError(); + } + + const virtualHostedNames = new Set([ + `${bucket}.s3.${region}.amazonaws.com`, + `${bucket}.s3.amazonaws.com`, + ]); + + let pathname = url.pathname.replace(/^\/+/, ''); + if (!virtualHostedNames.has(url.hostname)) { + const pathStyleNames = new Set([ + `s3.${region}.amazonaws.com`, + 's3.amazonaws.com', + ]); + if (!pathStyleNames.has(url.hostname) || !pathname.startsWith(`${bucket}/`)) { + throw validationError(); + } + pathname = pathname.slice(bucket.length + 1); + } + + try { + return decodeURIComponent(pathname); + } catch { + throw validationError(); + } +}; + +/** + * 새 요청은 객체 키만 허용하고, 과거에 DB에 저장된 같은 버킷의 직접 URL은 + * 읽기 시 객체 키로 변환한다. 외부 URL을 다시 서명하는 일은 허용하지 않는다. + */ +export const normalizeBusinessLicenseObjectKey = ( + value: string, + ownerUserId?: number, +): string => { + if (typeof value !== 'string') throw validationError(); + + const trimmed = value.trim(); + const objectKey = /^https?:\/\//i.test(trimmed) + ? objectKeyFromLegacyUrl(trimmed) + : trimmed; + const suffix = objectKey.slice(BUSINESS_LICENSE_PREFIX.length); + + if ( + !objectKey.startsWith(BUSINESS_LICENSE_PREFIX) || + objectKey.length > 1024 || + !suffix || + suffix.includes('\\') || + suffix.includes('//') || + suffix.split('/').includes('..') || + /[\u0000-\u001f\u007f]/.test(suffix) || + !ALLOWED_FILE_EXTENSION.test(suffix) + ) { + throw validationError(); + } + + if (ownerUserId !== undefined && !suffix.startsWith(`${ownerUserId}/`)) { + throw validationError(); + } + + return objectKey; +}; + +export const getBusinessLicenseDownloadUrl = async ( + storedValue: string | null | undefined, +): Promise => { + if (!storedValue) return null; + const objectKey = normalizeBusinessLicenseObjectKey(storedValue); + return getPresignedDownloadUrl(objectKey); +}; diff --git a/src/settlements/utils/payout-webhook-security.ts b/src/settlements/utils/payout-webhook-security.ts new file mode 100644 index 0000000..166df0e --- /dev/null +++ b/src/settlements/utils/payout-webhook-security.ts @@ -0,0 +1,62 @@ +import { BlockList, isIP } from 'node:net'; + +type IpFamily = 'ipv4' | 'ipv6'; +export type WebhookIpCheck = 'allowed' | 'denied' | 'misconfigured'; + +const normalizeRemoteAddress = (value: string): string => + value.startsWith('::ffff:') ? value.slice('::ffff:'.length) : value; + +const buildAllowlist = (configuredAllowlist: string | undefined): BlockList | null => { + const entries = configuredAllowlist + ?.split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + + if (!entries?.length) return null; + + const allowlist = new BlockList(); + try { + for (const entry of entries) { + const [network, rawPrefix] = entry.split('/'); + const networkVersion = isIP(network); + if (!networkVersion) return null; + const networkFamily: IpFamily = networkVersion === 4 ? 'ipv4' : 'ipv6'; + + if (rawPrefix === undefined) { + allowlist.addAddress(network, networkFamily); + continue; + } + + const prefix = Number(rawPrefix); + const maxPrefix = networkVersion === 4 ? 32 : 128; + if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) { + return null; + } + allowlist.addSubnet(network, prefix, networkFamily); + } + } catch { + return null; + } + + return allowlist; +}; + +export const isWebhookIpAllowlistConfigured = ( + configuredAllowlist: string | undefined, +): boolean => buildAllowlist(configuredAllowlist) !== null; + +export const checkWebhookSourceIp = ( + remoteAddress: string | undefined, + configuredAllowlist: string | undefined, +): WebhookIpCheck => { + const allowlist = buildAllowlist(configuredAllowlist); + if (!allowlist) return 'misconfigured'; + if (!remoteAddress) return 'denied'; + + const address = normalizeRemoteAddress(remoteAddress); + const addressVersion = isIP(address); + if (!addressVersion) return 'denied'; + const addressFamily: IpFamily = addressVersion === 4 ? 'ipv4' : 'ipv6'; + + return allowlist.check(address, addressFamily) ? 'allowed' : 'denied'; +}; diff --git a/src/settlements/utils/s3-client.ts b/src/settlements/utils/s3-client.ts index 43861c1..26767de 100644 --- a/src/settlements/utils/s3-client.ts +++ b/src/settlements/utils/s3-client.ts @@ -12,15 +12,15 @@ export const uploadFileToS3 = async ( key: string, buffer: Buffer, contentType: string, -): Promise => { +): Promise => { const command = new PutObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key, Body: buffer, ContentType: contentType, + CacheControl: 'private, no-store', + ServerSideEncryption: 'AES256', }); await s3Client.send(command); - - return `https://${process.env.S3_BUCKET!}.s3.${process.env.S3_REGION}.amazonaws.com/${key}`; }; diff --git a/src/settlements/utils/s3-presign.ts b/src/settlements/utils/s3-presign.ts index a47cdab..8463554 100644 --- a/src/settlements/utils/s3-presign.ts +++ b/src/settlements/utils/s3-presign.ts @@ -1,5 +1,6 @@ import { GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { AppError } from '../../errors/AppError'; import { s3Client } from './s3-client'; const DEFAULT_TTL_SECONDS = 5 * 60; @@ -10,9 +11,19 @@ export const getPresignedDownloadUrl = async ( objectKey: string, expiresInSeconds: number = DEFAULT_TTL_SECONDS, ): Promise => { + const bucket = process.env.S3_BUCKET; + if (!bucket) { + throw new AppError( + 'S3_BUCKET 환경변수가 설정되지 않았습니다.', + 500, + 'ConfigError', + ); + } + const command = new GetObjectCommand({ - Bucket: process.env.S3_BUCKET!, + Bucket: bucket, Key: objectKey, + ResponseCacheControl: 'private, no-store', }); return await getSignedUrl(s3Client, command, { expiresIn: expiresInSeconds }); }; diff --git a/src/tips/controllers/tip.controllers.ts b/src/tips/controllers/tip.controllers.ts index 4e154f1..968c42a 100644 --- a/src/tips/controllers/tip.controllers.ts +++ b/src/tips/controllers/tip.controllers.ts @@ -14,6 +14,8 @@ interface RawPaginationQuery { size?: string; } +type FlatParamsRequest = Request>; + //비회원 이용 가능 - 인증 필요 X export const getTipList = async ( req: Request, @@ -35,7 +37,7 @@ export const getTipList = async ( }; // 팁 생성 - 관리자 인증 필요 -export const getTip = async (req: Request, res: Response) => { +export const getTip = async (req: FlatParamsRequest, res: Response) => { try { const result = await getTipService(req.params.tipId); return res.success({ @@ -52,7 +54,7 @@ export const getTip = async (req: Request, res: Response) => { }; // 팁 생성 - 관리자 인증 필요 -export const createTip = async (req: Request, res: Response) => { +export const createTip = async (req: FlatParamsRequest, res: Response) => { if (!req.user) { res.fail({ statusCode: 401, @@ -77,7 +79,7 @@ export const createTip = async (req: Request, res: Response) => { } }; -export const patchTip = async (req: Request, res: Response) => { +export const patchTip = async (req: FlatParamsRequest, res: Response) => { try { const result = await patchTipService(req.params.tipId, req.body); return res.success({ @@ -93,7 +95,7 @@ export const patchTip = async (req: Request, res: Response) => { } }; -export const deleteTip = async (req: Request, res: Response) => { +export const deleteTip = async (req: FlatParamsRequest, res: Response) => { try { const result = await deleteTipService(req.params.tipId); return res.success({ diff --git a/tests/business-license-storage.test.ts b/tests/business-license-storage.test.ts new file mode 100644 index 0000000..15c4667 --- /dev/null +++ b/tests/business-license-storage.test.ts @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { AppError } from '../src/errors/AppError'; +import { normalizeBusinessLicenseObjectKey } from '../src/settlements/utils/business-license-storage'; + +const withS3Config = (run: () => void) => { + const originalBucket = process.env.S3_BUCKET; + const originalRegion = process.env.S3_REGION; + process.env.S3_BUCKET = 'private-documents'; + process.env.S3_REGION = 'ap-northeast-2'; + try { + run(); + } finally { + if (originalBucket === undefined) delete process.env.S3_BUCKET; + else process.env.S3_BUCKET = originalBucket; + if (originalRegion === undefined) delete process.env.S3_REGION; + else process.env.S3_REGION = originalRegion; + } +}; + +test('사업자등록증은 요청 사용자 prefix의 객체 키만 등록할 수 있다', () => { + assert.equal( + normalizeBusinessLicenseObjectKey('business-licenses/12/license.pdf', 12), + 'business-licenses/12/license.pdf', + ); + assert.throws( + () => normalizeBusinessLicenseObjectKey('business-licenses/13/license.pdf', 12), + (error: unknown) => + error instanceof AppError && + error.statusCode === 400 && + error.error === 'ValidationError', + ); +}); + +test('과거 같은 버킷 직접 URL은 객체 키로 변환한다', () => { + withS3Config(() => { + assert.equal( + normalizeBusinessLicenseObjectKey( + 'https://private-documents.s3.ap-northeast-2.amazonaws.com/business-licenses/12/license.pdf', + ), + 'business-licenses/12/license.pdf', + ); + }); +}); + +test('외부 URL과 경로 이탈 객체 키는 거절한다', () => { + withS3Config(() => { + assert.throws(() => + normalizeBusinessLicenseObjectKey( + 'https://attacker.example/business-licenses/12/license.pdf', + ), + ); + assert.throws(() => + normalizeBusinessLicenseObjectKey('business-licenses/12/../13/license.pdf'), + ); + assert.throws(() => + normalizeBusinessLicenseObjectKey('public/license.pdf'), + ); + }); +}); diff --git a/tests/payout-webhook-auth.test.ts b/tests/payout-webhook-auth.test.ts new file mode 100644 index 0000000..66f9d44 --- /dev/null +++ b/tests/payout-webhook-auth.test.ts @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + checkWebhookSourceIp, + isWebhookIpAllowlistConfigured, +} from '../src/settlements/utils/payout-webhook-security'; + +test('지급 웹훅 원본 IP는 exact IP와 CIDR allowlist를 지원한다', () => { + const allowlist = '203.0.113.10, 198.51.100.0/24, 2001:db8::/32'; + + assert.equal(checkWebhookSourceIp('203.0.113.10', allowlist), 'allowed'); + assert.equal(checkWebhookSourceIp('198.51.100.77', allowlist), 'allowed'); + assert.equal(checkWebhookSourceIp('2001:db8::7', allowlist), 'allowed'); + assert.equal(checkWebhookSourceIp('192.0.2.1', allowlist), 'denied'); +}); + +test('IPv4-mapped IPv6 주소를 정규화한다', () => { + assert.equal( + checkWebhookSourceIp('::ffff:203.0.113.10', '203.0.113.10'), + 'allowed', + ); +}); + +test('allowlist 누락 또는 잘못된 항목은 안전하게 설정 오류로 처리한다', () => { + assert.equal(isWebhookIpAllowlistConfigured('203.0.113.0/24'), true); + assert.equal(isWebhookIpAllowlistConfigured(undefined), false); + assert.equal(checkWebhookSourceIp('203.0.113.10', undefined), 'misconfigured'); + assert.equal( + checkWebhookSourceIp('203.0.113.10', 'not-an-ip'), + 'misconfigured', + ); + assert.equal( + checkWebhookSourceIp('203.0.113.10', '203.0.113.0/99'), + 'misconfigured', + ); +});