Skip to content

[TASK-178 / 25.06.27] Feature - 센트리 슬랙 웹훅 연동 #38

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jul 2, 2025
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/configs/db.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const poolConfig: pg.PoolConfig = {
max: 10, // 최대 연결 수
idleTimeoutMillis: 30000, // 연결 유휴 시간 (30초)
connectionTimeoutMillis: 5000, // 연결 시간 초과 (5초)
// ssl: false,
ssl: {
rejectUnauthorized: false,
},
Copy link
Member

Choose a reason for hiding this comment

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

주석은 왜 살려두며,, 왜 갑자기 false 를 굳이 추가하는지.. 기준님.. 기존 값을 그냥 그대로 둬 주세요..

Copy link
Member

Choose a reason for hiding this comment

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

지금 당장의 main 의 db.config.ts 를 봐주세여..!

Copy link
Member Author

@six-standard six-standard Jul 1, 2025

Choose a reason for hiding this comment

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

main에서 완전 제거됬으리라고 생각하지 못했네요.. F/U의 부재가 심각한 듯 싶습니다;;
말씀해두신대로 반영했습니다!

};

if (process.env.NODE_ENV === 'production') {
Expand Down
184 changes: 184 additions & 0 deletions src/controllers/__test__/webhook.controller.test.ts
Copy link
Contributor

Choose a reason for hiding this comment

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

Invalid Body값에 대한 실패 케이스 테스트가 없는 것 같네요!

Copy link
Member Author

Choose a reason for hiding this comment

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

반영했습니다!

Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import 'reflect-metadata';
import { Request, Response } from 'express';
import { WebhookController } from '@/controllers/webhook.controller';
import { sendSlackMessage } from '@/modules/slack/slack.notifier';

// Mock dependencies
jest.mock('@/modules/slack/slack.notifier');

// logger 모킹
jest.mock('@/configs/logger.config', () => ({
error: jest.fn(),
info: jest.fn(),
}));

describe('WebhookController', () => {
let webhookController: WebhookController;
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let nextFunction: jest.Mock;
let mockSendSlackMessage: jest.MockedFunction<typeof sendSlackMessage>;

beforeEach(() => {
// WebhookController 인스턴스 생성
webhookController = new WebhookController();

// Request, Response, NextFunction 모킹
mockRequest = {
body: {},
headers: {},
};

mockResponse = {
json: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
};

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

afterEach(() => {
jest.clearAllMocks();
});

describe('handleSentryWebhook', () => {
// 실제 동작에 필요한 필수 값만 사용하도록 타입 미적용
const mockSentryData = {
action: 'created',
data: {
issue: {
id: 'test-issue-123',
title: '테스트 오류입니다',
culprit: 'TestFile.js:10',
status: 'unresolved',
count: "5",
userCount: 3,
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('유효한 Sentry 웹훅 데이터로 처리에 성공해야 한다', async () => {
mockRequest.body = mockSentryData;
mockSendSlackMessage.mockResolvedValue();

await webhookController.handleSentryWebhook(
mockRequest as Request,
mockResponse as Response,
nextFunction
);

expect(mockSendSlackMessage).toHaveBeenCalledWith(
expect.stringContaining('🚨 *새로운 오류가 발생했습니다*')
);
expect(mockSendSlackMessage).toHaveBeenCalledWith(
expect.stringContaining('🔴 *제목:* 테스트 오류입니다')
);
expect(mockSendSlackMessage).toHaveBeenCalledWith(
expect.stringContaining('📍 *위치:* TestFile.js:10')
);
expect(mockSendSlackMessage).toHaveBeenCalledWith(
expect.stringContaining('🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/test-issue-123/')
);
expect(mockResponse.status).toHaveBeenCalledWith(200);
expect(mockResponse.json).toHaveBeenCalledWith({
success: true,
message: 'Sentry 웹훅 처리에 성공하였습니다.',
data: {},
error: null
});
});

it('permalink가 없는 경우 기본 URL 패턴을 사용해야 한다', async () => {
const dataWithoutPermalink = {
...mockSentryData,
data: {
...mockSentryData.data,
issue: {
...mockSentryData.data.issue,
permalink: undefined
}
}
};
mockRequest.body = dataWithoutPermalink;
mockSendSlackMessage.mockResolvedValue();

await webhookController.handleSentryWebhook(
mockRequest as Request,
mockResponse as Response,
nextFunction
);

expect(mockSendSlackMessage).toHaveBeenCalledWith(
expect.stringContaining('🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/test-issue-123/')
);
});

it('Slack 메시지 전송 실패 시 에러를 전달해야 한다', async () => {
mockRequest.body = mockSentryData;
const slackError = new Error('Slack 전송 실패');
mockSendSlackMessage.mockRejectedValue(slackError);

await webhookController.handleSentryWebhook(
mockRequest as Request,
mockResponse as Response,
nextFunction
);

expect(nextFunction).toHaveBeenCalledWith(slackError);
expect(mockResponse.json).not.toHaveBeenCalled();
});
});

describe('formatSentryMessage (private method integration test)', () => {
it('완전한 Sentry 데이터로 올바른 형식의 메시지를 생성해야 한다', async () => {
// 실제 동작에 필요한 필수 값만 사용하도록 타입 미적용
const completeData = {
action: 'created',
data: {
issue: {
id: 'issue-456',
title: 'TypeError: Cannot read property of undefined',
culprit: 'components/UserProfile.tsx:25',
status: 'unresolved',
count: "12",
userCount: 8,
firstSeen: '2024-01-15T14:30:00.000Z',
permalink: 'https://velog-dashboardv2.sentry.io/issues/issue-456/',
project: {
id: 'proj-789',
name: 'Velog Dashboard V2',
slug: 'velog-dashboard-v2'
}
}
}
};

mockRequest.body = completeData;
mockSendSlackMessage.mockResolvedValue();

await webhookController.handleSentryWebhook(
mockRequest as Request,
mockResponse as Response,
nextFunction
);

const expectedMessage = `🚨 *새로운 오류가 발생했습니다*

🔴 *제목:* TypeError: Cannot read property of undefined

📍 *위치:* components/UserProfile.tsx:25

🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/issue-456/`;

expect(mockSendSlackMessage).toHaveBeenCalledWith(expectedMessage);
});
});
});
55 changes: 55 additions & 0 deletions src/controllers/webhook.controller.ts
Copy link
Member

Choose a reason for hiding this comment

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

벨리데이션 미들웨어가 없어도 괜찮을 것 같은데 다만, 이를 아무나 호출할 수 있게 해도 괜찮을까 싶은 강력한 의문점이 있네요.
엔드포인트만 알아내면 무차별 공격이 가능한 상태라서요, 더욱이 컨트롤러 단에서 모든 로직을 처리하니까요 :)

어떻게 막을 수 있는 방법이 없을까요? 센트리에서 커스텀 헤더를 강제 추가해서 해당 헤더가 있는 경우만 OK 한다던지
또는 rate-limit 를 걸던지 등의 방식이요

Copy link
Member Author

Choose a reason for hiding this comment

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

저도 이 부분은 고민해보고 있는데.. Hook 쪽에서 강제로 어떤 헤더를 전송하게 하는 방법은 찾아보기가 어렵네요;;
그나마 rate-limit이 제일 현실적인 것 같은데, 이 부분을 서버에서 처리해줘야 하는건지, 아니면 로드밸런서 단에서 처리해줘야 하는건지 애매합니다
(적어도 현재 서버에 express-load-limitter같은 제한 관련 라이브러리가 설치되어있는 것 같지는 않네요)

Copy link
Member

Choose a reason for hiding this comment

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

LB 에서 처리하면 모든 라우팅에 제한이 됩니다, 하물며 저희 배치쪽에서도 쓰로풋이 제한이 되어버리죠. 더욱이 추후 서버 추가하는 순간 LB 에서 완벽한 보틀넥 지옥이라서요. L4 면 모를까, L7 에서는 주로 하지 못하는 방법이긴 해요.

공식홈페이지를 찾아보니까 "Sentry webhooks support various functionalities and are made up of four integral HTTP headers described below" - https://docs.sentry.io/organization/integrations/integration-platform/webhooks/

라고 이미 Sentry-Hook-Signature 헤더 값을 준다고 하네요 ㅎㅎ 기준님ㅋㅋ 찾은거.. 믿어도 괜찮은거죠..? 이걸 활용해 보는 것은 어떨까요?

Copy link
Member Author

Choose a reason for hiding this comment

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

Sentry-Hook-Signature값이 계속 바뀌는 값이다 보니 놓친 것 같은데, 공식 문서를 제대로 읽어보지 못한 것 같네요;;
지적 감사합니다! 한 번 적용해볼게요

Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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';

export class WebhookController {
private readonly STATUS_EMOJI = {
'unresolved': '🔴',
'resolved': '✅',
'ignored': '🔇',
'archived': '📦',
} as const;

handleSentryWebhook: RequestHandler = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
if (!req.body || typeof req.body !== 'object' || req.body.action !== "created") {
Copy link
Contributor

Choose a reason for hiding this comment

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

여기는

if (req.body?.action !== 'created')

정도로 축약할 수 있을 것 같은데 어떠신가요?

제안 드렸던 크레덴셜 검증이 사실 베스트이긴 한데, action이라는 필드가 있는지, 그게 created인지 정도만 해도 센트리인지 충분히 확인 될 것 같아요! 이게 저희 디비를 조작하거나 그런게 아니다보니 저는 이정도도 충분해보입니다!! 👍

Copy link
Contributor

Choose a reason for hiding this comment

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

덧붙이자면 제가 알기로 라우터에서 받는 request 객체는 무조건 body 필드가 있을거예요. Express에서 자동으로 Request 객체를 만들어서요!

Copy link
Member Author

Choose a reason for hiding this comment

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

말씀해주신대로 반영했습니다!

res.status(400).json(new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null));
}

const sentryData: SentryWebhookData = req.body;

const slackMessage = this.formatSentryMessage(sentryData);
await sendSlackMessage(slackMessage);

const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 성공하였습니다.', {}, null);
res.status(200).json(response);
} catch (error) {
logger.error('Sentry 웹훅 처리 실패:', error);
next(error);
}
};

private formatSentryMessage(sentryData: SentryWebhookData): string {
const { data: { issue } } = sentryData;

if(!issue.status || !issue.title || !issue.culprit || !issue.id) throw new Error('Sentry 웹훅 데이터가 올바르지 않습니다');
Copy link
Contributor

@ooheunda ooheunda Jul 1, 2025

Choose a reason for hiding this comment

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

요건 위 19번째줄이랑 같은 400 예외처리 같은데, 통합하면 좋을 것 같긴 합니다~!
그런데 지금 코드도 괜찮아요! 그리고 사실 통합한다면 완전히 DTO 검증의 느낌이라서요, 또 DTO 형태로 빼고 싶어질 것 같아요ㅋㅋ
제가 생각하는 베스트는 그런데, 계속 말씀드렸다시피 슬랙 웹훅에서 굳이? 싶긴 해요. 기준님 편하신대로 하시면 될 것 같아요.

Copy link
Member Author

Choose a reason for hiding this comment

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

저는 개인적으로 통합하는것보다 뺴는게 맞다고 생각하긴 합니다!
함수별로 어느 정도 역할의 분리가 필요하다고 생각하기도 했고, 결정적으로 코드가 너무 길어질 것 같거든요;;


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

// URL 생성 - permalink가 있으면 사용, 없으면 실제 프로젝트 URL 패턴으로 생성
const detailUrl = permalink || `https://velog-dashboardv2.sentry.io/issues/${id}/`;

let message = `🚨 *새로운 오류가 발생하였습니다*\n\n`;
message += `${statusEmoji} *제목:* ${issueTitle}\n\n`;
message += `📍 *위치:* ${culprit}\n\n`;
message += `🔗 *상세 보기:* ${detailUrl}`;

return message;
}
}
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import PostRouter from './post.router';
import NotiRouter from './noti.router';
import LeaderboardRouter from './leaderboard.router';
import TotalStatsRouter from './totalStats.router';
import WebhookRouter from './webhook.router';

const router: Router = express.Router();

Expand All @@ -16,5 +17,6 @@ router.use('/', PostRouter);
router.use('/', NotiRouter);
router.use('/', LeaderboardRouter);
router.use('/', TotalStatsRouter);
router.use('/', WebhookRouter);

export default router;
52 changes: 52 additions & 0 deletions src/routes/webhook.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import express, { Router } from 'express';
import { WebhookController } from '@/controllers/webhook.controller';

const router: Router = express.Router();

// 컨트롤러 인스턴스 생성
const webhookController = new WebhookController();

/**
* @swagger
* /webhook/sentry:
* post:
* summary: Sentry webhook 처리
* description: Sentry에서 전송되는 webhook 이벤트를 처리하고 Slack으로 알림을 전송합니다.
* tags: [Webhook]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* action:
* type: string
* description: Sentry 액션 타입
* enum: [created, resolved, unresolved, ignored]
* data:
* type: object
* properties:
* issue:
* type: object
* description: Sentry 이슈 정보
* actor:
* type: object
* description: 액션을 수행한 사용자 정보
* responses:
* 200:
* description: Webhook 처리 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: "Webhook 처리 완료"
* 500:
* description: 서버 오류
*/
router.post('/webhook/sentry', webhookController.handleSentryWebhook);
Copy link
Contributor

@coderabbitai coderabbitai bot Jun 27, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

🧩 Verification correct

웹훅 엔드포인트 보안 강화 필요

현재 구현에는 웹훅 요청의 진위를 확인하는 보안 검증이 없습니다. Sentry 웹훅은 일반적으로 서명 검증이나 시크릿 키 검증을 통해 요청의 유효성을 확인해야 합니다.

다음 스크립트로 프로젝트에서 웹훅 보안 관련 미들웨어나 설정이 있는지 확인해주세요:

Sentry 웹훅 서명 검증에 대한 최신 문서도 확인해주세요:


🏁 Script executed:

#!/bin/bash
# 웹훅 보안 관련 코드 검색
echo "웹훅 시크릿이나 서명 검증 관련 코드:"
rg -i "webhook.*secret|signature|verify" --type ts
echo "\n환경 변수에서 Sentry 관련 설정:"
rg -i "sentry.*secret|sentry.*key" --include="*.env*" .
echo "\n미들웨어에서 인증 관련 코드:"
fd "middleware" --type f --exec grep -l "auth\|verify\|secret" {} \;

Length of output: 2721


🌐 Web query:

Sentry webhook signature verification security best practices

💡 Result:

Verifying Sentry webhook signatures is crucial to ensure the authenticity and integrity of the payloads your server receives. Here are some best practices for Sentry webhook signature verification security:

  1. Use the Provided Secret: Sentry provides a secret key to sign the webhook payloads. Always use this secret to verify the signature rather than relying on IP whitelisting or other less secure methods.

  2. Verify HMAC Signature:

    • Sentry signs webhook payloads with an HMAC-SHA256 signature.
    • Compute the HMAC of the raw request body using the shared secret and compare it in a timing-attack resistant manner against the signature provided in the request header.
  3. Check Timestamp to Prevent Replay Attacks:

    • Webhooks typically include a timestamp.
    • Verify that the timestamp is within an acceptable window (e.g., 5 minutes) of your current server time.
    • Reject requests with old or future timestamps to prevent replay attacks.
  4. Use Constant-Time Comparison:

    • When comparing the computed HMAC and the signature sent by Sentry, use a constant-time comparison function to avoid timing attacks.
  5. Validate Payload Format:

    • Ensure that the payload format matches what you expect before parsing or processing it.
    • Reject webhook requests that have malformed payloads.
  6. Logging and Monitoring:

    • Log failed verification attempts for audit purposes.
    • Monitor unusual activity patterns that may indicate attempted attacks.
  7. Reject Requests Without Signature:

    • Do not process webhook requests that do not include the signature header.
  8. Use HTTPS:

    • Always use HTTPS endpoints for webhooks to ensure the payload and signature are transmitted securely.

If you'd like, I can provide example code snippets for verifying Sentry webhook signatures in common programming languages. Would you like that?


웹훅 엔드포인트 보안 강화 필요

현재 구현에는 웹훅 요청의 진위를 확인하는 보안 검증이 없습니다. Sentry 웹훅은 일반적으로 서명 검증이나 시크릿 키 검증을 통해 요청의 유효성을 확인해야 합니다.

다음 스크립트로 프로젝트에서 웹훅 보안 관련 미들웨어나 설정이 있는지 확인해주세요:

#!/bin/bash
# 웹훅 보안 관련 코드 검색
echo "웹훅 시크릿이나 서명 검증 관련 코드:"
rg -i "webhook.*secret|signature|verify" --type ts
echo "\n환경 변수에서 Sentry 관련 설정:"
rg -i "sentry.*secret|sentry.*key" --include="*.env*" .
echo "\n미들웨어에서 인증 관련 코드:"
fd "middleware" --type f --exec grep -l "auth\|verify\|secret" {} \;

Sentry 웹훅 서명 검증에 대한 최신 문서도 확인해주세요:

Sentry webhook signature verification security best practices
🤖 Prompt for AI Agents
In src/routes/webhook.router.ts at line 50, the Sentry webhook endpoint lacks
security verification to confirm the authenticity of incoming requests. To fix
this, implement middleware or logic that verifies the webhook signature or
secret key according to Sentry's recommended security best practices. This
typically involves extracting the signature from request headers, computing a
hash using a shared secret, and comparing it to the signature to validate the
request before passing it to the controller.

Copy link
Contributor

@ooheunda ooheunda Jun 28, 2025

Choose a reason for hiding this comment

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

https://docs.sentry.io/organization/integrations/integration-platform/webhooks/#sentry-hook-signature

+) 머져 그냥 넣어놓은건데 코드래빗이 엄청나게 뚱뚱한 댓글을 남겼네요...
이왕에 더 여담을 붙이자면, 저는 이전에 구매 관련 웹훅 API 개발할 때 이런 크레덴셜 검증을 중요하게 생각했는데, 이건 단순 알림웹훅이라 불필요하게 복잡해지려나 싶기도 하네요!!

Copy link
Contributor

Choose a reason for hiding this comment

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

🧩 Analysis chain

@ooheunda 제공해주신 Sentry 공식 문서를 확인해보겠습니다. Sentry의 웹훅 서명 검증 구현 방법에 대한 구체적인 정보를 찾아보겠습니다.

Sentry 웹훅 서명 검증을 위한 미들웨어 구현 예시를 제공하겠습니다:

// src/middlewares/webhook-verification.middleware.ts
import { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import { logger } from '@/utils/logger';

export const verifySentrySignature = (req: Request, res: Response, next: NextFunction) => {
  const signature = req.headers['sentry-hook-signature'] as string;
  const timestamp = req.headers['sentry-hook-timestamp'] as string;
  const secret = process.env.SENTRY_WEBHOOK_SECRET;

  if (!signature || !timestamp || !secret) {
    logger.warn('Missing required webhook verification headers or secret');
    return res.status(401).json({ error: 'Unauthorized' });
  }

  // 타임스탬프 검증 (5분 이내)
  const currentTime = Math.floor(Date.now() / 1000);
  const webhookTime = parseInt(timestamp);
  if (Math.abs(currentTime - webhookTime) > 300) {
    logger.warn('Webhook timestamp is too old');
    return res.status(401).json({ error: 'Request too old' });
  }

  // 서명 검증
  const payload = JSON.stringify(req.body);
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}${payload}`)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
    logger.warn('Invalid webhook signature');
    return res.status(401).json({ error: 'Invalid signature' });
  }

  next();
};

그리고 라우터에 이 미들웨어를 적용:

// src/routes/webhook.router.ts (수정된 부분)
import { verifySentrySignature } from '@/middlewares/webhook-verification.middleware';

// 웹훅 서명 검증 미들웨어 추가
router.post('/webhook/sentry', verifySentrySignature, webhookController.handleSentryWebhook);

환경 변수 설정도 필요합니다:

SENTRY_WEBHOOK_SECRET=your_sentry_webhook_secret_here

🏁 Script executed:

#!/bin/bash
# 현재 프로젝트에서 crypto 관련 라이브러리 사용 확인
echo "package.json에서 crypto 관련 의존성 확인:"
cat package.json | jq '.dependencies, .devDependencies' | grep -i crypto

echo "\n현재 미들웨어 구조 확인:"
ls -la src/middlewares/

echo "\n환경 변수 설정 파일 확인:"
fd "\.env" . --type f

Length of output: 682


🌐 Web query:

site:docs.sentry.io webhook signature verification implementation guide

💡 Result:

To implement webhook signature verification for Sentry, you can follow these steps:

  1. Retrieve the Secret Key: In your Sentry integration settings, locate the "Signing Secret" associated with your webhook. This secret is used to generate and verify the signature.

  2. Extract the Signature from the Header: When Sentry sends a webhook request, it includes a header named Sentry-Hook-Signature. This header contains the signature that you need to verify.

  3. Generate the Expected Signature: Using the same secret key, generate a hash of the request payload. In Python, you can use the hmac and hashlib libraries to do this:

    import hmac
    import hashlib
    
    def verify_signature(request, secret):
        signature = request.headers.get('Sentry-Hook-Signature')
        if not signature:
            return False
    
        # Generate the expected signature
        expected_signature = hmac.new(
            secret.encode(),
            msg=request.get_data(),
            digestmod=hashlib.sha256
        ).hexdigest()
    
        # Compare the signatures
        return hmac.compare_digest(signature, expected_signature)

    This function retrieves the signature from the request header, generates the expected signature using the secret key and the request payload, and compares the two signatures to verify authenticity.

For more detailed information, refer to Sentry's documentation on webhooks: (docs.sentry.io)

Citations:


@ooheunda 감사합니다! Sentry 공식 문서를 참조하여 정확한 구현 방법을 제공하겠습니다.

검색 결과를 바탕으로 Sentry 웹훅 서명 검증을 위한 미들웨어를 구현해보겠습니다:

// src/middlewares/webhook-signature.middleware.ts
import { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import { logger } from '@/utils/logger';

export const verifySentryWebhookSignature = (
  req: Request,
  res: Response,
  next: NextFunction,
): void => {
  try {
    const signature = req.headers['sentry-hook-signature'] as string;
    const secret = process.env.SENTRY_WEBHOOK_SECRET;

    if (!signature) {
      logger.warn('Missing Sentry webhook signature header');
      res.status(401).json({ error: 'Missing signature header' });
      return;
    }

    if (!secret) {
      logger.error('SENTRY_WEBHOOK_SECRET environment variable not set');
      res.status(500).json({ error: 'Server configuration error' });
      return;
    }

    // 요청 body를 raw string으로 사용 (JSON.stringify 사용하지 않음)
    const payload = JSON.stringify(req.body);
    
    // HMAC-SHA256으로 예상되는 서명 생성
    const expectedSignature = crypto
      .createHmac('sha256', secret)
      .update(payload)
      .digest('hex');

    // 타이밍 공격 방지를 위한 안전한 비교
    if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
      logger.warn('Invalid Sentry webhook signature');
      res.status(401).json({ error: 'Invalid signature' });
      return;
    }

    logger.info('Sentry webhook signature verified successfully');
    next();
  } catch (error) {
    logger.error('Error verifying Sentry webhook signature:', error);
    res.status(500).json({ error: 'Signature verification failed' });
  }
};

그리고 라우터에 이 미들웨어를 적용:

// src/routes/webhook.router.ts (수정)
import express, { Router } from 'express';
import { WebhookController } from '@/controllers/webhook.controller';
import { verifySentryWebhookSignature } from '@/middlewares/webhook-signature.middleware';

const router: Router = express.Router();

// 컨트롤러 인스턴스 생성
const webhookController = new WebhookController();

/**
 * @swagger
 * /webhook/sentry:
 *   post:
 *     summary: Sentry webhook 처리
 *     description: Sentry에서 전송되는 webhook 이벤트를 처리하고 Slack으로 알림을 전송합니다.
 *     tags: [Webhook]
 *     security:
 *       - SentryWebhookSignature: []
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             type: object
 *             properties:
 *               action:
 *                 type: string
 *                 description: Sentry 액션 타입
 *                 enum: [created, resolved, unresolved, ignored]
 *               data:
 *                 type: object
 *                 properties:
 *                   issue:
 *                     type: object
 *                     description: Sentry 이슈 정보
 *               actor:
 *                 type: object
 *                 description: 액션을 수행한 사용자 정보
 *     responses:
 *       200:
 *         description: Webhook 처리 성공
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 message:
 *                   type: string
 *                   example: "Webhook 처리 완료"
 *       401:
 *         description: 서명 검증 실패
 *       500:
 *         description: 서버 오류
 */
router.post('/webhook/sentry', verifySentryWebhookSignature, webhookController.handleSentryWebhook);

export default router;

환경 변수 설정 (.env 파일에 추가):

# Sentry 웹훅 서명 검증을 위한 시크릿 키
SENTRY_WEBHOOK_SECRET=your_sentry_webhook_signing_secret_here

추가 설정 필요사항:

  1. Sentry 프로젝트에서 웹훅 설정 시 "Signing Secret"을 생성하고 위 환경 변수에 설정
  2. body parser 설정 확인 - 웹훅 서명 검증을 위해 raw body에 접근할 수 있어야 함

이제 웹훅 엔드포인트가 Sentry의 서명 검증을 통과한 요청만 처리하게 됩니다.


🧬 Code Graph Analysis Results

Possibly relevant snippets from src/controllers/webhook.controller.ts (lines 6-64)

export class WebhookController {
  private readonly STATUS_EMOJI = {
    'unresolved': '🔴',
    'resolved': '✅',
    'ignored': '🔇',
    'archived': '📦',
  } as const;

  private readonly ACTION_TEXT = {
    'created': '새로운 오류가 발생했습니다',
    'resolved': '오류가 해결되었습니다',
    'unresolved': '오류가 다시 발생했습니다',
    'ignored': '오류가 무시되었습니다',
    'assigned': '오류가 할당되었습니다',
  } as const;

  handleSentryWebhook: RequestHandler = async (
    req: Request,
    res: Response,
    next: NextFunction,
  ): Promise<void> => {
    try {
      const sentryData: SentryWebhookData = req.body;
      const slackMessage = this.formatSentryMessage(sentryData);
      await sendSlackMessage(slackMessage);

      const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 성공하였습니다.', {}, null);
      res.status(200).json(response);
    } catch (error) {
      logger.error('Sentry 웹훅 처리 실패:', error);
      next(error);
    }
  };

  private formatSentryMessage(sentryData: SentryWebhookData): string {
    const { action, data } = sentryData || {};
    const issue = data?.issue || {};

    // 알 수 없는 액션에 대한 기본값 처리
    const actionText = this.ACTION_TEXT[action as keyof typeof this.ACTION_TEXT] || `오류 이벤트: ${action}`;
    
    // 알 수 없는 상태에 대한 기본값 처리
    const statusEmoji = this.STATUS_EMOJI[issue.status as keyof typeof this.STATUS_EMOJI] || '❓';
    
    const issueTitle = issue.title || '제목 없음';
    const culprit = issue.culprit || '위치 정보 없음';
    const permalink = issue.permalink;

    // URL 생성 - permalink가 있으면 사용, 없으면 실제 프로젝트 URL 패턴으로 생성
    const detailUrl = permalink || `https://velog-dashboardv2.sentry.io/issues/${issue.id || 'unknown'}/`;

    let message = `🚨 *${actionText}*\n\n`;
    message += `${statusEmoji} *제목:* ${issueTitle}\n\n`;
    message += `📍 *위치:* ${culprit}\n\n`;
    message += `🔗 *상세 보기:* ${detailUrl}`;

    return message;
  }
}

