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
This commit is contained in:
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(`<!doctype html><html><head><meta charset="utf-8"><title>`)
|
||||
b.WriteString(r.Ticker + " — FlowSight Report</title></head><body>")
|
||||
fmt.Fprintf(&b, "<h1>%s — FlowSight Report (%s)</h1>", r.Ticker, r.GeneratedAt)
|
||||
b.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>")
|
||||
b.WriteString(esc(r.Ticker) + " — FlowSight Report</title></head><body>")
|
||||
fmt.Fprintf(&b, "<h1>%s — FlowSight Report (%s)</h1>", esc(r.Ticker), esc(r.GeneratedAt))
|
||||
for _, s := range r.Sections {
|
||||
fmt.Fprintf(&b, "<h2>%s</h2><p>%s</p>", s.Name, s.Body)
|
||||
fmt.Fprintf(&b, "<h2>%s</h2><p>%s</p>", esc(s.Name), esc(s.Body))
|
||||
if len(s.Citations) > 0 {
|
||||
b.WriteString("<ul>")
|
||||
for _, c := range s.Citations {
|
||||
fmt.Fprintf(&b, "<li>%s %s @ %s</li>", c.Endpoint, c.Ticker, c.SnapshotAt)
|
||||
fmt.Fprintf(&b, "<li>%s %s @ %s</li>", esc(c.Endpoint), esc(c.Ticker), esc(c.SnapshotAt))
|
||||
}
|
||||
b.WriteString("</ul>")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user