Files
netwatch/backend/internal/handlers/report.go
T
clawbot ea66caf338
check / check (push) Successful in 11s
fix(backend): rate-limit and cap report ingest, drop wildcard CORS (closes #20)
POST /api/v1/reports stays unauthenticated but is bounded. Each client
address, as the trusted-proxy logic resolves it, may send
REPORTS_PER_MINUTE reports a minute (default 60, counted by
go-chi/httprate over a sliding minute); past that it gets 429 with
Retry-After. reportbuf refuses a report that would take the report
files past DATA_DIR_MAX_BYTES (default 1 GiB), counting the files
already in DATA_DIR and unwritten reports at their uncompressed size;
the handler answers 507. CORS adds nothing unless CORS_ALLOWED_ORIGINS
lists origins. A limit that is not a positive number, or an origin
that is not a plain scheme://host[:port], stops the server from
starting.

Model: opus-5-5
2026-09-29 04:22:19 +02:00

135 lines
3.3 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"sneak.berlin/go/netwatch/internal/reportbuf"
)
// maxLoggedFieldBytes bounds untrusted text (string fields,
// decode error text) before it is 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.respondJSON(w, r,
&response{Status: "error"},
s.appendErrorStatus(err),
)
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
}
// The decoder's error text can quote request bytes (a whole
// oversized number, for example), so it is bounded too.
s.log.Error("failed to decode report",
"error", boundedForLog(err.Error()),
)
return http.StatusBadRequest
}
// appendErrorStatus logs a failure to store a report and returns
// the status to send: 507 when the report files are at their size
// cap, otherwise 500.
func (s *Handlers) appendErrorStatus(err error) int {
if errors.Is(err, reportbuf.ErrFull) {
s.log.Warn("report refused: report files at their size cap")
return http.StatusInsufficientStorage
}
s.log.Error("failed to buffer report", "error", err)
return http.StatusInternalServerError
}
// 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
}