Skip to content

Commit bf1fb01

Browse files
authored
Merge pull request #39 from Check-Data-Out/feature/sentry-slack-notification
2 parents 3a5f6e9 + 3ba6149 commit bf1fb01

File tree

6 files changed

+77
-31
lines changed

6 files changed

+77
-31
lines changed

.env.sample

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ POSTGRES_HOST=localhost
2222
POSTGRES_PORT=5432
2323

2424
# ETC
25-
SLACK_WEBHOOK_URL=https://hooks.slack.com/services
25+
SLACK_WEBHOOK_URL=https://hooks.slack.com/services
26+
SLACK_SENTRY_SECRET=374708bedd34ae70f814471ff24db7dedc4b9bee06a7e8ef9255a4f6c8bd9049 # 실제 키를 사용하세요

.github/workflows/test-ci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ jobs:
5858
echo "POSTGRES_HOST=${{ secrets.POSTGRES_HOST }}" >> .env
5959
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
6060
echo "POSTGRES_PORT=${{ secrets.POSTGRES_PORT }}" >> .env
61+
echo "SENTRY_CLIENT_SECRET=${{ secrets.SENTRY_CLIENT_SECRET }}" >> .env
6162
# AES 키들 추가 (테스트용 더미 키)
6263
echo "AES_KEY_0=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" >> .env
6364
echo "AES_KEY_1=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" >> .env

src/controllers/__test__/webhook.controller.test.ts

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,13 @@ describe('WebhookController', () => {
147147
);
148148

149149
expect(mockResponse.status).toHaveBeenCalledWith(400);
150-
expect(mockResponse.json).toHaveBeenCalledWith({
151-
success: true,
152-
message: 'Sentry 웹훅 처리에 실패했습니다',
153-
data: {},
154-
error: null
155-
});
150+
expect(mockResponse.json).toHaveBeenCalledWith(
151+
expect.objectContaining({
152+
message: 'Sentry 웹훅 처리에 실패했습니다',
153+
statusCode: 400,
154+
code: 'INVALID_SYNTAX'
155+
})
156+
);
156157
expect(nextFunction).not.toHaveBeenCalled();
157158
});
158159

@@ -166,12 +167,13 @@ describe('WebhookController', () => {
166167
);
167168

168169
expect(mockResponse.status).toHaveBeenCalledWith(400);
169-
expect(mockResponse.json).toHaveBeenCalledWith({
170-
success: true,
171-
message: 'Sentry 웹훅 처리에 실패했습니다',
172-
data: {},
173-
error: null
174-
});
170+
expect(mockResponse.json).toHaveBeenCalledWith(
171+
expect.objectContaining({
172+
message: 'Sentry 웹훅 처리에 실패했습니다',
173+
statusCode: 400,
174+
code: 'INVALID_SYNTAX'
175+
})
176+
);
175177
});
176178

