183 lines
5.7 KiB
Go
183 lines
5.7 KiB
Go
package storage
|
|
|
|
import (
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// PresignGet is what serves a customer their parcel photographs. A parcel photo
|
|
// frames the inside of someone's doorway, so the properties tested here are
|
|
// privacy properties, not formatting ones: the link must expire, and it must
|
|
// never carry the bucket's secret key.
|
|
|
|
func withSpaces(t *testing.T, access, secret string) {
|
|
t.Helper()
|
|
for _, kv := range [][2]string{
|
|
{"DO_SPACES_ACCESS_KEY", access},
|
|
{"DO_SPACES_SECRET_KEY", secret},
|
|
{"DO_SPACES_REGION", "sgp1"},
|
|
{"DO_SPACES_ENDPOINT", "sgp1.digitaloceanspaces.com"},
|
|
{"DO_SPACES_BUCKET", "nearle"},
|
|
{"DO_SPACES_CDN_BASE", "https://images.nearle.app"},
|
|
} {
|
|
key, value := kv[0], kv[1]
|
|
previous, had := os.LookupEnv(key)
|
|
if value == "" {
|
|
_ = os.Unsetenv(key)
|
|
} else {
|
|
_ = os.Setenv(key, value)
|
|
}
|
|
t.Cleanup(func() {
|
|
if had {
|
|
_ = os.Setenv(key, previous)
|
|
} else {
|
|
_ = os.Unsetenv(key)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The signed URL must never contain the secret key. A leaked secret is the
|
|
// whole bucket, not one photo — and this is exactly the mistake the legacy
|
|
// rider app made by shipping the key inside the binary.
|
|
func TestPresignGetNeverLeaksTheSecretKey(t *testing.T) {
|
|
const secret = "s3cr3t-do-not-emit-this-anywhere"
|
|
withSpaces(t, "AKIAEXAMPLE", secret)
|
|
|
|
signed, err := PresignGet("pv/booking-70/parcel-1.jpg", 30*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("PresignGet: %v", err)
|
|
}
|
|
if strings.Contains(signed, secret) {
|
|
t.Fatal("the signed URL contains the secret key")
|
|
}
|
|
if strings.Contains(strings.ToLower(signed), "secret") {
|
|
t.Errorf("suspicious content in the signed URL: %s", signed)
|
|
}
|
|
// The ACCESS key is expected — it identifies the caller, it is not a
|
|
// credential on its own.
|
|
if !strings.Contains(signed, "AKIAEXAMPLE") {
|
|
t.Error("the signed URL carries no credential scope, so it cannot authenticate")
|
|
}
|
|
}
|
|
|
|
// A link that does not expire is a permanent link, which defeats the point of
|
|
// signing it at all.
|
|
func TestPresignGetCarriesAnExpiry(t *testing.T) {
|
|
withSpaces(t, "AKIAEXAMPLE", "shhh")
|
|
|
|
signed, err := PresignGet("pv/abc.jpg", 30*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("PresignGet: %v", err)
|
|
}
|
|
parsed, err := url.Parse(signed)
|
|
if err != nil {
|
|
t.Fatalf("the signed URL does not parse: %v", err)
|
|
}
|
|
q := parsed.Query()
|
|
|
|
if got := q.Get("X-Amz-Expires"); got != "1800" {
|
|
t.Errorf("X-Amz-Expires = %q, want 1800 (30 minutes)", got)
|
|
}
|
|
for _, param := range []string{"X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-SignedHeaders", "X-Amz-Signature"} {
|
|
if q.Get(param) == "" {
|
|
t.Errorf("missing %s — the URL would be rejected by the object store", param)
|
|
}
|
|
}
|
|
if q.Get("X-Amz-Algorithm") != "AWS4-HMAC-SHA256" {
|
|
t.Errorf("unexpected algorithm %q", q.Get("X-Amz-Algorithm"))
|
|
}
|
|
}
|
|
|
|
// A non-positive expiry must fall back to a real one rather than minting a link
|
|
// that is already dead, or worse, one the store treats as unbounded.
|
|
func TestPresignGetDefaultsAZeroExpiry(t *testing.T) {
|
|
withSpaces(t, "AKIAEXAMPLE", "shhh")
|
|
|
|
signed, err := PresignGet("pv/abc.jpg", 0)
|
|
if err != nil {
|
|
t.Fatalf("PresignGet: %v", err)
|
|
}
|
|
parsed, _ := url.Parse(signed)
|
|
if got := parsed.Query().Get("X-Amz-Expires"); got != "900" {
|
|
t.Errorf("X-Amz-Expires = %q on a zero expiry, want the 900s default", got)
|
|
}
|
|
}
|
|
|
|
// Two different objects must produce different signatures. A signature that
|
|
// does not cover the key would let one link fetch any file in the bucket.
|
|
func TestPresignGetSignatureCoversTheObjectKey(t *testing.T) {
|
|
withSpaces(t, "AKIAEXAMPLE", "shhh")
|
|
|
|
a, err := PresignGet("pv/booking-70/parcel-1.jpg", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("PresignGet: %v", err)
|
|
}
|
|
b, err := PresignGet("pv/booking-99/parcel-4.jpg", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("PresignGet: %v", err)
|
|
}
|
|
|
|
sigA, _ := url.Parse(a)
|
|
sigB, _ := url.Parse(b)
|
|
if sigA.Query().Get("X-Amz-Signature") == sigB.Query().Get("X-Amz-Signature") {
|
|
t.Fatal("two different objects produced the same signature — the key is not signed")
|
|
}
|
|
if !strings.Contains(a, "booking-70") || !strings.Contains(b, "booking-99") {
|
|
t.Error("the object key is missing from the URL path")
|
|
}
|
|
}
|
|
|
|
// With no credentials configured it degrades to the plain CDN link rather than
|
|
// failing. An unsigned photo the customer can see beats a receipt with a broken
|
|
// image — and the objects are currently written public-read anyway.
|
|
func TestPresignGetFallsBackToTheCdnWhenUnconfigured(t *testing.T) {
|
|
withSpaces(t, "", "")
|
|
|
|
got, err := PresignGet("pv/abc.jpg", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("PresignGet should degrade, not fail: %v", err)
|
|
}
|
|
if got != "https://images.nearle.app/pv/abc.jpg" {
|
|
t.Errorf("fallback URL = %q, want the plain CDN link", got)
|
|
}
|
|
}
|
|
|
|
// Configured() gates the presign path; it must not claim to be configured on a
|
|
// half-set environment.
|
|
func TestConfiguredRequiresBothKeys(t *testing.T) {
|
|
withSpaces(t, "AKIAEXAMPLE", "")
|
|
if Configured() {
|
|
t.Error("Configured() = true with no secret key")
|
|
}
|
|
|
|
withSpaces(t, "", "shhh")
|
|
if Configured() {
|
|
t.Error("Configured() = true with no access key")
|
|
}
|
|
|
|
withSpaces(t, "AKIAEXAMPLE", "shhh")
|
|
if !Configured() {
|
|
t.Error("Configured() = false with both keys present")
|
|
}
|
|
}
|
|
|
|
// Object keys reach this from user-influenced paths, so the encoder has to
|
|
// survive spaces and reserved characters without breaking the signature.
|
|
func TestEncodePathHandlesAwkwardKeys(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"pv/abc.jpg", "pv/abc.jpg"},
|
|
{"pv/a b.jpg", "pv/a%20b.jpg"},
|
|
{"pv/a+b.jpg", "pv/a%2Bb.jpg"},
|
|
{"pv/sub dir/x.jpg", "pv/sub%20dir/x.jpg"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := encodePath(tc.in); got != tc.want {
|
|
t.Errorf("encodePath(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|