package s3
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// AWS's published vector for a presigned GET (SigV4 docs, "Query
// string parameters"): the one key, secret, region and instant whose
// answer is written down, so a wrong canonicalisation fails here and
// not against a steward's bucket.
func TestSignMatchesTheAWSVector(t *testing.T) {
b := Bucket{Region: "us-east-1", AccessKey: "AKIAIOSFODNN7EXAMPLE",
Secret: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}
now := time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC)
q := b.sign(http.MethodGet, "examplebucket.s3.amazonaws.com", "/test.txt", 86400*time.Second, now, nil)
want := map[string]string{
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
"X-Amz-Credential": "AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request",
"X-Amz-Date": "20130524T000000Z",
"X-Amz-Expires": "86400",
"X-Amz-SignedHeaders": "host",
"X-Amz-Signature": "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404",
}
for k, v := range want {
if got := q.Get(k); got != v {
t.Errorf("FAIL: %s = %q, want %q", k, got, v)
}
}
t.Log("ok: the signature matches AWS's vector")
}
// Path style: the bucket is the first path segment, the key follows
// it with its own characters encoded the way the signature saw them,
// and every SigV4 parameter is on the query string.
func TestPresignIsPathStyle(t *testing.T) {
b := Bucket{Endpoint: "https://acct.r2.cloudflarestorage.com", Name: "files", Region: "auto",
AccessKey: "AK", Secret: "SK"}
u, err := b.Presign(http.MethodPut, "attachments/ab cd", time.Minute, time.Now(),
map[string]string{"Content-Type": "image/png", "content-length": "12"})
if err != nil {
t.Fatal(err)
}
if u.Host != "acct.r2.cloudflarestorage.com" || u.EscapedPath() != "/files/attachments/ab%20cd" {
t.Errorf("FAIL: url %s", u)
}
q := u.Query()
for _, k := range []string{"X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-Expires", "X-Amz-SignedHeaders", "X-Amz-Signature"} {
if q.Get(k) == "" {
t.Errorf("FAIL: %s missing from %s", k, u)
}
}
if q.Get("X-Amz-SignedHeaders") != "content-length;content-type;host" {
t.Errorf("FAIL: signed headers %q", q.Get("X-Amz-SignedHeaders"))
}
if strings.Contains(u.String(), "SK") {
t.Error("FAIL: the secret is in the url")
}
if _, err := (Bucket{Endpoint: "ftp://x", Name: "b"}).Presign("GET", "k", time.Minute, time.Now(), nil); err == nil {
t.Error("FAIL: a non-http endpoint presigned")
}
t.Log("ok: path-style url with every SigV4 parameter")
}
// The probe PUTs then DELETEs one object under attachments/, both
// presigned; the server's refusal comes back as the verb, the status
// and the service's code, never the secret.
func TestProbePutsThenDeletes(t *testing.T) {
var verbs, keys []string
var deleteStatus int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
verbs = append(verbs, r.Method)
keys = append(keys, r.URL.Path)
q := r.URL.Query()
if q.Get("X-Amz-Algorithm") != "AWS4-HMAC-SHA256" || q.Get("X-Amz-Signature") == "" ||
!strings.HasPrefix(q.Get("X-Amz-Credential"), "AK/") {
http.Error(w, "unsigned", http.StatusBadRequest)
return
}
if r.Method == http.MethodPut {
body, _ := io.ReadAll(r.Body)
if len(body) == 0 || r.Header.Get("Content-Type") != "text/plain" {
http.Error(w, "no body", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
return
}
if deleteStatus != 0 {
w.WriteHeader(deleteStatus)
io.WriteString(w, `<?xml version="1.0"?><Error><Code>AccessDenied</Code><Message>no</Message></Error>`)
return
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
b := Bucket{Endpoint: srv.URL, Name: "files", Region: "auto", AccessKey: "AK", Secret: "topsecret"}
if err := b.Probe(context.Background(), srv.Client(), time.Now()); err != nil {
t.Fatalf("FAIL: probe: %v", err)
}
if len(verbs) != 2 || verbs[0] != "PUT" || verbs[1] != "DELETE" || keys[0] != keys[1] ||
!strings.HasPrefix(keys[0], "/files/attachments/.probe-") {
t.Fatalf("FAIL: requests %v %v", verbs, keys)
}
deleteStatus = http.StatusForbidden
err := b.Probe(context.Background(), srv.Client(), time.Now())
var pe *ProbeError
if !errors.As(err, &pe) || pe.Verb != "DELETE" || pe.Status != 403 || pe.Code != "AccessDenied" {
t.Fatalf("FAIL: want a DELETE 403 AccessDenied, got %v", err)
}
if strings.Contains(err.Error(), "topsecret") {
t.Fatal("FAIL: the secret is in the error")
}
t.Logf("ok: PUT then DELETE of %s; refusal reads %q", keys[0], err)
}