From 60c416c377210b0c8ebcd7c2cde12e3a48a94e2b Mon Sep 17 00:00:00 2001 From: six-standard Date: Fri, 4 Jul 2025 11:45:19 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor:=20=EC=8B=9C=EA=B7=B8=EB=8B=88?= =?UTF-8?q?=EC=B2=98=20=EC=9D=B8=EC=A6=9D=20=EA=B3=BC=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 3 ++- src/controllers/webhook.controller.ts | 5 +++-- src/utils/verify.util.ts | 32 +++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 src/utils/verify.util.ts diff --git a/.env.sample b/.env.sample index 1c5459c..597c4d8 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_SIGNATURE=374708bedd34ae70f814471ff24db7dedc4b9bee06a7e8ef9255a4f6c8bd9049 # 실제 키를 사용하세요 \ No newline at end of file diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index e76693f..8a82925 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 { verifySignature } from '@/utils/verify.util'; export class WebhookController { private readonly STATUS_EMOJI = { @@ -16,8 +17,8 @@ export class WebhookController { next: NextFunction, ): Promise => { try { - - if (req.body?.action !== "created") { + // 시그니처 검증 과정 추가 + if (req.body?.action !== "created" || !verifySignature(req)) { const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null); res.status(400).json(response); return; diff --git a/src/utils/verify.util.ts b/src/utils/verify.util.ts new file mode 100644 index 0000000..a6d226f --- /dev/null +++ b/src/utils/verify.util.ts @@ -0,0 +1,32 @@ +import crypto from "crypto" +import dotenv from "dotenv"; +import { Request } from "express"; + +dotenv.config(); + +/** + * Sentry 웹훅 요청의 시그니처 헤더를 검증합니다. + * + * HMAC SHA256과 Sentry의 Client Secret를 사용하여 요청 본문을 해시화하고, + * + * Sentry에서 제공하는 시그니처 헤더와 비교하여 요청의 무결성을 확인합니다. + * + * @param {Request} request - Express 요청 객체 + * @returns {boolean} 헤더가 유효하면 true, 그렇지 않으면 false + * + * @example + * ```typescript + * const isValid = verifySignature(req, process.env.SENTRY_WEBHOOK_SECRET); + * if (!isValid) { + * return res.status(401).json({ error: 'Invalid signature' }); + * } + * ``` + */ +export function verifySignature(request: Request) { + if(!process.env.SENTRY_CLIENT_SECRET) throw new Error("SENTRY_CLIENT_SECRET is not defined"); + const hmac = crypto.createHmac("sha256", process.env.SENTRY_CLIENT_SECRET); + hmac.update(JSON.stringify(request.body), "utf8"); + const digest = hmac.digest("hex"); + + return digest === request.headers["sentry-hook-signature"]; +} \ No newline at end of file From 0116921b67522b012e39dce5be9edfa0c3a94a24 Mon Sep 17 00:00:00 2001 From: six-standard Date: Fri, 4 Jul 2025 12:03:52 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feature:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/webhook.controller.test.ts | 111 ++++++++++++ src/utils/__test__/verify.util.test.ts | 158 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 src/utils/__test__/verify.util.test.ts diff --git a/src/controllers/__test__/webhook.controller.test.ts b/src/controllers/__test__/webhook.controller.test.ts index cec602a..1b2421c 100644 --- a/src/controllers/__test__/webhook.controller.test.ts +++ b/src/controllers/__test__/webhook.controller.test.ts @@ -2,9 +2,11 @@ import 'reflect-metadata'; import { Request, Response } from 'express'; import { WebhookController } from '@/controllers/webhook.controller'; import { sendSlackMessage } from '@/modules/slack/slack.notifier'; +import { verifySignature } from '@/utils/verify.util'; // Mock dependencies jest.mock('@/modules/slack/slack.notifier'); +jest.mock('@/utils/verify.util'); // logger 모킹 jest.mock('@/configs/logger.config', () => ({ @@ -18,6 +20,7 @@ describe('WebhookController', () => { let mockResponse: Partial; let nextFunction: jest.Mock; let mockSendSlackMessage: jest.MockedFunction; + let mockVerifySignature: jest.MockedFunction; beforeEach(() => { // WebhookController 인스턴스 생성 @@ -36,6 +39,10 @@ describe('WebhookController', () => { nextFunction = jest.fn(); mockSendSlackMessage = sendSlackMessage as jest.MockedFunction; + mockVerifySignature = verifySignature as jest.MockedFunction; + + // 기본적으로 시그니처 검증이 성공하도록 설정 + mockVerifySignature.mockReturnValue(true); }); afterEach(() => { @@ -308,4 +315,108 @@ describe('WebhookController', () => { expect(mockSendSlackMessage).toHaveBeenCalledWith(expectedMessage); }); }); + + describe('Signature Verification', () => { + const mockSentryData = { + action: 'created', + data: { + issue: { + id: 'test-issue-123', + title: '시그니처 테스트 오류', + culprit: 'TestFile.js:10', + status: 'unresolved', + count: "1", + userCount: 1, + firstSeen: '2024-01-01T12:00:00.000Z', + permalink: 'https://velog-dashboardv2.sentry.io/issues/test-issue-123/', + project: { + id: 'project-123', + name: 'Velog Dashboard', + slug: 'velog-dashboard' + } + } + } + }; + + it('유효한 시그니처로 웹훅 처리에 성공해야 한다', async () => { + mockRequest.body = mockSentryData; + mockRequest.headers = { + 'sentry-hook-signature': 'valid-signature' + }; + mockVerifySignature.mockReturnValue(true); + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockVerifySignature).toHaveBeenCalledWith(mockRequest); + expect(mockSendSlackMessage).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + }); + + it('잘못된 시그니처로 400 에러를 반환해야 한다', async () => { + mockRequest.body = mockSentryData; + mockRequest.headers = { + 'sentry-hook-signature': 'invalid-signature' + }; + mockVerifySignature.mockReturnValue(false); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockVerifySignature).toHaveBeenCalledWith(mockRequest); + expect(mockSendSlackMessage).not.toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: 'Sentry 웹훅 처리에 실패했습니다', + data: {}, + error: null + }); + }); + + it('시그니처 헤더가 누락된 경우 400 에러를 반환해야 한다', async () => { + mockRequest.body = mockSentryData; + mockRequest.headers = {}; // 시그니처 헤더 누락 + mockVerifySignature.mockReturnValue(false); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockVerifySignature).toHaveBeenCalledWith(mockRequest); + expect(mockSendSlackMessage).not.toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(400); + }); + + it('시그니처 검증 중 예외 발생 시 에러를 전달해야 한다', async () => { + mockRequest.body = mockSentryData; + mockRequest.headers = { + 'sentry-hook-signature': 'some-signature' + }; + const verificationError = new Error('SENTRY_CLIENT_SECRET is not defined'); + mockVerifySignature.mockImplementation(() => { + throw verificationError; + }); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockVerifySignature).toHaveBeenCalledWith(mockRequest); + expect(nextFunction).toHaveBeenCalledWith(verificationError); + expect(mockSendSlackMessage).not.toHaveBeenCalled(); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + }); }); \ No newline at end of file diff --git a/src/utils/__test__/verify.util.test.ts b/src/utils/__test__/verify.util.test.ts new file mode 100644 index 0000000..b71d7fb --- /dev/null +++ b/src/utils/__test__/verify.util.test.ts @@ -0,0 +1,158 @@ +import { Request } from 'express'; +import { verifySignature } from '../verify.util'; +import crypto from 'crypto'; + +// 환경 변수 모킹 +const mockEnv = { + SENTRY_CLIENT_SECRET: 'test-secret-key' +}; + +describe('verifySignature', () => { + let mockRequest: Partial; + let originalEnv: NodeJS.ProcessEnv; + + beforeAll(() => { + originalEnv = process.env; + }); + + beforeEach(() => { + process.env = { ...originalEnv, ...mockEnv }; + mockRequest = { + body: {}, + headers: {} + }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('성공 케이스', () => { + it('유효한 시그니처로 검증에 성공해야 한다', () => { + const testBody = { action: 'created', data: { issue: { id: 'test' } } }; + const bodyString = JSON.stringify(testBody); + const expectedSignature = crypto + .createHmac('sha256', mockEnv.SENTRY_CLIENT_SECRET) + .update(bodyString) + .digest('hex'); + + mockRequest.body = testBody; + mockRequest.headers = { + 'sentry-hook-signature': expectedSignature + }; + + const result = verifySignature(mockRequest as Request); + + expect(result).toBe(true); + }); + + it('복잡한 객체 body로 검증에 성공해야 한다', () => { + const testBody = { + action: 'created', + data: { + issue: { + id: 'complex-issue-123', + title: 'Complex Error with Special Characters: áéíóú', + culprit: 'components/User@Profile.tsx:42', + status: 'unresolved', + count: "15", + userCount: 8, + firstSeen: '2024-01-01T12:00:00.000Z' + } + } + }; + + const bodyString = JSON.stringify(testBody); + const expectedSignature = crypto + .createHmac('sha256', mockEnv.SENTRY_CLIENT_SECRET) + .update(bodyString) + .digest('hex'); + + mockRequest.body = testBody; + mockRequest.headers = { + 'sentry-hook-signature': expectedSignature + }; + + const result = verifySignature(mockRequest as Request); + + expect(result).toBe(true); + }); + }); + + describe('실패 케이스', () => { + it('잘못된 시그니처로 검증에 실패해야 한다', () => { + const testBody = { action: 'created', data: { issue: { id: 'test' } } }; + + mockRequest.body = testBody; + mockRequest.headers = { + 'sentry-hook-signature': 'invalid-signature' + }; + + const result = verifySignature(mockRequest as Request); + + expect(result).toBe(false); + }); + + it('시그니처 헤더가 누락된 경우 false를 반환해야 한다', () => { + const testBody = { action: 'created', data: { issue: { id: 'test' } } }; + + mockRequest.body = testBody; + mockRequest.headers = {}; // 시그니처 헤더 누락 + + const result = verifySignature(mockRequest as Request); + + expect(result).toBe(false); + }); + + it('빈 시그니처로 검증에 실패해야 한다', () => { + const testBody = { action: 'created', data: { issue: { id: 'test' } } }; + + mockRequest.body = testBody; + mockRequest.headers = { + 'sentry-hook-signature': '' + }; + + const result = verifySignature(mockRequest as Request); + + expect(result).toBe(false); + }); + + it('다른 secret으로 생성된 시그니처로 검증에 실패해야 한다', () => { + const testBody = { action: 'created', data: { issue: { id: 'test' } } }; + const bodyString = JSON.stringify(testBody); + const wrongSignature = crypto + .createHmac('sha256', 'wrong-secret') + .update(bodyString) + .digest('hex'); + + mockRequest.body = testBody; + mockRequest.headers = { + 'sentry-hook-signature': wrongSignature + }; + + const result = verifySignature(mockRequest as Request); + + expect(result).toBe(false); + }); + }); + + describe('에러 케이스', () => { + it('SENTRY_CLIENT_SECRET이 없는 경우 에러를 발생시켜야 한다', () => { + delete process.env.SENTRY_CLIENT_SECRET; + + const testBody = { action: 'created', data: { issue: { id: 'test' } } }; + mockRequest.body = testBody; + mockRequest.headers = { + 'sentry-hook-signature': 'some-signature' + }; + + expect(() => { + verifySignature(mockRequest as Request); + }).toThrow('SENTRY_CLIENT_SECRET is not defined'); + }); + }); +}); \ No newline at end of file From f3da727c3edf9592dd1db5582352a961c7df6b92 Mon Sep 17 00:00:00 2001 From: six-standard Date: Fri, 4 Jul 2025 12:06:24 +0900 Subject: [PATCH 03/10] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/__test__/verify.util.test.ts | 158 ------------------------- 1 file changed, 158 deletions(-) delete mode 100644 src/utils/__test__/verify.util.test.ts diff --git a/src/utils/__test__/verify.util.test.ts b/src/utils/__test__/verify.util.test.ts deleted file mode 100644 index b71d7fb..0000000 --- a/src/utils/__test__/verify.util.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { Request } from 'express'; -import { verifySignature } from '../verify.util'; -import crypto from 'crypto'; - -// 환경 변수 모킹 -const mockEnv = { - SENTRY_CLIENT_SECRET: 'test-secret-key' -}; - -describe('verifySignature', () => { - let mockRequest: Partial; - let originalEnv: NodeJS.ProcessEnv; - - beforeAll(() => { - originalEnv = process.env; - }); - - beforeEach(() => { - process.env = { ...originalEnv, ...mockEnv }; - mockRequest = { - body: {}, - headers: {} - }; - }); - - afterAll(() => { - process.env = originalEnv; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('성공 케이스', () => { - it('유효한 시그니처로 검증에 성공해야 한다', () => { - const testBody = { action: 'created', data: { issue: { id: 'test' } } }; - const bodyString = JSON.stringify(testBody); - const expectedSignature = crypto - .createHmac('sha256', mockEnv.SENTRY_CLIENT_SECRET) - .update(bodyString) - .digest('hex'); - - mockRequest.body = testBody; - mockRequest.headers = { - 'sentry-hook-signature': expectedSignature - }; - - const result = verifySignature(mockRequest as Request); - - expect(result).toBe(true); - }); - - it('복잡한 객체 body로 검증에 성공해야 한다', () => { - const testBody = { - action: 'created', - data: { - issue: { - id: 'complex-issue-123', - title: 'Complex Error with Special Characters: áéíóú', - culprit: 'components/User@Profile.tsx:42', - status: 'unresolved', - count: "15", - userCount: 8, - firstSeen: '2024-01-01T12:00:00.000Z' - } - } - }; - - const bodyString = JSON.stringify(testBody); - const expectedSignature = crypto - .createHmac('sha256', mockEnv.SENTRY_CLIENT_SECRET) - .update(bodyString) - .digest('hex'); - - mockRequest.body = testBody; - mockRequest.headers = { - 'sentry-hook-signature': expectedSignature - }; - - const result = verifySignature(mockRequest as Request); - - expect(result).toBe(true); - }); - }); - - describe('실패 케이스', () => { - it('잘못된 시그니처로 검증에 실패해야 한다', () => { - const testBody = { action: 'created', data: { issue: { id: 'test' } } }; - - mockRequest.body = testBody; - mockRequest.headers = { - 'sentry-hook-signature': 'invalid-signature' - }; - - const result = verifySignature(mockRequest as Request); - - expect(result).toBe(false); - }); - - it('시그니처 헤더가 누락된 경우 false를 반환해야 한다', () => { - const testBody = { action: 'created', data: { issue: { id: 'test' } } }; - - mockRequest.body = testBody; - mockRequest.headers = {}; // 시그니처 헤더 누락 - - const result = verifySignature(mockRequest as Request); - - expect(result).toBe(false); - }); - - it('빈 시그니처로 검증에 실패해야 한다', () => { - const testBody = { action: 'created', data: { issue: { id: 'test' } } }; - - mockRequest.body = testBody; - mockRequest.headers = { - 'sentry-hook-signature': '' - }; - - const result = verifySignature(mockRequest as Request); - - expect(result).toBe(false); - }); - - it('다른 secret으로 생성된 시그니처로 검증에 실패해야 한다', () => { - const testBody = { action: 'created', data: { issue: { id: 'test' } } }; - const bodyString = JSON.stringify(testBody); - const wrongSignature = crypto - .createHmac('sha256', 'wrong-secret') - .update(bodyString) - .digest('hex'); - - mockRequest.body = testBody; - mockRequest.headers = { - 'sentry-hook-signature': wrongSignature - }; - - const result = verifySignature(mockRequest as Request); - - expect(result).toBe(false); - }); - }); - - describe('에러 케이스', () => { - it('SENTRY_CLIENT_SECRET이 없는 경우 에러를 발생시켜야 한다', () => { - delete process.env.SENTRY_CLIENT_SECRET; - - const testBody = { action: 'created', data: { issue: { id: 'test' } } }; - mockRequest.body = testBody; - mockRequest.headers = { - 'sentry-hook-signature': 'some-signature' - }; - - expect(() => { - verifySignature(mockRequest as Request); - }).toThrow('SENTRY_CLIENT_SECRET is not defined'); - }); - }); -}); \ No newline at end of file From 2fe688c4dc9ede26e8254df03ca3daa3e8d4086e Mon Sep 17 00:00:00 2001 From: six-standard Date: Sun, 6 Jul 2025 18:27:33 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20CI=20env=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 2 +- .github/workflows/test-ci.yaml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.sample b/.env.sample index 597c4d8..b1e7591 100644 --- a/.env.sample +++ b/.env.sample @@ -23,4 +23,4 @@ POSTGRES_PORT=5432 # ETC SLACK_WEBHOOK_URL=https://hooks.slack.com/services -SLACK_SENTRY_SIGNATURE=374708bedd34ae70f814471ff24db7dedc4b9bee06a7e8ef9255a4f6c8bd9049 # 실제 키를 사용하세요 \ No newline at end of file +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 From f6619d94e05842399cf2fd4584a3ac46125c7611 Mon Sep 17 00:00:00 2001 From: six-standard Date: Sun, 6 Jul 2025 18:29:15 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20=EB=AF=B8=EB=93=A4=EC=9B=A8?= =?UTF-8?q?=EC=96=B4=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/webhook.controller.ts | 4 +--- src/middlewares/auth.middleware.ts | 27 ++++++++++++++++++++++ src/routes/webhook.router.ts | 3 ++- src/utils/verify.util.ts | 32 --------------------------- 4 files changed, 30 insertions(+), 36 deletions(-) delete mode 100644 src/utils/verify.util.ts diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index 8a82925..09e20d7 100644 --- a/src/controllers/webhook.controller.ts +++ b/src/controllers/webhook.controller.ts @@ -2,7 +2,6 @@ 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 { verifySignature } from '@/utils/verify.util'; export class WebhookController { private readonly STATUS_EMOJI = { @@ -17,8 +16,7 @@ export class WebhookController { next: NextFunction, ): Promise => { try { - // 시그니처 검증 과정 추가 - if (req.body?.action !== "created" || !verifySignature(req)) { + if (req.body?.action !== "created") { const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null); res.status(400).json(response); return; diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 34f536d..531ff52 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -4,6 +4,7 @@ import logger from '@/configs/logger.config'; import pool from '@/configs/db.config'; import { DBError, InvalidTokenError } from '@/exception'; import { VelogJWTPayload, User } from '@/types'; +import crypto from "crypto"; /** * 요청에서 토큰을 추출하는 함수 @@ -66,10 +67,36 @@ const verifyBearerTokens = () => { }; }; +/** + * Sentry 웹훅 요청의 시그니처 헤더를 검증합니다. + * HMAC SHA256과 Sentry의 Client Secret를 사용하여 요청 본문을 해시화하고, + * Sentry에서 제공하는 시그니처 헤더와 비교하여 요청의 무결성을 확인합니다. + * @param {Request} request - Express 요청 객체 + * @returns {boolean} 헤더가 유효하면 true, 그렇지 않으면 false + */ +function verifySignature(request: 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); + hmac.update(JSON.stringify(request.body), "utf8"); + const digest = hmac.digest("hex"); + + if(digest !== request.headers["sentry-hook-signature"]) { + throw new Error("유효하지 않은 시그니처 헤더입니다."); + } + next(); + } catch (error) { + logger.error('시그니처 검증 중 오류가 발생하였습니다. : ', error); + next(error); + } + +} + /** * 사용자 인증을 위한 미들웨어 모음 * @property {Function} verify */ export const authMiddleware = { verify: verifyBearerTokens(), + verifySignature, }; 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 diff --git a/src/utils/verify.util.ts b/src/utils/verify.util.ts deleted file mode 100644 index a6d226f..0000000 --- a/src/utils/verify.util.ts +++ /dev/null @@ -1,32 +0,0 @@ -import crypto from "crypto" -import dotenv from "dotenv"; -import { Request } from "express"; - -dotenv.config(); - -/** - * Sentry 웹훅 요청의 시그니처 헤더를 검증합니다. - * - * HMAC SHA256과 Sentry의 Client Secret를 사용하여 요청 본문을 해시화하고, - * - * Sentry에서 제공하는 시그니처 헤더와 비교하여 요청의 무결성을 확인합니다. - * - * @param {Request} request - Express 요청 객체 - * @returns {boolean} 헤더가 유효하면 true, 그렇지 않으면 false - * - * @example - * ```typescript - * const isValid = verifySignature(req, process.env.SENTRY_WEBHOOK_SECRET); - * if (!isValid) { - * return res.status(401).json({ error: 'Invalid signature' }); - * } - * ``` - */ -export function verifySignature(request: Request) { - if(!process.env.SENTRY_CLIENT_SECRET) throw new Error("SENTRY_CLIENT_SECRET is not defined"); - const hmac = crypto.createHmac("sha256", process.env.SENTRY_CLIENT_SECRET); - hmac.update(JSON.stringify(request.body), "utf8"); - const digest = hmac.digest("hex"); - - return digest === request.headers["sentry-hook-signature"]; -} \ No newline at end of file From 9b98d89583ca38b3614607c863d289992eca52ab Mon Sep 17 00:00:00 2001 From: six-standard Date: Sun, 6 Jul 2025 18:45:33 +0900 Subject: [PATCH 06/10] =?UTF-8?q?refactor:=20body=20=EA=B0=92=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EC=BD=94=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/middlewares/auth.middleware.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 531ff52..e96c348 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -77,19 +77,28 @@ const verifyBearerTokens = () => { function verifySignature(request: 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); - hmac.update(JSON.stringify(request.body), "utf8"); + + // Raw body 사용 - Express에서 파싱되기 전의 원본 데이터 필요 + // request.rawBody가 없다면 fallback으로 JSON.stringify 사용 (완벽하지 않음) + // @ts-expect-error - rawBody는 커스텀 미들웨어에서 추가되는 속성 + const bodyToVerify = request.rawBody || JSON.stringify(request.body); + const sentrySignature = request.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 !== request.headers["sentry-hook-signature"]) { - throw new Error("유효하지 않은 시그니처 헤더입니다."); - } + if(digest !== sentrySignature) throw new Error(`유효하지 않은 시그니처 헤더입니다.`); + next(); - } catch (error) { + } catch (error) { logger.error('시그니처 검증 중 오류가 발생하였습니다. : ', error); next(error); } - } /** From 4ee3f536716b6f3583c9d3c5dea3c945050ef9ed Mon Sep 17 00:00:00 2001 From: six-standard Date: Sun, 6 Jul 2025 19:45:24 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20=EC=A0=9C=EA=B1=B0=EB=90=9C?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20import=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/webhook.controller.test.ts | 111 ------------------ 1 file changed, 111 deletions(-) diff --git a/src/controllers/__test__/webhook.controller.test.ts b/src/controllers/__test__/webhook.controller.test.ts index 1b2421c..cec602a 100644 --- a/src/controllers/__test__/webhook.controller.test.ts +++ b/src/controllers/__test__/webhook.controller.test.ts @@ -2,11 +2,9 @@ import 'reflect-metadata'; import { Request, Response } from 'express'; import { WebhookController } from '@/controllers/webhook.controller'; import { sendSlackMessage } from '@/modules/slack/slack.notifier'; -import { verifySignature } from '@/utils/verify.util'; // Mock dependencies jest.mock('@/modules/slack/slack.notifier'); -jest.mock('@/utils/verify.util'); // logger 모킹 jest.mock('@/configs/logger.config', () => ({ @@ -20,7 +18,6 @@ describe('WebhookController', () => { let mockResponse: Partial; let nextFunction: jest.Mock; let mockSendSlackMessage: jest.MockedFunction; - let mockVerifySignature: jest.MockedFunction; beforeEach(() => { // WebhookController 인스턴스 생성 @@ -39,10 +36,6 @@ describe('WebhookController', () => { nextFunction = jest.fn(); mockSendSlackMessage = sendSlackMessage as jest.MockedFunction; - mockVerifySignature = verifySignature as jest.MockedFunction; - - // 기본적으로 시그니처 검증이 성공하도록 설정 - mockVerifySignature.mockReturnValue(true); }); afterEach(() => { @@ -315,108 +308,4 @@ describe('WebhookController', () => { expect(mockSendSlackMessage).toHaveBeenCalledWith(expectedMessage); }); }); - - describe('Signature Verification', () => { - const mockSentryData = { - action: 'created', - data: { - issue: { - id: 'test-issue-123', - title: '시그니처 테스트 오류', - culprit: 'TestFile.js:10', - status: 'unresolved', - count: "1", - userCount: 1, - firstSeen: '2024-01-01T12:00:00.000Z', - permalink: 'https://velog-dashboardv2.sentry.io/issues/test-issue-123/', - project: { - id: 'project-123', - name: 'Velog Dashboard', - slug: 'velog-dashboard' - } - } - } - }; - - it('유효한 시그니처로 웹훅 처리에 성공해야 한다', async () => { - mockRequest.body = mockSentryData; - mockRequest.headers = { - 'sentry-hook-signature': 'valid-signature' - }; - mockVerifySignature.mockReturnValue(true); - mockSendSlackMessage.mockResolvedValue(); - - await webhookController.handleSentryWebhook( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - expect(mockVerifySignature).toHaveBeenCalledWith(mockRequest); - expect(mockSendSlackMessage).toHaveBeenCalled(); - expect(mockResponse.status).toHaveBeenCalledWith(200); - }); - - it('잘못된 시그니처로 400 에러를 반환해야 한다', async () => { - mockRequest.body = mockSentryData; - mockRequest.headers = { - 'sentry-hook-signature': 'invalid-signature' - }; - mockVerifySignature.mockReturnValue(false); - - await webhookController.handleSentryWebhook( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - expect(mockVerifySignature).toHaveBeenCalledWith(mockRequest); - expect(mockSendSlackMessage).not.toHaveBeenCalled(); - expect(mockResponse.status).toHaveBeenCalledWith(400); - expect(mockResponse.json).toHaveBeenCalledWith({ - success: true, - message: 'Sentry 웹훅 처리에 실패했습니다', - data: {}, - error: null - }); - }); - - it('시그니처 헤더가 누락된 경우 400 에러를 반환해야 한다', async () => { - mockRequest.body = mockSentryData; - mockRequest.headers = {}; // 시그니처 헤더 누락 - mockVerifySignature.mockReturnValue(false); - - await webhookController.handleSentryWebhook( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - expect(mockVerifySignature).toHaveBeenCalledWith(mockRequest); - expect(mockSendSlackMessage).not.toHaveBeenCalled(); - expect(mockResponse.status).toHaveBeenCalledWith(400); - }); - - it('시그니처 검증 중 예외 발생 시 에러를 전달해야 한다', async () => { - mockRequest.body = mockSentryData; - mockRequest.headers = { - 'sentry-hook-signature': 'some-signature' - }; - const verificationError = new Error('SENTRY_CLIENT_SECRET is not defined'); - mockVerifySignature.mockImplementation(() => { - throw verificationError; - }); - - await webhookController.handleSentryWebhook( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - expect(mockVerifySignature).toHaveBeenCalledWith(mockRequest); - expect(nextFunction).toHaveBeenCalledWith(verificationError); - expect(mockSendSlackMessage).not.toHaveBeenCalled(); - expect(mockResponse.json).not.toHaveBeenCalled(); - }); - }); }); \ No newline at end of file From 3ce78d7d09dd8caf97e09ba6402f9857fe0203fa Mon Sep 17 00:00:00 2001 From: six-standard Date: Sun, 6 Jul 2025 19:45:39 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/webhook.controller.ts | 5 +++-- src/middlewares/auth.middleware.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index 09e20d7..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 = { @@ -17,7 +18,7 @@ export class WebhookController { ): 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; } @@ -38,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 e96c348..9e55b6a 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -2,7 +2,7 @@ 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"; @@ -92,7 +92,7 @@ function verifySignature(request: Request, res: Response, next: NextFunction) { hmac.update(bodyToVerify, "utf8"); const digest = hmac.digest("hex"); - if(digest !== sentrySignature) throw new Error(`유효하지 않은 시그니처 헤더입니다.`); + if(digest !== sentrySignature) throw new CustomError("유효하지 않은 시그니처 헤더입니다.", "INVALID_SIGNATURE", 400); next(); } catch (error) { From cc217da4c3d9a0c95e75030bfed3aa5d7eb3722f Mon Sep 17 00:00:00 2001 From: six-standard Date: Sun, 6 Jul 2025 19:57:46 +0900 Subject: [PATCH 09/10] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/webhook.controller.test.ts | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) 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(); From 3ba614988367a01e954114b0468539cfaea0a381 Mon Sep 17 00:00:00 2001 From: six-standard Date: Sun, 6 Jul 2025 22:08:39 +0900 Subject: [PATCH 10/10] =?UTF-8?q?refactor:=20=EB=A6=B0=ED=8C=85=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=EC=84=B1=20=EA=B0=95=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/middlewares/auth.middleware.ts | 55 +++++++++++++++--------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 9e55b6a..d6e7069 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -71,41 +71,42 @@ const verifyBearerTokens = () => { * Sentry 웹훅 요청의 시그니처 헤더를 검증합니다. * HMAC SHA256과 Sentry의 Client Secret를 사용하여 요청 본문을 해시화하고, * Sentry에서 제공하는 시그니처 헤더와 비교하여 요청의 무결성을 확인합니다. - * @param {Request} request - Express 요청 객체 - * @returns {boolean} 헤더가 유효하면 true, 그렇지 않으면 false */ -function verifySignature(request: 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에서 파싱되기 전의 원본 데이터 필요 - // request.rawBody가 없다면 fallback으로 JSON.stringify 사용 (완벽하지 않음) - // @ts-expect-error - rawBody는 커스텀 미들웨어에서 추가되는 속성 - const bodyToVerify = request.rawBody || JSON.stringify(request.body); - const sentrySignature = request.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); +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, + verifySignature: verifySentrySignature(), };