package handlers import ( "encoding/json" "errors" "net/http" ) // maxLoggedFieldBytes bounds untrusted string fields before they // are logged, so a caller cannot inflate log volume with an // oversized value. const maxLoggedFieldBytes = 128 type reportSample struct { T int64 `json:"t"` Latency *int `json:"latency"` Error *string `json:"error"` } type reportHost struct { History []reportSample `json:"history"` Name string `json:"name"` Status string `json:"status"` URL string `json:"url"` } type report struct { ClientID string `json:"clientId"` Geo json.RawMessage `json:"geo"` Hosts []reportHost `json:"hosts"` Timestamp string `json:"timestamp"` } // HandleReport returns a handler that accepts telemetry // reports from NetWatch clients. func (s *Handlers) HandleReport() http.HandlerFunc { type response struct { Status string `json:"status"` } return func(w http.ResponseWriter, r *http.Request) { var rpt report err := s.decodeJSON(w, r, &rpt) if err != nil { s.respondJSON(w, r, &response{Status: "error"}, s.decodeErrorStatus(err), ) return } s.logReportReceived(rpt) err = s.buf.Append(rpt) if err != nil { s.log.Error("failed to buffer report", "error", err) s.respondJSON(w, r, &response{Status: "error"}, http.StatusInternalServerError, ) return } s.respondJSON(w, r, &response{Status: "ok"}, http.StatusOK) } } // decodeErrorStatus logs a report decode failure and returns the // status to send: 413 when the body exceeded the size limit, // otherwise 400 for malformed JSON. func (s *Handlers) decodeErrorStatus(err error) int { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { s.log.Warn("report body too large", "limit_bytes", tooLarge.Limit) return http.StatusRequestEntityTooLarge } s.log.Error("failed to decode report", "error", err) return http.StatusBadRequest } // logReportReceived logs an accepted report. Untrusted fields are // bounded (client_id, timestamp) or reduced to a length // (geo_bytes) so the raw attacker-controlled body never reaches // the log. func (s *Handlers) logReportReceived(rpt report) { totalSamples := 0 for _, h := range rpt.Hosts { totalSamples += len(h.History) } s.log.Info("report received", "client_id", boundedForLog(rpt.ClientID), "timestamp", boundedForLog(rpt.Timestamp), "host_count", len(rpt.Hosts), "total_samples", totalSamples, "geo_bytes", len(rpt.Geo), ) } // boundedForLog truncates an untrusted string to a fixed byte // bound so an attacker-controlled field cannot dominate the log. func boundedForLog(s string) string { if len(s) > maxLoggedFieldBytes { return s[:maxLoggedFieldBytes] } return s }