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_SIGNATURE=374708bedd34ae70f814471ff24db7dedc4b9bee06a7e8ef9255a4f6c8bd9049 # 실제 키를 사용하세요
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

환경 변수 이름 불일치 확인 필요

src/utils/verify.util.ts에서 사용하는 환경 변수명은 SENTRY_CLIENT_SECRET인데, 여기서는 SLACK_SENTRY_SIGNATURE로 되어 있습니다. 환경 변수명이 일치하지 않으면 시그니처 검증이 실패할 수 있습니다.

다음 스크립트로 실제 사용되는 환경 변수명을 확인해보세요:


🏁 Script executed:

#!/bin/bash
# 환경 변수 사용 현황 확인
echo "=== SENTRY_CLIENT_SECRET 사용 현황 ==="
rg "SENTRY_CLIENT_SECRET" --type ts

echo "=== SLACK_SENTRY_SIGNATURE 사용 현황 ==="
rg "SLACK_SENTRY_SIGNATURE" --type ts

Length of output: 593


환경 변수명 통일 필요

src/utils/verify.util.ts와 관련 테스트에서는 SENTRY_CLIENT_SECRET을 사용하지만, .env.sample에는 SLACK_SENTRY_SIGNATURE만 정의되어 있어 시그니처 검증 시 값이 로드되지 않습니다. 아래와 같이 .env.sample의 변수를 수정해주세요.

  • 파일: .env.sample
    - SLACK_SENTRY_SIGNATURE=374708bedd34ae70f814471ff24db7dedc4b9bee06a7e8ef9255a4f6c8bd9049 # 실제 키를 사용하세요
    + SENTRY_CLIENT_SECRET=374708bedd34ae70f814471ff24db7dedc4b9bee06a7e8ef9255a4f6c8bd9049 # 실제 키를 사용하세요
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
SLACK_SENTRY_SIGNATURE=374708bedd34ae70f814471ff24db7dedc4b9bee06a7e8ef9255a4f6c8bd9049 # 실제 키를 사용하세요
SENTRY_CLIENT_SECRET=374708bedd34ae70f814471ff24db7dedc4b9bee06a7e8ef9255a4f6c8bd9049 # 실제 키를 사용하세요
🤖 Prompt for AI Agents
In the .env.sample file at line 26, the environment variable name
SLACK_SENTRY_SIGNATURE is inconsistent with the variable SENTRY_CLIENT_SECRET
used in src/utils/verify.util.ts and its tests. Rename SLACK_SENTRY_SIGNATURE to
SENTRY_CLIENT_SECRET in .env.sample to ensure the correct value is loaded during
signature verification.

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();
});
});
});
5 changes: 3 additions & 2 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 { verifySignature } from '@/utils/verify.util';

export class WebhookController {
private readonly STATUS_EMOJI = {
Expand All @@ -16,8 +17,8 @@ export class WebhookController {
next: NextFunction,
): Promise<void> => {
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;
Expand Down
32 changes: 32 additions & 0 deletions src/utils/verify.util.ts
Copy link
Member

Choose a reason for hiding this comment

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

공유드린대로 이 부분은 router 에서 미들웨어를 사용하는 것이 어떨까 해요!

Original file line number Diff line number Diff line change
@@ -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"];
}
Loading