Skip to content
3 changes: 2 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ POSTGRES_HOST=localhost
POSTGRES_PORT=5432

# ETC
SLACK_WEBHOOK_URL=https://hooks.slack.com/services
SLACK_WEBHOOK_URL=https://hooks.slack.com/services
SLACK_SENTRY_SECRET=374708bedd34ae70f814471ff24db7dedc4b9bee06a7e8ef9255a4f6c8bd9049 # 실제 키를 사용하세요
1 change: 1 addition & 0 deletions .github/workflows/test-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions src/controllers/__test__/webhook.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -18,6 +20,7 @@ describe('WebhookController', () => {
let mockResponse: Partial<Response>;
let nextFunction: jest.Mock;
let mockSendSlackMessage: jest.MockedFunction<typeof sendSlackMessage>;
let mockVerifySignature: jest.MockedFunction<typeof verifySignature>;

beforeEach(() => {
// WebhookController 인스턴스 생성
Expand All @@ -36,6 +39,10 @@ describe('WebhookController', () => {

nextFunction = jest.fn();
mockSendSlackMessage = sendSlackMessage as jest.MockedFunction<typeof sendSlackMessage>;
mockVerifySignature = verifySignature as jest.MockedFunction<typeof verifySignature>;

// 기본적으로 시그니처 검증이 성공하도록 설정
mockVerifySignature.mockReturnValue(true);
});

afterEach(() => {
Expand Down Expand Up @@ -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();
});
});
});
1 change: 0 additions & 1 deletion src/controllers/webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export class WebhookController {
next: NextFunction,
): Promise<void> => {
try {

if (req.body?.action !== "created") {
const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null);
res.status(400).json(response);
Expand Down
27 changes: 27 additions & 0 deletions src/middlewares/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
* 요청에서 토큰을 추출하는 함수
Expand Down Expand Up @@ -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("유효하지 않은 시그니처 헤더입니다.");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기 커스텀에러로 수정해주시면 좋을 것 같아요! errorHandling 미들웨어 보시면 일반 에러객체는 다 500으로 처리되어서요.
/exception에 커스텀 예외를 상속하는 새로운 시그니처 예외를 추가하시거나, 그냥 커스텀 예외 가져다 쓰시면 될 것 같습니다!!

}
next();
} catch (error) {
logger.error('시그니처 검증 중 오류가 발생하였습니다. : ', error);
next(error);
}

}

/**
* 사용자 인증을 위한 미들웨어 모음
* @property {Function} verify
*/
export const authMiddleware = {
verify: verifyBearerTokens(),
verifySignature,
};
3 changes: 2 additions & 1 deletion src/routes/webhook.router.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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;
Loading