Summary:

  • This class WebhookController contains the method handleSentryWebhook which processes incoming Sentry webhook requests.
  • It extracts the webhook payload from req.body and formats a Slack message using formatSentryMessage.
  • The formatted message includes action text, status emoji, issue title, culprit, and a detail URL.
  • The method sends the Slack message asynchronously and returns a JSON response on success.
  • Errors during processing are logged and passed to the next middleware.
  • The code does not show any handling or verification of the Sentry webhook signature as described in the Sentry documentation for webhook security.

🧠 Learnings used
Learnt from: HA0N1
PR: Check-Data-Out/velog-dashboard-v2-api#5
File: src/middlewares/auth.middleware.ts:116-117
Timestamp: 2024-12-04T13:26:58.075Z
Learning: 'velog-dashboard-v2-api' 코드베이스에서는 `src/types/express.d.ts` 파일에서 Express의 `Request` 인터페이스를 확장하여 `user`와 `tokens` 속성을 추가하였습니다.

Learnt from: HA0N1
PR: Check-Data-Out/velog-dashboard-v2-api#5
File: src/middlewares/error-handling.middleware.ts:1-2
Timestamp: 2024-12-04T14:05:58.537Z
Learning: `src/middlewares/error-handling.middleware.ts` 파일의 에러 핸들링 미들웨어에서 `NextFunction`을 사용하지 않으며, `err`은 커스텀 에러로 사용되므로 `NextFunction`과 `ErrorRequestHandler`를 임포트할 필요가 없습니다.

