package s3
import (
"net/http"
"net/url"
"strings"
"testing"
"time"
)
// Every presigned URL publishes X-Amz-Content-Sha256, and it is signed.
//
// The payload hash was folded into the canonical request and never put
// on the query string. S3 accepts that — absent the parameter it
// infers UNSIGNED-PAYLOAD for a presigned URL — and R2 does not: it
// answers 400 InvalidRequest, "Missing x-amz-content-sha256".
//
// So on an R2-backed forge every attachment view and download was
// refused BY THE BUCKET, while uploads worked. That asymmetry is what
// hid it: a file could be attached, listed and linked, and the page
// showed an empty frame with no error on it and nothing in the log.
func TestEveryPresignedURLPublishesThePayloadHash(t *testing.T) {
b := Bucket{Endpoint: "https://acct.r2.cloudflarestorage.com", Name: "files",
Region: "auto", AccessKey: "AK", Secret: "SK"}
now := time.Date(2026, 9, 17, 21, 0, 0, 0, time.UTC)
get, err := b.PresignGet("attachments/x", time.Hour, now, "x.png", "image/png", true)
if err != nil {
t.Fatal(err)
}
put, err := b.Presign(http.MethodPut, "attachments/x", time.Minute, now,
map[string]string{"content-type": "image/png"})
if err != nil {
t.Fatal(err)
}
for name, u := range map[string]*url.URL{"GET": get, "PUT": put} {
q := u.Query()
if got := q.Get("X-Amz-Content-Sha256"); got != unsignedPayload {
t.Errorf("FAIL: the presigned %s carries X-Amz-Content-Sha256 %q, want %q — "+
"R2 refuses the request without it", name, got, unsignedPayload)
}
if q.Get("X-Amz-Signature") == "" {
t.Errorf("FAIL: the presigned %s is not signed at all", name)
}
}
// And it is INSIDE the signature: changing it must change the
// signature, or it is an unsigned extra a strict peer may reject.
q := get.Query()
was := q.Get("X-Amz-Signature")
q.Set("X-Amz-Content-Sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
again, err := b.PresignGet("attachments/x", time.Hour, now, "x.png", "image/png", true)
if err != nil {
t.Fatal(err)
}
if again.Query().Get("X-Amz-Signature") != was {
t.Error("FAIL: the same inputs signed differently; the presign is not deterministic")
}
if !strings.Contains(get.RawQuery, "X-Amz-Content-Sha256") {
t.Error("FAIL: the parameter is not on the query string the peer receives")
}
}
// And sign itself stays AWS's canonical form, so the published vector
// keeps checking the ALGORITHM rather than our parameter list.
func TestSignItselfAddsNoParametersOfItsOwn(t *testing.T) {
b := Bucket{Region: "us-east-1", AccessKey: "AKIAIOSFODNN7EXAMPLE",
Secret: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}
q := b.sign(http.MethodGet, "examplebucket.s3.amazonaws.com", "/test.txt",
86400*time.Second, time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC), nil, nil)
if q.Get("X-Amz-Content-Sha256") != "" {
t.Error("FAIL: sign adds the payload-hash parameter itself, which moves it out of " +
"AWS's canonical form and makes the vector test check our list instead of the algorithm")
}
}