Root cause
Injection & unsafe execution introduced
feat(webhook): add webhook server for external integrations
How AI contributed: Direct introductionMediumPublished Aug 20, 2026 in
chenhg5/cc-connectAll 2 findings in this repository
A vulnerability was found in chenhg5 cc-connect up to 1.4.1. Affected by this vulnerability is the function Authenticate of the file core/webhook.go. The manipulation of the argument exec results in code injection. The attack may be performed from remote. The exploit has been made public and could be used. The reported GitHub issue was closed automatically due to inactivity.
When the optional webhook server is enabled, it listens on all interfaces and treats an empty configured token as authenticated. An unauthenticated remote POST to the configured hook path can therefore supply a JSON exec value; handleHook dispatches that value to executeShell, which passes it as the command string to the configured shell. Thus an enabled deployment with the documented empty-token setting exposes arbitrary command execution in the cc-connect process context.
Root cause
feat(webhook): add webhook server for external integrations
Fix status
No minimum fix commit has been established for this finding.
Fix status unresolved
Case facts
@@ -0,0 +1,334 @@+package core++import (+ "context"+ "crypto/subtle"+ "encoding/json"+ "fmt"+ "log/slog"+ "net/http"+ "os"+ "os/exec"+ "strings"+ "sync"+ "time"+)++// WebhookServer exposes an HTTP endpoint for external systems+// (git hooks, CI/CD, file watchers, etc.) to trigger agent or shell actions.+type WebhookServer struct {+ port int+ token string+ path string+ server *http.Server+ engines map[string]*Engine+ mu sync.RWMutex+}++// WebhookRequest is the JSON body for POST /hook.+type WebhookRequest struct {+ Event string `json:"event,omitempty"` // event name for logging (e.g. "git:commit")+ Project string `json:"project,omitempty"` // target project; optional if single project+ SessionKey string `json:"session_key"` // target session key (required)+ Prompt string `json:"prompt,omitempty"` // agent prompt (mutually exclusive with exec)+ Exec string `json:"exec,omitempty"` // shell command (mutually exclusive with prompt)+ WorkDir string `json:"work_dir,omitempty"` // working dir for exec+ Silent bool `json:"silent,omitempty"` // suppress notification+ Payload any `json:"payload,omitempty"` // arbitrary extra data; appended to prompt context+}++func NewWebhookServer(port int, token, path string) *WebhookServer {+ if port <= 0 {+ port = 9111+ }+ if path == "" {+ path = "/hook"+ }+ if !strings.HasPrefix(path, "/") {+ path = "/" + path+ }+ return &WebhookServer{+ port: port,+ token: token,+ path: path,+ engines: make(map[string]*Engine),+ }+}++func (ws *WebhookServer) RegisterEngine(name string, e *Engine) {+ ws.mu.Lock()+ defer ws.mu.Unlock()+ ws.engines[name] = e+}++func (ws *WebhookServer) Start() {+ mux := http.NewServeMux()+ mux.HandleFunc(ws.path, ws.handleHook)++ addr := fmt.Sprintf(":%d", ws.port)+ ws.server = &http.Server{Addr: addr, Handler: mux}++ go func() {+ slog.Info("webhook: server started", "addr", addr, "path", ws.path)+ if err := ws.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {+ slog.Error("webhook: server error", "error", err)+ }+ }()+}++func (ws *WebhookServer) Stop() {+ if ws.server != nil {+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)+ defer cancel()+ ws.server.Shutdown(ctx)+ }+}++func (ws *WebhookServer) handleHook(w http.ResponseWriter, r *http.Request) {+ if r.Method != http.MethodPost {+ http.Error(w, "POST only", http.StatusMethodNotAllowed)@@ -0,0 +1,104 @@+package core++import (+ "bytes"+ "encoding/json"+ "net/http"+ "net/http/httptest"+ "testing"+)++func TestWebhookServer_AuthBearer(t *testing.T) {+ ws := NewWebhookServer(0, "my-secret", "/hook")+ r := httptest.NewRequest(http.MethodPost, "/hook", nil)+ r.Header.Set("Authorization", "Bearer my-secret")+ if !ws.authenticate(r) {+ t.Error("expected auth to succeed with correct Bearer token")+ }+ r.Header.Set("Authorization", "Bearer wrong")+ if ws.authenticate(r) {+ t.Error("expected auth to fail with wrong Bearer token")+ }+}++func TestWebhookServer_AuthHeader(t *testing.T) {+ ws := NewWebhookServer(0, "tok123", "/hook")+ r := httptest.NewRequest(http.MethodPost, "/hook", nil)+ r.Header.Set("X-Webhook-Token", "tok123")+ if !ws.authenticate(r) {+ t.Error("expected auth to succeed with X-Webhook-Token")+ }+}++func TestWebhookServer_AuthQuery(t *testing.T) {+ ws := NewWebhookServer(0, "qsecret", "/hook")+ r := httptest.NewRequest(http.MethodPost, "/hook?token=qsecret", nil)+ if !ws.authenticate(r) {+ t.Error("expected auth to succeed with query token")+ }+}++func TestWebhookServer_NoTokenRequired(t *testing.T) {+ ws := NewWebhookServer(0, "", "/hook")+ r := httptest.NewRequest(http.MethodPost, "/hook", nil)+ if !ws.authenticate(r) {+ t.Error("expected auth to pass when no token configured")+ }+}++func TestWebhookServer_HandleHook_MethodNotAllowed(t *testing.T) {+ ws := NewWebhookServer(0, "", "/hook")+ w := httptest.NewRecorder()+ r := httptest.NewRequest(http.MethodGet, "/hook", nil)+ ws.handleHook(w, r)+ if w.Code != http.StatusMethodNotAllowed {+ t.Errorf("expected 405, got %d", w.Code)+ }+}++func TestWebhookServer_HandleHook_Unauthorized(t *testing.T) {+ ws := NewWebhookServer(0, "secret", "/hook")+ w := httptest.NewRecorder()+ body, _ := json.Marshal(WebhookRequest{SessionKey: "tg:1:1", Prompt: "hi"})+ r := httptest.NewRequest(http.MethodPost, "/hook", bytes.NewReader(body))+ ws.handleHook(w, r)+ if w.Code != http.StatusUnauthorized {+ t.Errorf("expected 401, got %d", w.Code)+ }+}++func TestWebhookServer_HandleHook_Validation(t *testing.T) {+ ws := NewWebhookServer(0, "", "/hook")++ tests := []struct {+ name string+ body WebhookRequest+ code int+ }{+ {"missing session_key", WebhookRequest{Prompt: "hi"}, http.StatusBadRequest},+ {"missing prompt and exec", WebhookRequest{SessionKey: "tg:1:1"}, http.StatusBadRequest},+ {"both prompt and exec", WebhookRequest{SessionKey: "tg:1:1", Prompt: "hi", Exec: "ls"}, http.StatusBadRequest},+ }++ for _, tt := range tests {+ t.Run(tt.name, func(t *testing.T) {+ w := httptest.NewRecorder()+ body, _ := json.Marshal(tt.body)+ r := httptest.NewRequest(http.MethodPost, "/hook", bytes.NewReader(body))+ ws.handleHook(w, r)+ if w.Code != tt.code {@@ -30,6 +30,7 @@ type Config struct { RateLimit RateLimitConfig `toml:"rate_limit"` // per-session rate limiting Quiet *bool `toml:"quiet,omitempty"` // global default for quiet mode; project-level overrides this Cron CronConfig `toml:"cron"`+ Webhook WebhookConfig `toml:"webhook"` IdleTimeoutMins *int `toml:"idle_timeout_mins,omitempty"` // max minutes between agent events; 0 = no timeout; default 120 } @@ -38,6 +39,14 @@ type CronConfig struct { Silent *bool `toml:"silent"` // suppress cron start notification; default false } +// WebhookConfig controls the external HTTP webhook endpoint.+type WebhookConfig struct {+ Enabled *bool `toml:"enabled"` // default false+ Port int `toml:"port,omitempty"` // listen port; default 9111+ Token string `toml:"token,omitempty"` // shared secret for authentication; empty = no auth+ Path string `toml:"path,omitempty"` // URL path prefix; default "/hook"+}+ // DisplayConfig controls how intermediate messages (thinking, tool output) are shown. type DisplayConfig struct { ThinkingMaxLen *int `toml:"thinking_max_len"` // max chars for thinking messages; 0 = no truncation; default 300