Learnt from: HA0N1
PR: Check-Data-Out/velog-dashboard-v2-api#6
File: src/controllers/user.controller.ts:11-12
Timestamp: 2024-12-06T14:29:50.385Z
Learning: TypeScript Express 프로젝트에서, `Express.Request` 인터페이스는 전역으로 확장되어 `user`와 `tokens` 프로퍼티를 포함합니다. `user` 프로퍼티는 `VelogUserLoginResponse | VelogUserVerifyResponse` 타입이고, `tokens`는 `accessToken`과 `refreshToken`을 가진 객체입니다.

Learnt from: HA0N1
PR: Check-Data-Out/velog-dashboard-v2-api#5
File: src/types/dto/user-with-token.dto.ts:3-26
Timestamp: 2024-12-04T13:28:34.692Z
Learning: In `src/types/dto/user-with-token.dto.ts`, since the project is using Express instead of NestJS, `nestjs/swagger` cannot be used. Documentation will be implemented later using an Express-specific Swagger library.


export default router;
11 changes: 11 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,16 @@ export type { TotalStatsItem } from '@/types/dto/responses/totalStatsResponse.ty
export { GetTotalStatsQueryDto } from '@/types/dto/requests/getTotalStatsQuery.type';
export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse.type';

// Sentry 관련
export type {
SentryIssueStatus,
SentryAction,
} from '@/types/models/Sentry.type';
export type {
SentryProject,
SentryIssue,
SentryWebhookData,
} from '@/types/models/Sentry.type';

// Common
export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type';
Loading
Loading