diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index ea3a5ad..bf07bc6 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -236,13 +236,17 @@ func TestDestinations(t *testing.T) { if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("code = %d, want 422", rec.Code) } - // Discord non-https -> 422. + // Discord non-https or non-Discord host -> 422. rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "http://x"}) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("code = %d, want 422", rec.Code) } + rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "https://evil.example/hook"}) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("non-discord host must 422, got %d", rec.Code) + } // Valid discord create -> 201. - rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "label": "ops", "webhook_url": "https://discord.example/hook"}) + rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "label": "ops", "webhook_url": "https://discord.com/api/webhooks/123/abc"}) if rec.Code != http.StatusCreated { t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()) } diff --git a/backend/internal/api/destinations.go b/backend/internal/api/destinations.go index c493ef5..b2230d6 100644 --- a/backend/internal/api/destinations.go +++ b/backend/internal/api/destinations.go @@ -134,7 +134,10 @@ func (s *Server) DeleteDestination(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true}) } -// checkDestSecrets validates kind-appropriate secrets. +// 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: @@ -149,8 +152,31 @@ func checkDestSecrets(kind, botToken, chatID, webhookURL string) string { 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 +} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 956bdfc..fddf175 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -63,6 +63,16 @@ func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client) func (s *Server) Router() http.Handler { r := chi.NewRouter() r.Use(middleware.Logger, middleware.Recoverer, middleware.Heartbeat("/ping")) + // Cap request bodies (1 MiB) so large POSTs cannot exhaust memory. + // JSON bodies here are tiny (auth, screen filters, chat prompts). + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Body != nil && (req.Method == http.MethodPost || req.Method == http.MethodPut || req.Method == http.MethodPatch) { + req.Body = http.MaxBytesReader(w, req.Body, 1<<20) + } + next.ServeHTTP(w, req) + }) + }) r.Route("/api", func(r chi.Router) { r.Get("/health", s.Health) r.Get("/auth/start", s.AuthStart) diff --git a/backend/internal/reports/render.go b/backend/internal/reports/render.go index 50e976a..943ff0d 100644 --- a/backend/internal/reports/render.go +++ b/backend/internal/reports/render.go @@ -3,6 +3,7 @@ package reports import ( "bytes" "fmt" + "html" "strings" "time" @@ -29,18 +30,21 @@ func (r Report) ToMarkdown() string { return b.String() } -// ToHTML renders the report as a standalone page. +// ToHTML renders the report as a standalone page. All dynamic values are +// HTML-escaped — bodies come from stored snapshots/LLM and must never be able +// to inject script into the exported file. func (r Report) ToHTML() string { + esc := html.EscapeString var b strings.Builder - b.WriteString(``) - b.WriteString(r.Ticker + " — FlowSight Report") - fmt.Fprintf(&b, "

%s — FlowSight Report (%s)

", r.Ticker, r.GeneratedAt) + b.WriteString("") + b.WriteString(esc(r.Ticker) + " — FlowSight Report") + fmt.Fprintf(&b, "

%s — FlowSight Report (%s)

", esc(r.Ticker), esc(r.GeneratedAt)) for _, s := range r.Sections { - fmt.Fprintf(&b, "

%s

%s

", s.Name, s.Body) + fmt.Fprintf(&b, "

%s

%s

", esc(s.Name), esc(s.Body)) if len(s.Citations) > 0 { b.WriteString("") }