Files
Duy /zuey/andGitHub 532ff91d8e fix(security): harden upstream critical surfaces (#32)
* fix(security): harden upstream critical surfaces

Refs #30

* fix(security): close pre-landing review gaps

Refs #30

* fix(security): close official release blockers
2026-05-20 16:33:49 +07:00

561 lines
18 KiB
Go

package http
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/security"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
const (
// webhookLLMTimeout is the hard deadline for synchronous LLM invocations.
webhookLLMTimeout = 30 * time.Second
// webhookLLMResponseTruncate is the maximum bytes stored in the audit row response column.
webhookLLMResponseTruncate = 32 * 1024
// webhookLaneName is the scheduler lane name for webhook LLM calls.
webhookLaneName = "webhook"
// webhookLaneDefaultConcurrency is the fallback concurrency when no lane is provided.
webhookLaneDefaultConcurrency = 4
)
// webhookLLMReq is the JSON request body for POST /v1/webhooks/llm.
// Input accepts either a plain string or a message array [{role,content}...].
type webhookLLMReq struct {
// Input is the user prompt. Either a plain string or message array.
// Required.
Input json.RawMessage `json:"input"`
// SessionKey is an optional stable conversation anchor for multi-turn conversations.
// If omitted, a per-call ephemeral key is generated.
SessionKey string `json:"session_key,omitempty"`
// UserID is an optional free-form external user identifier for multi-tenant scoping.
UserID string `json:"user_id,omitempty"`
// Model is an optional per-request model override.
Model string `json:"model,omitempty"`
// Mode controls dispatch: "sync" (default) or "async".
Mode string `json:"mode,omitempty"`
// CallbackURL is required when mode=async. Validated against SSRF policy.
CallbackURL string `json:"callback_url,omitempty"`
// Metadata is optional caller-provided context echoed to callback (max 8 KB — enforced by middleware).
Metadata json.RawMessage `json:"metadata,omitempty"`
}
// webhookInputMessage is a single turn in a structured input array.
type webhookInputMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// webhookLLMSyncResp is the 200 response for synchronous LLM calls.
type webhookLLMSyncResp struct {
CallID string `json:"call_id"`
AgentID string `json:"agent_id"`
Output string `json:"output"`
Usage *webhookLLMUsage `json:"usage,omitempty"`
FinishReason string `json:"finish_reason"`
}
// webhookLLMUsage mirrors providers.Usage for the response envelope.
type webhookLLMUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// webhookLLMAsyncResp is the 202 response for asynchronous LLM calls.
type webhookLLMAsyncResp struct {
CallID string `json:"call_id"`
Status string `json:"status"` // always "queued"
}
// WebhookLLMHandler handles POST /v1/webhooks/llm.
// Available in all editions — auth enforced by WebhookAuthMiddleware with kind="llm".
// Sync mode: invokes agent directly with a 30s timeout.
// Async mode: enqueues a webhook_calls row for phase 07 worker.
type WebhookLLMHandler struct {
agentRouter *agent.Router
callStore store.WebhookCallStore
webhooks store.WebhookStore
limiter *webhookLimiter
lane *scheduler.Lane
encKey string // AES-256-GCM key for decrypting encrypted_secret at HMAC verify time
// syncTimeout overrides webhookLLMTimeout (30s) — set in tests only.
syncTimeout time.Duration
}
// NewWebhookLLMHandler constructs a WebhookLLMHandler.
// lane controls concurrency for sync LLM calls (nil → uses internal default lane).
func NewWebhookLLMHandler(
agentRouter *agent.Router,
callStore store.WebhookCallStore,
webhooks store.WebhookStore,
limiter *webhookLimiter,
lane *scheduler.Lane,
) *WebhookLLMHandler {
if lane == nil {
lane = scheduler.NewLane(webhookLaneName, webhookLaneDefaultConcurrency)
}
return &WebhookLLMHandler{
agentRouter: agentRouter,
callStore: callStore,
webhooks: webhooks,
limiter: limiter,
lane: lane,
}
}
// SetEncKey sets the AES-256-GCM encryption key for decrypting webhook secrets at HMAC verify time.
func (h *WebhookLLMHandler) SetEncKey(encKey string) {
h.encKey = encKey
}
// RegisterRoutes mounts POST /v1/webhooks/llm behind the auth middleware.
// Mounted in both Standard and Lite editions (localhost_only enforced at middleware level).
func (h *WebhookLLMHandler) RegisterRoutes(mux *http.ServeMux) {
authMW := WebhookAuthMiddleware(
h.webhooks,
h.callStore,
h.limiter,
h.encKey,
"llm",
WebhookMaxBodyLLM,
)
mux.Handle("POST /v1/webhooks/llm", authMW(http.HandlerFunc(h.handle)))
}
// handle is the HTTP handler for POST /v1/webhooks/llm.
func (h *WebhookLLMHandler) handle(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
locale := store.LocaleFromContext(ctx)
// Webhook row always present — injected by WebhookAuthMiddleware.
webhook := WebhookDataFromContext(ctx)
if webhook == nil {
writeError(w, http.StatusInternalServerError, protocol.ErrInternal,
i18n.T(locale, i18n.MsgInternalError, "webhook context missing"))
return
}
// P0: webhook must have a bound agent.
if webhook.AgentID == nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest,
i18n.T(locale, i18n.MsgWebhookAgentNotFound))
return
}
agentID := webhook.AgentID.String()
// Decode and validate request body.
var req webhookLLMReq
if !bindJSON(w, r, locale, &req) {
return
}
// Validate input field is present.
if len(req.Input) == 0 || string(req.Input) == "null" {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest,
i18n.T(locale, i18n.MsgRequired, "input"))
return
}
// Determine mode: default sync, or async when callback_url provided.
mode := "sync"
if req.Mode == "async" || req.CallbackURL != "" {
mode = "async"
}
if req.Mode != "" && req.Mode != "sync" && req.Mode != "async" {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest,
i18n.T(locale, i18n.MsgInvalidRequest, "mode must be 'sync' or 'async'"))
return
}
if mode == "async" && req.CallbackURL == "" {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest,
i18n.T(locale, i18n.MsgRequired, "callback_url"))
return
}
// Parse and build user message + optional extra system prompt from input.
userMessage, extraSystemPrompt, err := buildInput(req.Input)
if err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest,
i18n.T(locale, i18n.MsgInvalidRequest, err.Error()))
return
}
if userMessage == "" {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest,
i18n.T(locale, i18n.MsgRequired, "input"))
return
}
// Resolve agent via router — uses webhook.AgentID (UUID string).
// router.Get caches by tenantID:agentKey. UUID form incurs a fresh resolver
// call each time (documented in router.go:90), but correctness is guaranteed.
ag, agErr := h.agentRouter.Get(ctx, agentID)
if agErr != nil {
writeError(w, http.StatusNotFound, protocol.ErrNotFound,
i18n.T(locale, i18n.MsgWebhookAgentNotFound))
return
}
// P0 cross-tenant isolation: agent must belong to webhook's tenant.
if ag.UUID() != *webhook.AgentID {
slog.Warn("security.webhook.tenant_mismatch",
"webhook_id", webhook.ID,
"webhook_tenant", webhook.TenantID,
"agent_id", agentID,
)
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized,
i18n.T(locale, i18n.MsgWebhookTenantMismatch))
return
}
callID := store.GenNewID()
deliveryID := store.GenNewID()
now := time.Now()
// Capture raw body bytes for body_hash computation when middleware supplied them.
// Direct handler tests fall back to canonical JSON bytes from the decoded request.
// The audit payload uses the canonical JSON shape {"body_hash":"...","meta":{...}}
// so PG jsonb insert never triggers error 22P02.
reqBytes := WebhookRawBodyFromContext(ctx)
if reqBytes == nil {
reqBytes, _ = json.Marshal(req)
}
requestPayload, _ := buildAuditPayload(reqBytes, req)
idempotencyKey := optionalIdempotencyKey(r)
// Dispatch based on mode.
switch mode {
case "async":
h.handleAsync(w, r, ctx, locale, webhook, ag, agentID, req, callID, deliveryID, now, requestPayload, idempotencyKey, userMessage, extraSystemPrompt)
default: // "sync"
h.handleSync(w, r, ctx, locale, webhook, ag, agentID, req, callID, deliveryID, now, requestPayload, idempotencyKey, userMessage, extraSystemPrompt)
}
}
// handleSync invokes the agent within a 30s timeout and returns the response directly.
func (h *WebhookLLMHandler) handleSync(
w http.ResponseWriter,
r *http.Request,
ctx context.Context,
locale string,
webhook *store.WebhookData,
ag agent.Agent,
agentID string,
req webhookLLMReq,
callID, deliveryID uuid.UUID,
now time.Time,
requestPayload []byte,
idempotencyKey *string,
userMessage, extraSystemPrompt string,
) {
runID := uuid.NewString()
sessionKey := resolveWebhookSessionKey(req.SessionKey, agentID, webhook.ID, runID)
callRecord := &store.WebhookCallData{
ID: callID,
TenantID: webhook.TenantID,
WebhookID: webhook.ID,
AgentID: webhook.AgentID,
DeliveryID: deliveryID,
IdempotencyKey: idempotencyKey,
Mode: "sync",
Status: "running",
Attempts: 0,
RequestPayload: requestPayload,
CreatedAt: now,
StartedAt: &now,
}
callReserved, handled := reserveIdempotentCall(w, r, h.callStore, callRecord)
if handled {
return
}
rr := agent.RunRequest{
SessionKey: sessionKey,
Message: userMessage,
Channel: "webhook",
ChatID: webhook.ID.String(),
RunID: runID,
UserID: req.UserID,
Stream: false,
ModelOverride: req.Model,
ExtraSystemPrompt: extraSystemPrompt,
HistoryLimit: 0,
TraceName: "webhook.llm",
TraceTags: []string{"webhook"},
}
slog.Info("webhook.llm.invoked",
"call_id", callID,
"mode", "sync",
"agent_id", agentID,
"webhook_id", webhook.ID,
"user_id", req.UserID,
)
// type to propagate result from lane goroutine back to the handler.
type runOutcome struct {
result *agent.RunResult
err error
}
outCh := make(chan runOutcome, 1)
// Determine the effective timeout (30s in production; overridable in tests).
timeout := webhookLLMTimeout
if h.syncTimeout > 0 {
timeout = h.syncTimeout
}
// Acquire a webhook-lane slot; if full, return 503.
laneCtx, laneCancel := context.WithTimeout(ctx, timeout)
defer laneCancel()
submitErr := h.lane.Submit(laneCtx, func() {
// Each sync run gets its own hard timeout, isolated from request context
// so the HTTP response write path does not race with run cancellation.
runCtx, runCancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer runCancel()
result, err := ag.Run(runCtx, rr)
outCh <- runOutcome{result: result, err: err}
})
if submitErr != nil {
completedAt := time.Now()
errMsg := submitErr.Error()
callRecord.Status = "failed"
callRecord.Attempts = 1
callRecord.CompletedAt = &completedAt
callRecord.LastError = &errMsg
persistWebhookCall(ctx, h.callStore, callRecord, callReserved, "webhook.llm.audit_write_failed")
// Lane at capacity or ctx cancelled before slot acquired.
slog.Warn("webhook.lane_saturated",
"webhook_id", webhook.ID,
"agent_id", agentID,
"error", submitErr,
)
writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal,
i18n.T(locale, i18n.MsgWebhookLaneSaturated))
return
}
// Wait for run to complete or the overall laneCtx deadline to fire.
// The goroutine's runCtx (30s) should fire first, but we also select on
// laneCtx so the handler isn't leaked if the goroutine stalls.
var out runOutcome
select {
case out = <-outCh:
// normal completion
case <-laneCtx.Done():
out = runOutcome{err: context.DeadlineExceeded}
}
if out.err != nil {
completedAt := time.Now()
if errors.Is(out.err, context.DeadlineExceeded) {
// Write audit row as failed/timeout.
errMsg := "context deadline exceeded"
callRecord.Status = "failed"
callRecord.Attempts = 1
callRecord.LastError = &errMsg
callRecord.CompletedAt = &completedAt
persistWebhookCall(ctx, h.callStore, callRecord, callReserved, "webhook.llm.audit_write_failed")
writeError(w, http.StatusGatewayTimeout, protocol.ErrInternal,
i18n.T(locale, i18n.MsgWebhookLLMTimeout))
return
}
// Other error.
errMsg := out.err.Error()
callRecord.Status = "failed"
callRecord.Attempts = 1
callRecord.LastError = &errMsg
callRecord.CompletedAt = &completedAt
persistWebhookCall(ctx, h.callStore, callRecord, callReserved, "webhook.llm.audit_write_failed")
writeError(w, http.StatusInternalServerError, protocol.ErrInternal,
i18n.T(locale, i18n.MsgInternalError, out.err.Error()))
return
}
// Build response.
resp := webhookLLMSyncResp{
CallID: callID.String(),
AgentID: agentID,
Output: out.result.Content,
FinishReason: "stop",
}
if out.result.Usage != nil {
resp.Usage = &webhookLLMUsage{
PromptTokens: out.result.Usage.PromptTokens,
CompletionTokens: out.result.Usage.CompletionTokens,
TotalTokens: out.result.Usage.TotalTokens,
}
}
// Persist audit row (truncate response to 32 KB).
respBytes, _ := json.Marshal(resp)
if len(respBytes) > webhookLLMResponseTruncate {
respBytes = respBytes[:webhookLLMResponseTruncate]
}
completedAt := time.Now()
callRecord.Status = "done"
callRecord.Attempts = 1
callRecord.Response = respBytes
callRecord.CompletedAt = &completedAt
persistWebhookCall(ctx, h.callStore, callRecord, callReserved, "webhook.llm.audit_write_failed")
slog.Info("webhook.llm.sync",
"call_id", callID,
"agent_id", agentID,
"webhook_id", webhook.ID,
"output_len", len(out.result.Content),
)
writeJSON(w, http.StatusOK, resp)
}
// handleAsync enqueues a webhook_calls row and returns 202 immediately.
func (h *WebhookLLMHandler) handleAsync(
w http.ResponseWriter,
r *http.Request,
ctx context.Context,
locale string,
webhook *store.WebhookData,
_ agent.Agent,
agentID string,
req webhookLLMReq,
callID, deliveryID uuid.UUID,
now time.Time,
requestPayload []byte,
idempotencyKey *string,
_, _ string, // userMessage, extraSystemPrompt — stored in requestPayload, not used here
) {
// SSRF validation on callback_url — defense against DNS rebinding.
if _, _, err := security.Validate(req.CallbackURL); err != nil {
slog.Warn("security.webhook.callback_url_blocked",
"webhook_id", webhook.ID,
"url_hint", redactedHost(req.CallbackURL),
"error", err,
)
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest,
i18n.T(locale, i18n.MsgWebhookCallbackURLInvalid))
return
}
cbURL := req.CallbackURL
nextAttempt := now
call := &store.WebhookCallData{
ID: callID,
TenantID: webhook.TenantID,
WebhookID: webhook.ID,
AgentID: webhook.AgentID,
DeliveryID: deliveryID,
IdempotencyKey: idempotencyKey,
Mode: "async",
Status: "queued",
CallbackURL: &cbURL,
NextAttemptAt: &nextAttempt,
RequestPayload: requestPayload,
Attempts: 0,
CreatedAt: now,
}
if err := h.callStore.Create(ctx, call); err != nil {
if idempotencyKey != nil && errors.Is(err, store.ErrIdempotencyConflict) {
if replayStoredIdempotencyFromPayload(w, r, h.callStore, webhook.ID, *idempotencyKey, requestPayload) {
return
}
}
slog.Error("webhook.llm.async_enqueue_failed",
"error", err,
"call_id", callID,
"webhook_id", webhook.ID,
)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal,
i18n.T(locale, i18n.MsgInternalError, "failed to enqueue"))
return
}
slog.Info("webhook.llm.async_enqueued",
"call_id", callID,
"delivery_id", deliveryID,
"agent_id", agentID,
"webhook_id", webhook.ID,
)
writeJSON(w, http.StatusAccepted, webhookLLMAsyncResp{
CallID: callID.String(),
Status: "queued",
})
}
// buildInput parses the raw JSON input into a user message and optional extra system prompt.
//
// Two formats are accepted:
// 1. Plain string: used verbatim as the user message.
// 2. Array of {role, content} objects: non-system roles concatenated as the user message;
// system entries contribute to ExtraSystemPrompt.
//
// v2 note: full multi-turn array support (passing turns directly to RunRequest) is deferred.
func buildInput(raw json.RawMessage) (userMessage string, extraSystemPrompt string, err error) {
// Try plain string first.
var s string
if json.Unmarshal(raw, &s) == nil {
return s, "", nil
}
// Try message array.
var msgs []webhookInputMessage
if err := json.Unmarshal(raw, &msgs); err != nil {
return "", "", fmt.Errorf("input must be a string or array of {role,content} objects: %w", err)
}
var userParts, systemParts []string
for _, m := range msgs {
switch strings.ToLower(m.Role) {
case "system":
if m.Content != "" {
systemParts = append(systemParts, m.Content)
}
default: // "user", "assistant", anything else treated as user content
if m.Content != "" {
userParts = append(userParts, m.Content)
}
}
}
return strings.Join(userParts, "\n"), strings.Join(systemParts, "\n"), nil
}
// resolveWebhookSessionKey returns a stable or ephemeral session key.
// If the caller provides a sessionKey, it is used verbatim for conversation continuity.
// Otherwise, an ephemeral key is generated per-call.
func resolveWebhookSessionKey(reqSessionKey, agentID string, webhookID uuid.UUID, runID string) string {
if reqSessionKey != "" {
return reqSessionKey
}
return fmt.Sprintf("webhook:%s:%s:%s", agentID, webhookID.String(), runID[:8])
}