backend requirements onthe xustomer app
This commit is contained in:
@@ -223,3 +223,77 @@ func awsEncode(s string, encodeSlash bool) string {
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// PresignGet issues a short-lived, signed GET URL for one object.
|
||||
//
|
||||
// Parcel photographs are shown to the customer on the receipt, and a parcel
|
||||
// photograph frames the inside of someone's doorway. A permanent CDN link to
|
||||
// one is a permanent link anybody who ever saw it can keep, so the customer
|
||||
// surface serves these through a signature that expires instead.
|
||||
//
|
||||
// Falls back to the plain CDN URL when the bucket credentials are not
|
||||
// configured: an unsigned photo the customer can see beats a receipt with a
|
||||
// missing image, and the objects are currently written public-read anyway.
|
||||
// Once parcel photos are switched to a private ACL this becomes the only way
|
||||
// to read one — which is the point of routing them through here now.
|
||||
func PresignGet(objectKey string, expiry time.Duration) (string, error) {
|
||||
cfg := loadSpacesConfig()
|
||||
if cfg.accessKey == "" || cfg.secretKey == "" || cfg.bucket == "" {
|
||||
if cfg.cdnBase != "" {
|
||||
return cfg.cdnBase + "/" + objectKey, nil
|
||||
}
|
||||
return "", fmt.Errorf("object storage not configured")
|
||||
}
|
||||
|
||||
const (
|
||||
service = "s3"
|
||||
algorithm = "AWS4-HMAC-SHA256"
|
||||
)
|
||||
|
||||
host := cfg.bucket + "." + cfg.endpoint
|
||||
|
||||
now := time.Now().UTC()
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
dateStamp := now.Format("20060102")
|
||||
expSecs := int(expiry.Seconds())
|
||||
if expSecs <= 0 {
|
||||
expSecs = 900
|
||||
}
|
||||
|
||||
canonicalURI := "/" + encodePath(objectKey)
|
||||
credentialScope := dateStamp + "/" + cfg.region + "/" + service + "/aws4_request"
|
||||
credential := cfg.accessKey + "/" + credentialScope
|
||||
signedHeaders := "host"
|
||||
|
||||
q := [][2]string{
|
||||
{"X-Amz-Algorithm", algorithm},
|
||||
{"X-Amz-Credential", credential},
|
||||
{"X-Amz-Date", amzDate},
|
||||
{"X-Amz-Expires", fmt.Sprintf("%d", expSecs)},
|
||||
{"X-Amz-SignedHeaders", signedHeaders},
|
||||
}
|
||||
canonicalQuery := canonicalizeQuery(q)
|
||||
canonicalHeaders := "host:" + host + "\n"
|
||||
|
||||
canonicalRequest := strings.Join([]string{
|
||||
"GET",
|
||||
canonicalURI,
|
||||
canonicalQuery,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
"UNSIGNED-PAYLOAD",
|
||||
}, "\n")
|
||||
|
||||
stringToSign := strings.Join([]string{
|
||||
algorithm,
|
||||
amzDate,
|
||||
credentialScope,
|
||||
hexSHA256(canonicalRequest),
|
||||
}, "\n")
|
||||
|
||||
signingKey := deriveSigningKey(cfg.secretKey, dateStamp, cfg.region, service)
|
||||
signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign))
|
||||
|
||||
return "https://" + host + canonicalURI + "?" + canonicalQuery +
|
||||
"&X-Amz-Signature=" + signature, nil
|
||||
}
|
||||
|
||||
182
internal/storage/spaces_test.go
Normal file
182
internal/storage/spaces_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user