Skip to content

实现 Issue #152: 需要给GITHUB 事件加上签名校验 #153

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 3 commits into from
Jul 18, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
43 changes: 40 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ CodeAgent 是一个基于 Go 语言开发的自动化代码生成系统,通过
- 💻 **本地 CLI 模式**: 支持本地 Claude CLI 和 Gemini CLI,无需 Docker
- 🧹 **自动清理**: 智能管理临时工作空间,避免资源泄露
- 📊 **状态监控**: 实时监控系统状态和执行进度
- 🔒 **安全可靠**: 完善的错误处理和重试机制
- 🔒 **安全可靠**: 完善的错误处理和重试机制,支持 GitHub Webhook 签名验证
- 🧠 **上下文感知**: Gemini CLI 模式自动构建完整上下文,提升代码质量

## 系统架构
Expand Down Expand Up @@ -122,7 +122,43 @@ use_docker: true # 是否使用 Docker,false 表示使用本地 CLI
- `true`: 使用 Docker 容器(推荐用于生产环境)
- `false`: 使用本地 CLI(推荐用于开发环境)

**注意**: 敏感信息(如 token、api_key)应该通过命令行参数或环境变量设置,而不是写在配置文件中。
**注意**: 敏感信息(如 token、api_key、webhook_secret)应该通过命令行参数或环境变量设置,而不是写在配置文件中。

### 安全配置

#### Webhook 签名验证

为了防止 webhook 接口被恶意利用,CodeAgent 支持 GitHub Webhook 签名验证功能:

1. **配置 webhook secret**:
```bash
# 方式1: 环境变量(推荐)
export WEBHOOK_SECRET="your-strong-secret-here"

# 方式2: 命令行参数
go run ./cmd/server --webhook-secret "your-strong-secret-here"
```

2. **GitHub Webhook 设置**:
- 在 GitHub 仓库设置中添加 Webhook
- URL: `https://your-domain.com/hook`
- Content type: `application/json`
- Secret: 输入与 `WEBHOOK_SECRET` 相同的值
- 选择事件: `Issue comments`, `Pull request reviews`, `Pull requests`

3. **签名验证机制**:
- 支持 SHA-256 签名验证(优先)
- 向下兼容 SHA-1 签名验证
- 使用恒定时间比较防止时间攻击
- 如果未配置 `webhook_secret`,则跳过签名验证(仅用于开发环境)

#### 安全建议

- 使用强密码作为 webhook secret(建议 32 字符以上)
- 在生产环境中务必配置 webhook secret
- 使用 HTTPS 保护 webhook 端点
- 定期轮换 API 密钥和 webhook secret
- 限制 GitHub Token 的权限范围

### 本地运行

Expand Down Expand Up @@ -229,7 +265,8 @@ curl http://localhost:8888/health
3. **配置 GitHub Webhook**
- URL: `http://your-domain.com/hook`
- 事件: `Issue comments`, `Pull request reviews`
- 密钥: 与配置文件中的 `webhook_secret` 一致
- 密钥: 与配置文件中的 `webhook_secret` 一致(用于签名验证)
- 推荐使用 HTTPS 和强密码来保证安全性

### 使用示例

Expand Down
2 changes: 2 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

server:
port: 8888
# GitHub webhook 签名验证密钥,用于验证请求的真实性
# 需要与 GitHub webhook 配置中的 secret 保持一致
webhook_secret: your-webhook-secret-here

github:
Expand Down
46 changes: 33 additions & 13 deletions internal/webhook/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/qbox/codeagent/internal/agent"
"github.com/qbox/codeagent/internal/config"
"github.com/qbox/codeagent/pkg/signature"

"github.com/google/go-github/v58/github"
"github.com/qiniu/x/reqid"
Expand All @@ -26,17 +27,46 @@ func NewHandler(cfg *config.Config, agent *agent.Agent) *Handler {

// HandleWebhook 通用 Webhook 处理器
func (h *Handler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
// 1. 验证 Webhook 签名(此处省略,建议用 X-Hub-Signature 校验)
// 1. 读取请求体 (需要在签名验证前读取)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read request body", http.StatusBadRequest)
return
}

// 2. 获取事件类型
// 2. 验证 Webhook 签名
if h.config.Server.WebhookSecret != "" {
// 优先使用 SHA-256 签名
sig256 := r.Header.Get("X-Hub-Signature-256")
if sig256 != "" {
if err := signature.ValidateGitHubSignature(sig256, body, h.config.Server.WebhookSecret); err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
} else {
// 如果没有 SHA-256 签名,尝试 SHA-1 签名 (已弃用但仍支持)
sig1 := r.Header.Get("X-Hub-Signature")
if sig1 != "" {
if err := signature.ValidateGitHubSignatureSHA1(sig1, body, h.config.Server.WebhookSecret); err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
} else {
http.Error(w, "missing signature", http.StatusUnauthorized)
return
}
}
}

// 3. 获取事件类型
eventType := r.Header.Get("X-GitHub-Event")
if eventType == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("missing X-GitHub-Event header"))
return
}

