Files
seaweedfs/weed/s3api/s3vectors/limits_test.go
T
Chris Lu 6c473433c5 vector: bound request buffering before authentication and document installation (#12537)
* s3vectors: bound body buffering before authentication

* docs: include vector engine installation and source builds

* s3vectors: prevent MIME sniffing of JSON error responses

* docs: load vector Cargo configuration when building from source

* docs: note the native protoc requirement for Windows vector builds

* docs: make vector source checkout and working directory explicit

* ci: handle deb822 sources during Ubuntu mirror failover

* ci: bound Ubuntu mirror attempts and reject partial index updates
2026-11-10 04:21:56 -08:00

243 lines
8.9 KiB
Go

package s3vectors
import (
"bytes"
"context"
"encoding/json"
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
func TestVectorLimitsResolveResourcesAndPreserveBodies(t *testing.T) {
s := NewServer(nil, headerAuth{}, nil)
s.SetRegion("r")
s.SetAccountID("a")
for _, tc := range []struct {
op, action string
body interface{}
}{
{"PutVectors", s3_constants.ACTION_WRITE, PutVectorsRequest{VectorBucketName: "embeddings", IndexName: "documents", Vectors: []*PutInputVector{{Key: "a", Data: &VectorData{Float32: []float32{1, 2}}}}}},
{"QueryVectors", s3_constants.ACTION_READ, QueryVectorsRequest{IndexArn: s.indexArn("embeddings", "documents"), TopK: 1}},
{"GetVectorBucket", s3_constants.ACTION_READ, GetVectorBucketRequest{VectorBucketArn: s.vectorBucketArn("embeddings")}},
} {
t.Run(tc.op, func(t *testing.T) {
payload, err := json.Marshal(tc.body)
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodPost, "/"+tc.op, bytes.NewReader(payload))
var called bool
s.SetLimiter(func(next http.HandlerFunc, action string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if mux.Vars(r)["bucket"] != "embeddings" || action != tc.action {
t.Fatalf("limit target %v, action %s", mux.Vars(r), action)
}
next(w, r)
}
})
s.instrument(func(w http.ResponseWriter, r *http.Request) {
s.limitRequest(func(w http.ResponseWriter, r *http.Request) {
called = true
var got json.RawMessage
if err := readJSON(r, &got); err != nil || !bytes.Equal(got, payload) {
t.Fatalf("handler body %s: %v", got, err)
}
}, w, r)
})(httptest.NewRecorder(), r)
if !called {
t.Fatal("limited handler was not called")
}
})
}
}
func TestVectorLimitsReturnJSONWithoutCallingHandler(t *testing.T) {
s := NewServer(nil, headerAuth{}, nil)
s.SetLimiter(func(next http.HandlerFunc, action string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s3err.WriteErrorResponse(w, r, s3err.ErrTooManyRequest)
}
})
r := httptest.NewRequest(http.MethodPost, "/PutVectors", bytes.NewBufferString(`{"vectorBucketName":"embeddings","indexName":"documents"}`))
w := httptest.NewRecorder()
s.instrument(func(w http.ResponseWriter, r *http.Request) {
s.limitRequest(func(http.ResponseWriter, *http.Request) { t.Fatal("rejected handler ran") }, w, r)
})(w, r)
var failure Error
if json.Unmarshal(w.Body.Bytes(), &failure) != nil || failure.Code == "" || w.Code < http.StatusBadRequest || w.Header().Get("Content-Type") != "application/x-amz-json-1.1" {
t.Fatalf("limit response: %d %v %s", w.Code, w.Header(), w.Body)
}
}
func TestVectorLimitsIgnoreUnrelatedResourceFields(t *testing.T) {
s := NewServer(nil, headerAuth{}, nil)
for _, tc := range []struct{ op, body, bucket string }{
{"PutVectors", `{"vectorBucketName":"bucket-a","indexName":"idx","resourceArn":"arn:aws:s3vectors:r:a:bucket/bucket-b"}`, "bucket-a"},
{"PutVectors", `{"vectorBucketName":"bucket-a","indexName":"idx","resourceArn":{"ignored":true}}`, "bucket-a"},
{"GetVectorBucket", `{"vectorBucketName":"bucket-a","indexArn":"invalid","indexName":42}`, "bucket-a"},
{"CreateIndex", `{"vectorBucketName":"bucket-a","indexName":"idx","resourceArn":"ignored"}`, "bucket-a"},
{"ListVectorBuckets", `{"vectorBucketName":"ignored","resourceArn":"ignored"}`, ""},
} {
t.Run(tc.op+tc.body, func(t *testing.T) {
called := false
s.SetLimiter(func(next http.HandlerFunc, action string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
called = true
if got := mux.Vars(r)["bucket"]; got != tc.bucket {
t.Fatalf("limited bucket %q, want %q", got, tc.bucket)
}
next(w, r)
}
})
r := httptest.NewRequest(http.MethodPost, "/"+tc.op, bytes.NewBufferString(tc.body))
s.instrument(func(w http.ResponseWriter, r *http.Request) {
s.limitRequest(func(http.ResponseWriter, *http.Request) {}, w, r)
})(httptest.NewRecorder(), r)
if !called {
t.Fatal("limiter was not invoked")
}
})
}
}
func TestVectorLimitsCountChunkedBodyBytes(t *testing.T) {
s := NewServer(nil, headerAuth{}, nil)
body := `{"vectorBucketName":"embeddings","indexName":"documents"}`
var got int64
s.SetLimiter(func(next http.HandlerFunc, action string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { got = r.ContentLength; next(w, r) }
})
r := httptest.NewRequest(http.MethodPost, "/PutVectors", bytes.NewBufferString(body))
r.ContentLength = -1
s.instrument(func(w http.ResponseWriter, r *http.Request) {
s.limitRequest(func(http.ResponseWriter, *http.Request) {}, w, r)
})(httptest.NewRecorder(), r)
if got != int64(len(body)) {
t.Fatalf("limited body length = %d, want %d", got, len(body))
}
if r.ContentLength != -1 {
t.Fatal("mutated original request")
}
}
type admissionAuth struct {
started, release chan struct{}
calls atomic.Int32
}
func (a *admissionAuth) DefaultAllow() bool { return true }
func (a *admissionAuth) AuthenticateRequest(r *http.Request) (string, interface{}, s3err.ErrorCode) {
a.calls.Add(1)
_, _ = io.ReadAll(r.Body)
if r.Header.Get("Block") != "" {
close(a.started)
select {
case <-a.release:
case <-r.Context().Done():
}
}
if r.Header.Get("Deny") != "" {
return "", nil, s3err.ErrSignatureDoesNotMatch
}
return "tester", nil, s3err.ErrNone
}
func TestVectorAdmissionBoundsBufferingBeforeAuthentication(t *testing.T) {
for _, tc := range []struct {
name string
requests, bytes int64
chunked bool
}{
{"request count", 1, 10, false},
{"byte count", 2, 2, false},
{"chunked body", 2, 2, true},
} {
t.Run(tc.name, func(t *testing.T) {
auth := &admissionAuth{started: make(chan struct{}), release: make(chan struct{})}
server := NewServer(nil, auth, nil)
server.SetRequestLimits(tc.requests, tc.bytes)
handler := server.Auth(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })
request := func() *http.Request {
r := httptest.NewRequest(http.MethodPost, "/PutVectors", strings.NewReader("{}"))
if tc.chunked {
r.ContentLength = -1
}
return r
}
first := request()
first.Header.Set("Block", "true")
firstCtx, cancelFirst := context.WithTimeout(t.Context(), 5*time.Second)
defer cancelFirst()
done := make(chan struct{})
go func() { defer close(done); handler(httptest.NewRecorder(), first.WithContext(firstCtx)) }()
select {
case <-auth.started:
case <-firstCtx.Done():
t.Fatal("first request did not enter authentication")
}
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
defer cancel()
waiting := httptest.NewRecorder()
handler(waiting, request().WithContext(ctx))
if waiting.Code != http.StatusRequestTimeout || auth.calls.Load() != 1 {
t.Fatalf("waiting request buffered/authenticated: status %d, calls %d", waiting.Code, auth.calls.Load())
}
close(auth.release)
<-done
denied := request()
denied.Header.Set("Deny", "true")
handler(httptest.NewRecorder(), denied.WithContext(firstCtx))
after := httptest.NewRecorder()
handler(after, request().WithContext(firstCtx))
if after.Code != http.StatusNoContent || auth.calls.Load() != 3 {
t.Fatalf("capacity leaked after success/failure: status %d, calls %d", after.Code, auth.calls.Load())
}
})
}
}
func TestVectorErrorResponsesEscapeHTML(t *testing.T) {
payload := "<script>alert(1)</script>"
for _, limited := range []bool{false, true} {
server := NewServer(nil, headerAuth{}, nil)
operation := payload
body := `{}`
if limited {
operation = "QueryVectors"
body = `{"vectorBucketName":"embeddings","indexName":"documents"}`
server.SetLimiter(func(http.HandlerFunc, string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
_ = xml.NewEncoder(w).Encode(struct {
XMLName xml.Name `xml:"Error"`
Code, Message string
}{Code: "SlowDown", Message: payload})
}
})
}
router := mux.NewRouter()
server.RegisterRoutes(router)
request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
request.Header.Set("X-Amz-Target", operation)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
var failure Error
if response.Code < 400 || json.Unmarshal(response.Body.Bytes(), &failure) != nil || !strings.Contains(failure.Message, payload) {
t.Fatalf("error response lost message: %d %s", response.Code, response.Body)
}
if strings.Contains(response.Body.String(), "<") || response.Header().Get("X-Content-Type-Options") != "nosniff" || response.Header().Get("Content-Type") != "application/x-amz-json-1.1" {
t.Fatalf("unsafe error response: %v %s", response.Header(), response.Body)
}
}
}