check / check (push) Successful in 11s
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
135 lines
3.3 KiB
Go
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
|
|
}
|