diff --git a/.env.example b/.env.example index 44c9f6b..7b31756 100644 --- a/.env.example +++ b/.env.example @@ -9,4 +9,7 @@ OPENAI_MODEL=gpt-4o-mini # 应用配置 PORT=3000 +# 建议使用以下命令生成一个安全的随机字符串作为webhook密钥: +# 在Linux/Mac终端: openssl rand -hex 32 +# 或者在Node.js中: require('crypto').randomBytes(32).toString('hex') WEBHOOK_SECRET=your_webhook_secret diff --git a/README.md b/README.md index 722cc58..6b59b9f 100644 --- a/README.md +++ b/README.md @@ -71,17 +71,28 @@ **Pull Request审查webhook**: - URL: `http://your-server:3000/webhook/gitea/pull_request` - 内容类型: `application/json` - - 秘钥: 设置为与WEBHOOK_SECRET相同的值 + - 秘钥: 设置为与`WEBHOOK_SECRET`环境变量相同的值 - 触发事件: 选择"Pull Request" **提交状态审查webhook**: - URL: `http://your-server:3000/webhook/gitea/status` - 内容类型: `application/json` - - 秘钥: 设置为与WEBHOOK_SECRET相同的值 + - 秘钥: 设置为与`WEBHOOK_SECRET`环境变量相同的值 - 触发事件: 选择"Status" > 注意: 老端点 `/webhook/gitea` 仍然支持Pull Request审查,但仅作向后兼容使用。 +### Webhook签名验证 + +为确保请求安全,系统使用Gitea的Webhook签名验证机制: + +1. 设置环境变量`WEBHOOK_SECRET`为一个安全的随机字符串 +2. 在Gitea的Webhook配置中,使用相同的字符串作为"秘钥" +3. 每次请求时,系统会验证请求头中的`X-Gitea-Signature` +4. 如果签名验证失败,请求会被拒绝处理 + +验证方法使用SHA-256哈希算法,在处理高负载的情况下这能防止恶意请求并保证请求来源的真实性。 + ## 功能说明 ### PR代码审查 diff --git a/package.json b/package.json index 2154873..7b96f5a 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,10 @@ { - "name": "ai-review", + "name": "gitea-ai-reviewer", "version": "1.0.0", "description": "AI-driven code review for Gitea", - "packageManager": "yarn@4.6.0", + "engines": { + "bun": ">=1.2.5" + }, "dependencies": { "@hono/zod-validator": "^0.4.3", "axios": "^1.8.3", diff --git a/src/controllers/review.ts b/src/controllers/review.ts index 9cacd76..a22259e 100644 --- a/src/controllers/review.ts +++ b/src/controllers/review.ts @@ -1,8 +1,8 @@ import { Context } from 'hono'; import { giteaService, PullRequestFile, PullRequestDetails } from '../services/gitea'; import { aiReviewService } from '../services/ai-review'; -// import config from '../config'; -// import * as crypto from 'crypto'; +import config from '../config'; +import * as crypto from 'crypto'; import { logger } from '../utils/logger'; // 判断是否为开发环境 @@ -11,32 +11,41 @@ const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV; /** * 验证Webhook请求签名 */ -// function verifyWebhookSignature(body: string, signature: string): boolean { -// // 开发环境下跳过签名验证 -// if (isDev && !signature) { -// logger.warn('开发环境: 跳过Webhook签名验证'); -// return true; -// } +function verifyWebhookSignature(body: string, signature: string): boolean { + // 开发环境下跳过签名验证 + if (isDev && !signature) { + logger.warn('开发环境: 跳过Webhook签名验证'); + return true; + } -// if (!config.app.webhookSecret) return false; + if (!config.app.webhookSecret) { + logger.warn('未配置Webhook密钥,跳过签名验证'); + return false; + } -// const hmac = crypto.createHmac('sha256', config.app.webhookSecret); -// hmac.update(body); -// const calculatedSignature = `sha256=${hmac.digest('hex')}`; + // Gitea使用SHA-256哈希算法 + const hmac = crypto.createHmac('sha256', config.app.webhookSecret); + hmac.update(body); + const calculatedSignature = hmac.digest('hex'); -// // 如果签名不存在,直接返回false -// if (!signature) return false; + // 如果签名不存在,直接返回false + if (!signature) { + logger.warn('请求中无签名头'); + return false; + } -// try { -// return crypto.timingSafeEqual( -// Buffer.from(calculatedSignature), -// Buffer.from(signature) -// ); -// } catch (error) { -// logger.error('签名验证失败', error); -// return false; -// } -// } + // Gitea的签名没有前缀,直接比较 + try { + // 使用timingSafeEqual进行常量时间比较,防止时序攻击 + return crypto.timingSafeEqual( + Buffer.from(calculatedSignature), + Buffer.from(signature) + ); + } catch (error) { + logger.error('签名验证失败', error); + return false; + } +} /** * 处理Pull Request事件 @@ -44,13 +53,13 @@ const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV; export async function handlePullRequestEvent(c: Context): Promise { try { // 验证Webhook签名 - // const signature = c.req.header('X-Gitea-Signature') || ''; + const signature = c.req.header('X-Gitea-Signature') || ''; const rawBody = await c.req.text(); - // if (!verifyWebhookSignature(rawBody, signature)) { - // logger.error('Webhook签名验证失败'); - // return c.json({ error: 'Webhook签名验证失败' }, 401); - // } + if (!verifyWebhookSignature(rawBody, signature)) { + logger.error('Webhook签名验证失败'); + return c.json({ error: 'Webhook签名验证失败' }, 401); + } // 解析请求体 const body = JSON.parse(rawBody); @@ -99,7 +108,15 @@ export async function handlePullRequestEvent(c: Context): Promise { */ export async function handleCommitStatusEvent(c: Context): Promise { try { + // 验证Webhook签名 + const signature = c.req.header('X-Gitea-Signature') || ''; const rawBody = await c.req.text(); + + if (!verifyWebhookSignature(rawBody, signature)) { + logger.error('Webhook签名验证失败'); + return c.json({ error: 'Webhook签名验证失败' }, 401); + } + const body = JSON.parse(rawBody); // 记录收到的数据,方便调试 diff --git a/src/index.ts b/src/index.ts index 166d323..7cf04c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,24 @@ const app = new Hono(); // 健康检查路由 app.get('/', (c) => { - return c.json({ status: 'ok', message: 'AI Code Review 服务运行中' }); + const webhookSecretConfigured = !!config.app.webhookSecret; + + return c.json({ + status: 'ok', + message: 'AI Code Review 服务运行中', + version: '1.1.0', + webhookSecurityEnabled: webhookSecretConfigured, + configuration: { + webhookEndpoints: { + pullRequest: '/webhook/gitea/pull_request', + commitStatus: '/webhook/gitea/status', + legacy: '/webhook/gitea (仅支持Pull Request事件)' + }, + signature: webhookSecretConfigured + ? '签名验证已启用 (使用X-Gitea-Signature头)' + : '警告: 签名验证未配置,建议设置WEBHOOK_SECRET环境变量' + } + }); }); // Gitea webhook路由 - 处理PR事件