Skip to content
Merged
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
56 changes: 31 additions & 25 deletions src/controllers/__test__/webhook.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions src/controllers/webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -16,9 +17,8 @@ export class WebhookController {
next: NextFunction,
): Promise<void> => {
try {

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

/**
* 요청에서 토큰을 추출하는 함수
Expand Down Expand Up @@ -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(),
};
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