Files
flowsight/backend/internal/api/destinations.go
T
asepharyana 05a3bc7471
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
fix: audit round 4 — XSS in HTML report export, SSRF webhook guard, body limit
- ToHTML() now html.EscapeString on ticker, generated_at, section name/body, citations (stored/LLM content can't inject script into exported file)
- Discord destination webhook_url restricted to discord.com/discordapp.com hosts (SSRF guard: server POSTs alert cards; no private IPs or arbitrary hosts)
- Router middleware caps POST/PUT/PATCH bodies at 1 MiB (MaxBytesReader)
- TestDestinations updated: non-discord host must 422, uses realistic discord.com webhook URL
2026-09-16 14:43:56 +07:00

183 lines
5.5 KiB
Go

package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"flowsight/internal/store"
)
// destOut is the masked API shape: secrets never leave the server.
type destOut struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Label string `json:"label"`
Enabled bool `json:"enabled"`
Configured bool `json:"configured"`
}
func maskDestinations(rows []store.Destination) []destOut {
out := make([]destOut, 0, len(rows))
for _, d := range rows {
cfg := false
switch d.Kind {
case store.DestTelegram:
cfg = d.BotToken != "" && d.ChatID != ""
case store.DestDiscord:
cfg = d.WebhookURL != ""
}
out = append(out, destOut{ID: d.ID, Kind: d.Kind, Label: d.Label, Enabled: d.Enabled, Configured: cfg})
}
return out
}
// ListDestinations serves GET /api/destinations (secrets masked).
func (s *Server) ListDestinations(w http.ResponseWriter, r *http.Request) {
rows, err := s.DB.ListDestinations(s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"destinations": maskDestinations(rows)})
}
// CreateDestination serves POST /api/destinations.
func (s *Server) CreateDestination(w http.ResponseWriter, r *http.Request) {
var req struct {
Kind string `json:"kind" validate:"required,oneof=telegram discord"`
Label string `json:"label"`
BotToken string `json:"bot_token"`
ChatID string `json:"chat_id"`
WebhookURL string `json:"webhook_url"`
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
req.Kind = strings.ToLower(strings.TrimSpace(req.Kind))
if err := s.Validate.Struct(req); err != nil {
writeErr(w, http.StatusUnprocessableEntity, "kind must be telegram or discord")
return
}
if msg := checkDestSecrets(req.Kind, req.BotToken, req.ChatID, req.WebhookURL); msg != "" {
writeErr(w, http.StatusUnprocessableEntity, msg)
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
id, err := s.DB.CreateDestination(store.Destination{
UserKey: s.userKey(r), Kind: req.Kind, Label: req.Label,
BotToken: req.BotToken, ChatID: req.ChatID, WebhookURL: req.WebhookURL,
Enabled: enabled,
})
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id, "kind": req.Kind})
}
// UpdateDestination serves PATCH /api/destinations/:id. Kind is immutable;
// omitted secret fields keep their stored value.
func (s *Server) UpdateDestination(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
var req struct {
Label *string `json:"label"`
Enabled *bool `json:"enabled"`
BotToken *string `json:"bot_token"`
ChatID *string `json:"chat_id"`
WebhookURL *string `json:"webhook_url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
ok, err := s.DB.UpdateDestination(id, s.userKey(r), req.Label, req.Enabled, req.BotToken, req.ChatID, req.WebhookURL)
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
if !ok {
writeErr(w, http.StatusNotFound, "destination not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
}
// DeleteDestination serves DELETE /api/destinations/:id.
func (s *Server) DeleteDestination(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
ok, err := s.DB.DeleteDestination(id, s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
if !ok {
writeErr(w, http.StatusNotFound, "destination not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
}
// checkDestSecrets validates kind-appropriate secrets. Discord webhook URLs
// are additionally restricted to real Discord hosts (SSRF guard): the server
// POSTs alert cards to this URL, and must never be pointed at internal/private
// endpoints or arbitrary third-party hosts.
func checkDestSecrets(kind, botToken, chatID, webhookURL string) string {
switch kind {
case store.DestTelegram:
if strings.TrimSpace(botToken) == "" || strings.TrimSpace(chatID) == "" {
return "telegram needs bot_token and chat_id"
}
case store.DestDiscord:
u := strings.TrimSpace(webhookURL)
if u == "" {
return "discord needs webhook_url"
}
if !strings.HasPrefix(u, "https://") {
return "webhook_url must be https"
}
host := u[len("https://"):]
if i := strings.IndexAny(host, "/?"); i >= 0 {
host = host[:i]
}
if !discordWebhookHost(host) {
return "webhook_url must be a discord.com/app.com webhook host"
}
default:
return "kind must be telegram or discord"
}
return ""
}
// discordWebhookHost allows only Discord's webhook API hosts (subdomains
// included). Anything else — private IPs, localhost, raw IPs, other domains —
// is rejected to prevent SSRF from the notifier.
func discordWebhookHost(host string) bool {
h := strings.ToLower(strings.TrimSpace(host))
if h == "discord.com" || h == "discordapp.com" {
return true
}
for _, suffix := range []string{".discord.com", ".discordapp.com", ".discord.gg"} {
if strings.HasSuffix(h, suffix) {
return true
}
}
return false
}