177179
it('action이 없는 경우 400 에러를 반환해야 한다', async () => {
@@ -184,12 +186,13 @@ describe('WebhookController', () => {
184186
);
185187

186188
expect(mockResponse.status).toHaveBeenCalledWith(400);
187-
expect(mockResponse.json).toHaveBeenCalledWith({
188-
success: true,
189-
message: 'Sentry 웹훅 처리에 실패했습니다',
190-
data: {},
191-
error: null
192-
});
189+
expect(mockResponse.json).toHaveBeenCalledWith(
190+
expect.objectContaining({
191+
message: 'Sentry 웹훅 처리에 실패했습니다',
192+
statusCode: 400,
193+
code: 'INVALID_SYNTAX'
194+
})
195+
);
193196
});
194197

195198
it('전혀 다른 형태의 객체인 경우 400 에러를 반환해야 한다', async () => {
@@ -206,12 +209,13 @@ describe('WebhookController', () => {
206209
);
207210

208211
expect(mockResponse.status).toHaveBeenCalledWith(400);
209-
expect(mockResponse.json).toHaveBeenCalledWith({
210-
success: true,
211-
message: 'Sentry 웹훅 처리에 실패했습니다',
212-
data: {},
213-
error: null
214-
});
212+
expect(mockResponse.json).toHaveBeenCalledWith(
213+
expect.objectContaining({
214+
message: 'Sentry 웹훅 처리에 실패했습니다',
215+
statusCode: 400,
216+
code: 'INVALID_SYNTAX'
217+
})
218+
);
215219
});
216220

217221
it('action은 created이지만 필수 필드가 없는 경우 에러를 전달해야 한다', async () => {
@@ -232,7 +236,9 @@ describe('WebhookController', () => {
232236

233237
expect(nextFunction).toHaveBeenCalledWith(
234238
expect.objectContaining({
235-
message: 'Sentry 웹훅 데이터가 올바르지 않습니다'
239+
message: 'Sentry 웹훅 처리에 실패했습니다',
240+
statusCode: 400,
241+
code: 'INVALID_SYNTAX'
236242
})
237243
);
238244
expect(mockResponse.json).not.toHaveBeenCalled();

src/controllers/webhook.controller.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextFunction, Request, RequestHandler, Response } from 'express';
22
import { EmptyResponseDto, SentryWebhookData } from '@/types';
33
import logger from '@/configs/logger.config';
44
import { sendSlackMessage } from '@/modules/slack/slack.notifier';
5+
import { BadRequestError } from '@/exception';
56

67
export class WebhookController {
78
private readonly STATUS_EMOJI = {
@@ -16,9 +17,8 @@ export class WebhookController {
1617
next: NextFunction,
1718
): Promise<void> => {
1819
try {
19-
2020
if (req.body?.action !== "created") {
21-
const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null);
21+
const response = new BadRequestError('Sentry 웹훅 처리에 실패했습니다');
2222
res.status(400).json(response);
2323
return;
2424
}
@@ -39,7 +39,7 @@ export class WebhookController {
3939
private formatSentryMessage(sentryData: SentryWebhookData): string {
4040
const { data: { issue } } = sentryData;
4141

42-
if(!issue.status || !issue.title || !issue.culprit || !issue.id) throw new Error('Sentry 웹훅 데이터가 올바르지 않습니다');
42+
if(!issue.status || !issue.title || !issue.culprit || !issue.id) throw new BadRequestError('Sentry 웹훅 처리에 실패했습니다');
4343

4444
const { status, title: issueTitle, culprit, permalink, id } = issue;
4545
const statusEmoji = this.STATUS_EMOJI[status as keyof typeof this.STATUS_EMOJI];

src/middlewares/auth.middleware.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { NextFunction, Request, Response } from 'express';
22
import { isUUID } from 'class-validator';
33
import logger from '@/configs/logger.config';
44
import pool from '@/configs/db.config';
5-
import { DBError, InvalidTokenError } from '@/exception';
5+
import { CustomError, DBError, InvalidTokenError } from '@/exception';
66
import { VelogJWTPayload, User } from '@/types';
7+
import crypto from "crypto";
78

89
/**
910
* 요청에서 토큰을 추출하는 함수
@@ -66,10 +67,46 @@ const verifyBearerTokens = () => {
6667
};
6768
};
6869

70+
/**
71+
* Sentry 웹훅 요청의 시그니처 헤더를 검증합니다.
72+
* HMAC SHA256과 Sentry의 Client Secret를 사용하여 요청 본문을 해시화하고,
73+
* Sentry에서 제공하는 시그니처 헤더와 비교하여 요청의 무결성을 확인합니다.
74+
*/
75+
function verifySentrySignature() {
76+
return (req: Request, res: Response, next: NextFunction) => {
77+
try {
78+
if (!process.env.SENTRY_CLIENT_SECRET) throw new Error("SENTRY_CLIENT_SECRET가 env에 없습니다");
79+
80+
const hmac = crypto.createHmac("sha256", process.env.SENTRY_CLIENT_SECRET);
81+
82+
// Raw body 사용 - Express에서 파싱되기 전의 원본 데이터 필요
83+
// req.rawBody가 없다면 fallback으로 JSON.stringify 사용 (완벽하지 않음)
84+
// @ts-expect-error - rawBody는 커스텀 미들웨어에서 추가되는 속성
85+
const bodyToVerify = req.rawBody || JSON.stringify(req.body);
86+
const sentrySignature = req.headers["sentry-hook-signature"];
87+
88+
if (!bodyToVerify) throw new Error("요청 본문이 없습니다.");
89+
if (!sentrySignature) throw new Error("시그니처 헤더가 없습니다.");
90+
91+
hmac.update(bodyToVerify, "utf8");
92+
const digest = hmac.digest("hex");
93+
94+
if (digest !== sentrySignature) throw new CustomError("유효하지 않은 시그니처 헤더입니다.", "INVALID_SIGNATURE", 400);
95+
96+
next();
97+
} catch (error) {
98+
logger.error('시그니처 검증 중 오류가 발생하였습니다. : ', error);
99+
next(error);
100+
}
101+
}
102+
}
103+
69104
/**
70105
* 사용자 인증을 위한 미들웨어 모음
71106
* @property {Function} verify
107+
* * @property {Function} verifySignature
72108
*/
73109
export const authMiddleware = {
74110
verify: verifyBearerTokens(),
111+
verifySignature: verifySentrySignature(),
75112
};

src/routes/webhook.router.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import express, { Router } from 'express';
22
import { WebhookController } from '@/controllers/webhook.controller';
3+
import { authMiddleware } from '@/middlewares/auth.middleware';
34

45
const router: Router = express.Router();
56

@@ -47,6 +48,6 @@ const webhookController = new WebhookController();
4748
* 500:
4849
* description: 서버 오류
4950
*/
50-
router.post('/webhook/sentry', webhookController.handleSentryWebhook);
51+
router.post('/webhook/sentry', authMiddleware.verifySignature, webhookController.handleSentryWebhook);
5152

5253
export default router;

0 commit comments

Comments
 (0)