package objectlink import ( "bytes" "context" "crypto/sha256" "encoding/hex" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) // Issue 1592: one helper signs and streams a store link (novox/hq ADR 0468 §3). type roundTrip func(*http.Request) (*http.Response, error) func (f roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } func hexSum(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } // 1592/AC1: AWS's documented query-presign example (a GET of test.txt, 86400 s, 2013-05-24) gives the documented signature. func TestPresignMatchesAWSDocumentedExample(t *testing.T) { now := time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC) query := sign("GET", "examplebucket.s3.amazonaws.com", "/test.txt", "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "us-east-1", now, 86400) const want = "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404" if !strings.HasSuffix(query, "&X-Amz-Signature="+want) { t.Fatalf("the signature differs from AWS's documented one: %s", query) } if !strings.Contains(query, "X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request") { t.Errorf("the credential is not encoded as documented: %s", query) } } // 1592/AC2: an Expires of 0, below 0 or above 600 s is refused; the signed host is the endpoint's host. func TestPresignRefusesBadExpiresAndKeepsTheHost(t *testing.T) { r := Request{Method: "PUT", Endpoint: "http://minio.internal:9000", Bucket: "files", Key: "a b/c.pdf", AccessKey: "ak", SecretKey: "sk", Expires: 10 * time.Minute, Now: time.Unix(1700000000, 0)} link, err := Presign(r) if err != nil { t.Fatal(err) } u, err := url.Parse(link) if err != nil { t.Fatal(err) } if u.Host != "minio.internal:9000" || u.Path != "/files/a b/c.pdf" || u.Query().Get("X-Amz-Expires") != "600" || u.Query().Get("X-Amz-SignedHeaders") != "host" { t.Errorf("the link is %s", link) } for _, e := range []time.Duration{0, -time.Second, 601 * time.Second, time.Hour} { r.Expires = e if _, err := Presign(r); err == nil { t.Errorf("Expires %s was accepted", e) } } } // 1592/AC3: Put streams and answers size and SHA-256 as the server saw them; past the limit it is refused; // a 403 with an S3 error names its code. func TestPutStreamsCountsAndRefuses(t *testing.T) { var gotBody []byte var gotLength int64 store := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotBody, _ = io.ReadAll(r.Body) gotLength = r.ContentLength if r.URL.Query().Get("deny") != "" { w.WriteHeader(403) io.WriteString(w, `SignatureDoesNotMatchnope`) } })) defer store.Close() defer store.CloseClientConnections() data := bytes.Repeat([]byte("0123456789"), 100) // A reader with no length: streamed chunked, counted as it goes. got, err := Put(context.Background(), store.Client(), store.URL+"/b/k", io.MultiReader(bytes.NewReader(data)), 0) if err != nil { t.Fatal(err) } if got.Size != int64(len(gotBody)) || got.SHA256 != hexSum(gotBody) || got.Size != 1000 { t.Errorf("answered %+v, the server received %d bytes %s", got, len(gotBody), hexSum(gotBody)) } // One with a length is sent with it. if _, err := Put(context.Background(), store.Client(), store.URL+"/b/k", bytes.NewReader(data), 1000); err != nil || gotLength != 1000 { t.Errorf("exactly the limit: err %v, length %d", err, gotLength) } // Past the limit, both ways. A stand-in transport reads the body as a store would, so no connection is left half open. reads := &http.Client{Transport: roundTrip(func(r *http.Request) (*http.Response, error) { _, err := io.Copy(io.Discard, r.Body) return nil, err })} for name, body := range map[string]io.Reader{"unknown length": io.MultiReader(bytes.NewReader(data)), "known length": bytes.NewReader(data)} { if _, err := Put(context.Background(), reads, store.URL+"/b/k", body, 999); err == nil || !strings.Contains(err.Error(), "ADR 0468 §3") { t.Errorf("%s: one byte past the limit: %v", name, err) } } _, err = Put(context.Background(), store.Client(), store.URL+"/b/k?deny=1", bytes.NewReader(data), 0) var refused *StoreError if e, ok := err.(*StoreError); !ok || e.Status != 403 || e.Code != "SignatureDoesNotMatch" { t.Fatalf("the refusal is %v", err) } else { refused = e } if !strings.Contains(refused.Error(), "SignatureDoesNotMatch") { t.Errorf("the message does not name the code: %v", refused) } } // 1592/AC4: Get writes the body and answers size and SHA-256; a wrong digest and a document past the limit are errors. func TestGetStreamsChecksAndRefuses(t *testing.T) { data := bytes.Repeat([]byte("abcdefghij"), 100) store := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(data) })) defer store.Close() var out bytes.Buffer got, err := Get(context.Background(), store.Client(), store.URL+"/b/k", &out, 0, hexSum(data)) if err != nil { t.Fatal(err) } if got.Size != 1000 || got.SHA256 != hexSum(data) || !bytes.Equal(out.Bytes(), data) { t.Errorf("answered %+v, wrote %d bytes", got, out.Len()) } if _, err := Get(context.Background(), store.Client(), store.URL+"/b/k", io.Discard, 0, hexSum([]byte("other"))); err == nil { t.Error("a wrong digest was accepted") } if _, err := Get(context.Background(), store.Client(), store.URL+"/b/k", io.Discard, 999, ""); err == nil { t.Error("a document past the limit was accepted") } chunked := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.(http.Flusher).Flush() w.Write(data) })) defer chunked.Close() if _, err := Get(context.Background(), chunked.Client(), chunked.URL+"/b/k", io.Discard, 999, ""); err == nil { t.Error("a chunked document past the limit was accepted") } if _, err := Get(context.Background(), chunked.Client(), chunked.URL+"/b/k", io.Discard, 1000, ""); err != nil { t.Errorf("exactly the limit: %v", err) } }