- 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>
132 lines
4.1 KiB
Go
132 lines
4.1 KiB
Go
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)
|
|
}
|
|
}
|