validateWebhookFields replicates the legacy validation. The two security-critical checks are reused from their canonical homes (SSRF via agent.ValidateWebhookURL, event allowlist via webhookpub.IsValidEventType) so they can't drift; the charset/length/ownership checks are explicit.
(ctx context.Context, userID, url string, events []string, f WebhookFiltersView, description string)
| 82 | // agent.ValidateWebhookURL, event allowlist via webhookpub.IsValidEventType) |
| 83 | // so they can't drift; the charset/length/ownership checks are explicit. |
| 84 | func (s *Server) validateWebhookFields(ctx context.Context, userID, url string, events []string, f WebhookFiltersView, description string) *ErrorEnvelope { |
| 85 | if url != "" { |
| 86 | if err := agent.ValidateWebhookURL(url); err != nil { |
| 87 | return NewError(http.StatusBadRequest, "invalid_webhook_url", "invalid url: "+err.Error()) |
| 88 | } |
| 89 | if len(url) > webhookMaxURLLen { |
| 90 | return NewError(http.StatusBadRequest, "invalid_request", "url too long (max 2048 chars)") |
| 91 | } |
| 92 | } |
| 93 | if len(events) == 0 { |
| 94 | return NewError(http.StatusBadRequest, "invalid_request", "events must be a non-empty array") |
| 95 | } |
| 96 | for _, e := range events { |
| 97 | if !webhookpub.IsValidEventType(e) { |
| 98 | return NewError(http.StatusBadRequest, "invalid_event_type", |
| 99 | fmt.Sprintf("unknown event type %q (allowed: %s)", e, strings.Join(webhookpub.AllEventTypes, ", "))) |
| 100 | } |
| 101 | } |
| 102 | if len(description) > webhookMaxDescriptionLen { |
| 103 | return NewError(http.StatusBadRequest, "invalid_request", "description too long (max 200 chars)") |
| 104 | } |
| 105 | if strings.ContainsAny(description, "\r\n") { |
| 106 | return NewError(http.StatusBadRequest, "invalid_request", "description must not contain CR or LF") |
| 107 | } |
| 108 | if len(f.AgentIDs) > webhookMaxAgentIDs { |
| 109 | return NewError(http.StatusBadRequest, "invalid_request", fmt.Sprintf("filters.agent_ids exceeds cap of %d", webhookMaxAgentIDs)) |
| 110 | } |
| 111 | if len(f.ConversationIDs) > webhookMaxConversationIDs { |
| 112 | return NewError(http.StatusBadRequest, "invalid_request", fmt.Sprintf("filters.conversation_ids exceeds cap of %d", webhookMaxConversationIDs)) |
| 113 | } |
| 114 | if len(f.Labels) > webhookMaxLabels { |
| 115 | return NewError(http.StatusBadRequest, "invalid_request", fmt.Sprintf("filters.labels exceeds cap of %d", webhookMaxLabels)) |
| 116 | } |
| 117 | for _, a := range f.AgentIDs { |
| 118 | if a == "" || len(a) > webhookMaxFilterValueLen { |
| 119 | return NewError(http.StatusBadRequest, "invalid_request", "filters.agent_ids contains empty entry or one over 200 chars") |
| 120 | } |
| 121 | } |
| 122 | // agent_ids must reference agents the caller owns. |
| 123 | if err := s.assertAgentsOwned(ctx, userID, f.AgentIDs); err != nil { |
| 124 | return err |
| 125 | } |
| 126 | for _, c := range f.ConversationIDs { |
| 127 | if c == "" || len(c) > webhookMaxFilterValueLen { |
| 128 | return NewError(http.StatusBadRequest, "invalid_request", "filters.conversation_ids contains empty entry or one over 200 chars") |
| 129 | } |
| 130 | for _, r := range c { |
| 131 | if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_') { |
| 132 | return NewError(http.StatusBadRequest, "invalid_request", fmt.Sprintf("filters.conversation_ids[%q]: invalid character", c)) |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | for _, l := range f.Labels { |
| 137 | if l == "" || len(l) > 64 { |
| 138 | return NewError(http.StatusBadRequest, "invalid_request", "filters.labels contains empty entry or one over 64 chars") |
| 139 | } |
| 140 | for _, r := range l { |
| 141 | if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ':' || r == '-' || r == '_') { |
no test coverage detected