// 3. 创建追踪 ID 和上下文
// 4. 创建追踪 ID 和上下文
// 使用 X-GitHub-Delivery header 作为 trace ID,截短到前8位
deliveryID := r.Header.Get("X-GitHub-Delivery")
var traceID string
Expand All @@ -52,16 +82,6 @@ func (h *Handler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
ctx := reqid.NewContext(context.Background(), traceID)
xl := xlog.NewWith(ctx)
xl.Infof("Received webhook event: %s", eventType)

// 4. 读取请求体
body, err := io.ReadAll(r.Body)
if err != nil {
xl.Errorf("Failed to read request body: %v", err)
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid body"))
return
}

xl.Debugf("Request body size: %d bytes", len(body))

// 5. 根据事件类型分发处理
Expand Down
122 changes: 122 additions & 0 deletions internal/webhook/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package webhook

import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"testing"

"github.com/qbox/codeagent/internal/config"
)

func TestHandleWebhook_SignatureValidation(t *testing.T) {
// 创建测试配置
cfg := &config.Config{
Server: config.ServerConfig{
WebhookSecret: "test-secret",
},
}

// 创建处理器
handler := NewHandler(cfg, nil)

// 测试数据
payload := []byte(`{"action":"opened","number":1}`)

// 生成有效签名
mac := hmac.New(sha256.New, []byte(cfg.Server.WebhookSecret))
mac.Write(payload)
validSignature := "sha256=" + hex.EncodeToString(mac.Sum(nil))

tests := []struct {
name string
signature string
expectedStatus int
expectedBody string
}{
{
name: "valid signature",
signature: validSignature,
expectedStatus: http.StatusBadRequest, // 由于没有 X-GitHub-Event 头,会返回 400
expectedBody: "missing X-GitHub-Event header",
},
{
name: "invalid signature",
signature: "sha256=invalid",
expectedStatus: http.StatusUnauthorized,
expectedBody: "invalid signature\n",
},
{
name: "missing signature",
signature: "",
expectedStatus: http.StatusUnauthorized,
expectedBody: "missing signature\n",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 创建请求
req := httptest.NewRequest("POST", "/hook", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
if tt.signature != "" {
req.Header.Set("X-Hub-Signature-256", tt.signature)
}

// 创建响应记录器
rr := httptest.NewRecorder()

// 调用处理器
handler.HandleWebhook(rr, req)

// 检查状态码
if rr.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, rr.Code)
}

// 检查响应体
if rr.Body.String() != tt.expectedBody {
t.Errorf("Expected body %q, got %q", tt.expectedBody, rr.Body.String())
}
})
}
}

func TestHandleWebhook_NoSecretConfigured(t *testing.T) {
// 创建无密钥配置
cfg := &config.Config{
Server: config.ServerConfig{
WebhookSecret: "",
},
}

// 创建处理器
handler := NewHandler(cfg, nil)

// 测试数据
payload := []byte(`{"action":"opened","number":1}`)

// 创建请求(无签名)
req := httptest.NewRequest("POST", "/hook", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")

// 创建响应记录器
rr := httptest.NewRecorder()

// 调用处理器
handler.HandleWebhook(rr, req)

// 当没有配置 webhook secret 时,应该跳过签名验证
// 但由于没有 X-GitHub-Event 头,会返回 400
if rr.Code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code)
}

expectedBody := "missing X-GitHub-Event header"
if rr.Body.String() != expectedBody {
t.Errorf("Expected body %q, got %q", expectedBody, rr.Body.String())
}
}
91 changes: 91 additions & 0 deletions pkg/signature/github.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package signature

import (
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
)

var (
ErrInvalidSignature = errors.New("invalid signature")
ErrMissingSignature = errors.New("missing signature")
ErrInvalidFormat = errors.New("invalid signature format")
)

// ValidateGitHubSignature 验证GitHub webhook签名
// signature: 来自请求头 X-Hub-Signature-256 的签名
// payload: 请求体的原始数据
// secret: webhook配置的secret
func ValidateGitHubSignature(signature string, payload []byte, secret string) error {
if signature == "" {
return ErrMissingSignature
}

// GitHub签名格式: sha256=<signature>
const prefix = "sha256="
if !strings.HasPrefix(signature, prefix) {
return ErrInvalidFormat
}

// 提取签名部分
sig := strings.TrimPrefix(signature, prefix)

// 解码十六进制签名
expectedSig, err := hex.DecodeString(sig)
if err != nil {
return fmt.Errorf("failed to decode signature: %w", err)
}

// 计算HMAC-SHA256
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
computedSig := mac.Sum(nil)

// 使用恒定时间比较防止时间攻击
if !hmac.Equal(expectedSig, computedSig) {
return ErrInvalidSignature
}

return nil
}

// ValidateGitHubSignatureSHA1 验证GitHub webhook签名 (SHA1, 已弃用但仍支持)
// signature: 来自请求头 X-Hub-Signature 的签名
// payload: 请求体的原始数据
// secret: webhook配置的secret
func ValidateGitHubSignatureSHA1(signature string, payload []byte, secret string) error {
if signature == "" {
return ErrMissingSignature
}

// GitHub签名格式: sha1=<signature>
const prefix = "sha1="
if !strings.HasPrefix(signature, prefix) {
return ErrInvalidFormat
}

// 提取签名部分
sig := strings.TrimPrefix(signature, prefix)

// 解码十六进制签名
expectedSig, err := hex.DecodeString(sig)
if err != nil {
return fmt.Errorf("failed to decode signature: %w", err)
}

// 计算HMAC-SHA1 (已弃用但仍支持)
mac := hmac.New(sha1.New, []byte(secret))
mac.Write(payload)
computedSig := mac.Sum(nil)

// 使用恒定时间比较防止时间攻击
if !hmac.Equal(expectedSig, computedSig) {
return ErrInvalidSignature
}

return nil
}
Loading