diff --git a/.env.sample b/.env.sample index 1c5459c..b1e7591 100644 --- a/.env.sample +++ b/.env.sample @@ -22,4 +22,5 @@ POSTGRES_HOST=localhost POSTGRES_PORT=5432 # ETC -SLACK_WEBHOOK_URL=https://hooks.slack.com/services \ No newline at end of file +SLACK_WEBHOOK_URL=https://hooks.slack.com/services +SLACK_SENTRY_SECRET=374708bedd34ae70f814471ff24db7dedc4b9bee06a7e8ef9255a4f6c8bd9049 # 실제 키를 사용하세요 \ No newline at end of file diff --git a/.github/workflows/test-ci.yaml b/.github/workflows/test-ci.yaml index de1ba66..a1333b0 100644 --- a/.github/workflows/test-ci.yaml +++ b/.github/workflows/test-ci.yaml @@ -58,6 +58,7 @@ jobs: echo "POSTGRES_HOST=${{ secrets.POSTGRES_HOST }}" >> .env echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env echo "POSTGRES_PORT=${{ secrets.POSTGRES_PORT }}" >> .env + echo "SENTRY_CLIENT_SECRET=${{ secrets.SENTRY_CLIENT_SECRET }}" >> .env # AES 키들 추가 (테스트용 더미 키) echo "AES_KEY_0=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" >> .env echo "AES_KEY_1=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" >> .env diff --git a/src/controllers/__test__/webhook.controller.test.ts b/src/controllers/__test__/webhook.controller.test.ts index cec602a..e551c77 100644 --- a/src/controllers/__test__/webhook.controller.test.ts +++ b/src/controllers/__test__/webhook.controller.test.ts @@ -147,12 +147,13 @@ describe('WebhookController', () => { ); expect(mockResponse.status).toHaveBeenCalledWith(400); - expect(mockResponse.json).toHaveBeenCalledWith({ - success: true, - message: 'Sentry 웹훅 처리에 실패했습니다', - data: {}, - error: null - }); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Sentry 웹훅 처리에 실패했습니다', + statusCode: 400, + code: 'INVALID_SYNTAX' + }) + ); expect(nextFunction).not.toHaveBeenCalled(); }); @@ -166,12 +167,13 @@ describe('WebhookController', () => { ); expect(mockResponse.status).toHaveBeenCalledWith(400); - expect(mockResponse.json).toHaveBeenCalledWith({ - success: true, - message: 'Sentry 웹훅 처리에 실패했습니다', - data: {}, - error: null - }); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Sentry 웹훅 처리에 실패했습니다', + statusCode: 400, + code: 'INVALID_SYNTAX' + }) + ); }); it('action이 없는 경우 400 에러를 반환해야 한다', async () => { @@ -184,12 +186,13 @@ describe('WebhookController', () => { ); expect(mockResponse.status).toHaveBeenCalledWith(400); - expect(mockResponse.json).toHaveBeenCalledWith({ - success: true, - message: 'Sentry 웹훅 처리에 실패했습니다', - data: {}, - error: null - }); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Sentry 웹훅 처리에 실패했습니다', + statusCode: 400, + code: 'INVALID_SYNTAX' + }) + ); }); it('전혀 다른 형태의 객체인 경우 400 에러를 반환해야 한다', async () => { @@ -206,12 +209,13 @@ describe('WebhookController', () => { ); expect(mockResponse.status).toHaveBeenCalledWith(400); - expect(mockResponse.json).toHaveBeenCalledWith({ - success: true, - message: 'Sentry 웹훅 처리에 실패했습니다', - data: {}, - error: null - }); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Sentry 웹훅 처리에 실패했습니다', + statusCode: 400, + code: 'INVALID_SYNTAX' + }) + ); }); it('action은 created이지만 필수 필드가 없는 경우 에러를 전달해야 한다', async () => { @@ -232,7 +236,9 @@ describe('WebhookController', () => { expect(nextFunction).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Sentry 웹훅 데이터가 올바르지 않습니다' + message: 'Sentry 웹훅 처리에 실패했습니다', + statusCode: 400, + code: 'INVALID_SYNTAX' }) ); expect(mockResponse.json).not.toHaveBeenCalled(); diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index e76693f..5a67c66 100644 --- a/src/controllers/webhook.controller.ts +++ b/src/controllers/webhook.controller.ts @@ -2,6 +2,7 @@ import { NextFunction, Request, RequestHandler, Response } from 'express'; import { EmptyResponseDto, SentryWebhookData } from '@/types'; import logger from '@/configs/logger.config'; import { sendSlackMessage } from '@/modules/slack/slack.notifier'; +import { BadRequestError } from '@/exception'; export class WebhookController { private readonly STATUS_EMOJI = { @@ -16,9 +17,8 @@ export class WebhookController { next: NextFunction, ): Promise => { try { - if (req.body?.action !== "created") { - const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null); + const response = new BadRequestError('Sentry 웹훅 처리에 실패했습니다'); res.status(400).json(response); return; } @@ -39,7 +39,7 @@ export class WebhookController { private formatSentryMessage(sentryData: SentryWebhookData): string { const { data: { issue } } = sentryData; - if(!issue.status || !issue.title || !issue.culprit || !issue.id) throw new Error('Sentry 웹훅 데이터가 올바르지 않습니다'); + if(!issue.status || !issue.title || !issue.culprit || !issue.id) throw new BadRequestError('Sentry 웹훅 처리에 실패했습니다'); const { status, title: issueTitle, culprit, permalink, id } = issue; const statusEmoji = this.STATUS_EMOJI[status as keyof typeof this.STATUS_EMOJI]; diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 34f536d..d6e7069 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -2,8 +2,9 @@ import { NextFunction, Request, Response } from 'express'; import { isUUID } from 'class-validator'; import logger from '@/configs/logger.config'; import pool from '@/configs/db.config'; -import { DBError, InvalidTokenError } from '@/exception'; +import { CustomError, DBError, InvalidTokenError } from '@/exception'; import { VelogJWTPayload, User } from '@/types'; +import crypto from "crypto"; /** * 요청에서 토큰을 추출하는 함수 @@ -66,10 +67,46 @@ const verifyBearerTokens = () => { }; }; +/** + * Sentry 웹훅 요청의 시그니처 헤더를 검증합니다. + * HMAC SHA256과 Sentry의 Client Secret를 사용하여 요청 본문을 해시화하고, + * Sentry에서 제공하는 시그니처 헤더와 비교하여 요청의 무결성을 확인합니다. + */ +function verifySentrySignature() { + return (req: Request, res: Response, next: NextFunction) => { + try { + if (!process.env.SENTRY_CLIENT_SECRET) throw new Error("SENTRY_CLIENT_SECRET가 env에 없습니다"); + + const hmac = crypto.createHmac("sha256", process.env.SENTRY_CLIENT_SECRET); + + // Raw body 사용 - Express에서 파싱되기 전의 원본 데이터 필요 + // req.rawBody가 없다면 fallback으로 JSON.stringify 사용 (완벽하지 않음) + // @ts-expect-error - rawBody는 커스텀 미들웨어에서 추가되는 속성 + const bodyToVerify = req.rawBody || JSON.stringify(req.body); + const sentrySignature = req.headers["sentry-hook-signature"]; + + if (!bodyToVerify) throw new Error("요청 본문이 없습니다."); + if (!sentrySignature) throw new Error("시그니처 헤더가 없습니다."); + + hmac.update(bodyToVerify, "utf8"); + const digest = hmac.digest("hex"); + + if (digest !== sentrySignature) throw new CustomError("유효하지 않은 시그니처 헤더입니다.", "INVALID_SIGNATURE", 400); + + next(); + } catch (error) { + logger.error('시그니처 검증 중 오류가 발생하였습니다. : ', error); + next(error); + } + } +} + /** * 사용자 인증을 위한 미들웨어 모음 * @property {Function} verify + * * @property {Function} verifySignature */ export const authMiddleware = { verify: verifyBearerTokens(), + verifySignature: verifySentrySignature(), }; diff --git a/src/routes/webhook.router.ts b/src/routes/webhook.router.ts index 42ec9be..f48d7fa 100644 --- a/src/routes/webhook.router.ts +++ b/src/routes/webhook.router.ts @@ -1,5 +1,6 @@ import express, { Router } from 'express'; import { WebhookController } from '@/controllers/webhook.controller'; +import { authMiddleware } from '@/middlewares/auth.middleware'; const router: Router = express.Router(); @@ -47,6 +48,6 @@ const webhookController = new WebhookController(); * 500: * description: 서버 오류 */ -router.post('/webhook/sentry', webhookController.handleSentryWebhook); +router.post('/webhook/sentry', authMiddleware.verifySignature, webhookController.handleSentryWebhook); export default router; \ No newline at end of file