fix(security): close High/Critical findings from the Stage 8 scan

- Bump github.com/rabbitmq/amqp091-go 1.12.0 -> 1.13.0 (3 Critical, 7 High,
  reachable via rabbitmq.Connect) and golang.org/x/{text,net,crypto},
  klauspost/compress; toolchain go1.26.8, builder image pinned to
  golang:1.26.8-alpine3.24.
- Build Content-Disposition per RFC 6266/8187 (ASCII fallback +
  filename*=UTF-8''), stripping CR/LF, quotes and backslashes (CWE-113).
- Cap request bodies with MaxBytesReader (HTTP_MAX_UPLOAD_BYTES, default
  2 GiB); multipart uploads over the limit return 413 (CWE-400).
- /readyz reports "unavailable" instead of raw backend errors (CWE-209).
- Drop deprecated middleware.RealIP; add X-Content-Type-Options: nosniff.
- docker-compose credentials come from ${VAR:-default}.
- Check the RegisterValidation error; annotate verified gosec false
  positives with #nosec and a reason.

Re-scan: govulncheck 0 reachable, trivy fs/image 0 High/Critical,
gosec High 0. go test -race passes against PostGIS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 21:44:10 +05:00
parent 2544d4e146
commit 5b44cbda2f
22 changed files with 505 additions and 83 deletions

View File

@@ -0,0 +1,131 @@
package http
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-playground/validator/v10"
)
// multipartBody builds a multipart/form-data body with one file part of
// size bytes.
func multipartBody(t *testing.T, size int) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file", "big.tif")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(bytes.Repeat([]byte{'x'}, size)); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
return &buf, mw.FormDataContentType()
}
func TestLimitRequestBodyRejectsOversizedUpload(t *testing.T) {
body, ct := multipartBody(t, 4096)
req := httptest.NewRequest(http.MethodPost, "/landscape/slots/lp-z-lcr", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h := limitRequestBody(1024)(http.HandlerFunc(newLandscapeTestHandler().uploadSlot))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "1024 byte limit") {
t.Fatalf("body %s does not state the limit", rec.Body.String())
}
}
func TestLimitRequestBodyPassesSmallBody(t *testing.T) {
var got []byte
h := limitRequestBody(1024)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
got, err = io.ReadAll(r.Body)
if err != nil {
t.Errorf("read: %v", err)
}
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/", strings.NewReader("hello")))
if string(got) != "hello" {
t.Fatalf("body = %q, want hello", got)
}
}
func TestLimitRequestBodyDisabled(t *testing.T) {
h := limitRequestBody(0)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := io.ReadAll(r.Body); err != nil {
t.Errorf("read with cap disabled: %v", err)
}
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(make([]byte, 1<<20))))
}
func newTestRouter() http.Handler {
v := validator.New(validator.WithRequiredStructEnabled())
return NewRouter(RouterDeps{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Health: NewHealthHandler(nil, nil),
Categories: NewCategoryHandler(nil, v),
Datasets: NewDatasetHandler(nil, v),
DatasetGroups: NewDatasetGroupHandler(nil, v),
Landscape: NewLandscapeHandler(nil, v),
MaxBodyBytes: 1024,
})
}
func TestRouterSetsNosniff(t *testing.T) {
rec := httptest.NewRecorder()
newTestRouter().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
}
}
func TestRouterCapsUploadBody(t *testing.T) {
body, ct := multipartBody(t, 4096)
req := httptest.NewRequest(http.MethodPost, "/landscape/slots/lp-z-lcr", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
newTestRouter().ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String())
}
}
func TestReadyDoesNotLeakCheckErrors(t *testing.T) {
h := NewHealthHandler(map[string]ReadinessCheck{
"postgres": func(context.Context) error {
return errors.New("dial tcp 10.1.2.3:5432: connect: connection refused (user=gis)")
},
"s3": func(context.Context) error { return nil },
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
rec := httptest.NewRecorder()
h.Ready(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
body := rec.Body.String()
if strings.Contains(body, "10.1.2.3") || strings.Contains(body, "dial tcp") {
t.Fatalf("readiness body leaks internal error details: %s", body)
}
if !strings.Contains(body, `"postgres":"unavailable"`) || !strings.Contains(body, `"s3":"ok"`) {
t.Fatalf("unexpected readiness body: %s", body)
}
}