s3: fix Alibaba OSS "Only support authorization for OSS4-HMAC-SHA256" errors
Some Alibaba Cloud OSS endpoints (dedicated regions in particular) only accept OSS native V4 signatures (OSS4-HMAC-SHA256) and reject the AWS style signatures rclone sends. Alibaba also require OSS V4 signatures for accounts created after March 2025 and buckets created after September 2025. This implements OSS V4 signing as a custom signer plugged into the AWS SDK and enables it by default for the Alibaba provider. It can be controlled with the new --s3-use-oss-v4-auth flag. The signer translates the x-amz-* request headers into their x-oss-* equivalents (including the storage class names) so that they take part in the signature, and translates the x-oss-* response headers back so the SDK can parse them. See: https://forum.rclone.org/t/rclone-v1-74-4-cannot-access-alibaba-oss-endpoint-with-oss4-hmac-sha256-only/54090
This commit is contained in:
1 parent
7357fb82a9
commit
d07036b92a
5 files changed
+629
No files matched your search
@@ -0,0 +1,384 @@
|
||||
// OSS V4 signing (OSS4-HMAC-SHA256) for Alibaba Cloud OSS
|
||||
//
|
||||
// Implemented from
|
||||
// https://www.alibabacloud.com/help/en/oss/developer-reference/recommend-to-use-signature-version-4
|
||||
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
v4signer "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
ossAlgorithm = "OSS4-HMAC-SHA256"
|
||||
ossUnsignedPayload = "UNSIGNED-PAYLOAD"
|
||||
ossAmzPrefix = "x-amz-"
|
||||
ossPrefix = "x-oss-"
|
||||
)
|
||||
|
||||
// ossV4Signer signs requests with OSS native V4 signatures
|
||||
// (OSS4-HMAC-SHA256) as required by Alibaba Cloud OSS endpoints which
|
||||
// don't accept AWS style signatures.
|
||||
//
|
||||
// Implements the HTTPSignerV4 interface.
|
||||
type ossV4Signer struct {
|
||||
region string // signing region for the credential scope
|
||||
product string // signing product - "oss" or "oss-cloudbox"
|
||||
endpointHost string // host of the endpoint to detect virtual hosted style requests
|
||||
}
|
||||
|
||||
// newOSSV4Signer creates an OSS V4 signer from the options.
|
||||
//
|
||||
// The signing region is derived from the endpoint if possible,
|
||||
// otherwise the region option is used.
|
||||
func newOSSV4Signer(opt *Options) *ossV4Signer {
|
||||
host := ossEndpointHost(opt.Endpoint)
|
||||
region, product := ossRegionProduct(host)
|
||||
if region == "" {
|
||||
region = opt.Region
|
||||
}
|
||||
return &ossV4Signer{
|
||||
region: region,
|
||||
product: product,
|
||||
endpointHost: host,
|
||||
}
|
||||
}
|
||||
|
||||
// ossEndpointHost returns the lower case host part of an endpoint
|
||||
// which may or may not have a scheme, or "" if it can't be parsed.
|
||||
func ossEndpointHost(endpoint string) string {
|
||||
if endpoint == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(endpoint, "://") {
|
||||
endpoint = "https://" + endpoint
|
||||
}
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(u.Hostname())
|
||||
}
|
||||
|
||||
// ossRegionProduct derives the signing region and product from an OSS
|
||||
// endpoint host.
|
||||
//
|
||||
// Standard endpoints look like oss-<region>[-internal].aliyuncs.com
|
||||
// and CloudBox endpoints look like
|
||||
// <cb-id>.<region>.oss-cloudbox[-internal].aliyuncs.com where the
|
||||
// signing region is the CloudBox ID.
|
||||
//
|
||||
// It returns region == "" if the region could not be derived, eg for
|
||||
// accelerate endpoints or custom domains.
|
||||
func ossRegionProduct(host string) (region, product string) {
|
||||
product = "oss"
|
||||
labels := strings.Split(host, ".")
|
||||
for _, label := range labels[1:] {
|
||||
if label == "oss-cloudbox" || label == "oss-cloudbox-internal" {
|
||||
return labels[0], "oss-cloudbox"
|
||||
}
|
||||
}
|
||||
if r, ok := strings.CutPrefix(labels[0], "oss-"); ok {
|
||||
r = strings.TrimSuffix(r, "-internal")
|
||||
if r != "" && !strings.HasPrefix(r, "accelerate") {
|
||||
region = r
|
||||
}
|
||||
}
|
||||
return region, product
|
||||
}
|
||||
|
||||
// ossStorageClassToOSS maps AWS style storage class names (and upper
|
||||
// cased native names as produced by SetTier) to the case sensitive
|
||||
// native OSS names. Native names pass through unchanged.
|
||||
var ossStorageClassToOSS = map[string]string{
|
||||
"STANDARD": "Standard",
|
||||
"STANDARD_IA": "IA",
|
||||
"GLACIER": "Archive",
|
||||
"DEEP_ARCHIVE": "ColdArchive",
|
||||
"ARCHIVE": "Archive",
|
||||
"COLDARCHIVE": "ColdArchive",
|
||||
"DEEPCOLDARCHIVE": "DeepColdArchive",
|
||||
}
|
||||
|
||||
// ossStorageClassFromOSS maps native OSS storage class names back to
|
||||
// the AWS style names the S3 compatible interface uses.
|
||||
var ossStorageClassFromOSS = map[string]string{
|
||||
"Standard": "STANDARD",
|
||||
"IA": "STANDARD_IA",
|
||||
"Archive": "GLACIER",
|
||||
"ColdArchive": "DEEP_ARCHIVE",
|
||||
}
|
||||
|
||||
// ossHeaderRename returns the x-oss-* header equivalent to the
|
||||
// x-amz-* header with the given suffix (the part after "x-amz-",
|
||||
// lower case). hasKey should be true if the request refers to an
|
||||
// object rather than a bucket.
|
||||
//
|
||||
// It returns "" for headers which should be dropped because the
|
||||
// signer sets the x-oss-* equivalent itself.
|
||||
func ossHeaderRename(amzSuffix string, hasKey bool) string {
|
||||
switch amzSuffix {
|
||||
case "date", "content-sha256", "security-token":
|
||||
return ""
|
||||
case "acl":
|
||||
// OSS uses different headers for object and bucket ACLs
|
||||
if hasKey {
|
||||
return "x-oss-object-acl"
|
||||
}
|
||||
return "x-oss-acl"
|
||||
case "server-side-encryption-aws-kms-key-id":
|
||||
return "x-oss-server-side-encryption-key-id"
|
||||
}
|
||||
return ossPrefix + amzSuffix
|
||||
}
|
||||
|
||||
// ossTranslateRequest renames the x-amz-* headers the SDK generates
|
||||
// into the x-oss-* equivalents that OSS uses so that they take part
|
||||
// in the signature. OSS requires all x-oss-* headers sent to be
|
||||
// signed.
|
||||
func ossTranslateRequest(req *http.Request, hasKey bool) {
|
||||
var keys []string
|
||||
for k := range req.Header {
|
||||
if strings.HasPrefix(strings.ToLower(k), ossAmzPrefix) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
for _, k := range keys {
|
||||
values := req.Header.Values(k)
|
||||
req.Header.Del(k)
|
||||
newKey := ossHeaderRename(strings.ToLower(k)[len(ossAmzPrefix):], hasKey)
|
||||
if newKey == "" {
|
||||
continue
|
||||
}
|
||||
switch newKey {
|
||||
case "x-oss-copy-source":
|
||||
// OSS wants the copy source to start with a "/"
|
||||
for i, v := range values {
|
||||
if !strings.HasPrefix(v, "/") {
|
||||
values[i] = "/" + v
|
||||
}
|
||||
}
|
||||
case "x-oss-storage-class":
|
||||
// OSS uses different case sensitive storage class names
|
||||
for i, v := range values {
|
||||
if native, ok := ossStorageClassToOSS[v]; ok {
|
||||
values[i] = native
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, v := range values {
|
||||
req.Header.Add(newKey, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ossCanonicalURI returns the canonical URI for the request,
|
||||
// expanding a virtual hosted style bucket in the host back to
|
||||
// /bucket/key form.
|
||||
func (s *ossV4Signer) ossCanonicalURI(u *url.URL) string {
|
||||
path := u.EscapedPath()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
if s.endpointHost != "" && strings.HasSuffix(host, "."+s.endpointHost) {
|
||||
bucket := strings.TrimSuffix(host, "."+s.endpointHost)
|
||||
path = "/" + bucket + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// ossCanonicalQuery returns the canonical query string for the
|
||||
// request. This signs the query parameters exactly as they are sent
|
||||
// on the wire except that "+" is re-encoded as %20 and the
|
||||
// parameters are sorted. Parameters with an empty value are signed
|
||||
// as a bare name without "=".
|
||||
func ossCanonicalQuery(rawQuery string) string {
|
||||
query := strings.ReplaceAll(rawQuery, "+", "%20")
|
||||
type param struct{ name, value string }
|
||||
var params []param
|
||||
for _, part := range strings.Split(query, "&") {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
name, value, _ := strings.Cut(part, "=")
|
||||
params = append(params, param{name, value})
|
||||
}
|
||||
sort.Slice(params, func(i, j int) bool {
|
||||
if params[i].name != params[j].name {
|
||||
return params[i].name < params[j].name
|
||||
}
|
||||
return params[i].value < params[j].value
|
||||
})
|
||||
var b strings.Builder
|
||||
for i, p := range params {
|
||||
if i > 0 {
|
||||
b.WriteByte('&')
|
||||
}
|
||||
b.WriteString(p.name)
|
||||
if p.value != "" {
|
||||
b.WriteByte('=')
|
||||
b.WriteString(p.value)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ossCanonicalHeaders returns the canonical headers for the request.
|
||||
// OSS signs all x-oss-* headers plus content-type and content-md5.
|
||||
func ossCanonicalHeaders(header http.Header) string {
|
||||
var names []string
|
||||
values := make(map[string]string)
|
||||
for k, vs := range header {
|
||||
name := strings.ToLower(k)
|
||||
if name != "content-type" && name != "content-md5" && !strings.HasPrefix(name, ossPrefix) {
|
||||
continue
|
||||
}
|
||||
trimmed := make([]string, len(vs))
|
||||
for i, v := range vs {
|
||||
trimmed[i] = strings.TrimSpace(v)
|
||||
}
|
||||
names = append(names, name)
|
||||
values[name] = strings.Join(trimmed, ",")
|
||||
}
|
||||
sort.Strings(names)
|
||||
var b strings.Builder
|
||||
for _, name := range names {
|
||||
b.WriteString(name)
|
||||
b.WriteByte(':')
|
||||
b.WriteString(values[name])
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ossHMAC returns the HMAC-SHA256 of value with the given key
|
||||
func ossHMAC(key []byte, value string) []byte {
|
||||
h := hmac.New(sha256.New, key)
|
||||
_, _ = h.Write([]byte(value))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// SignHTTP signs the request with an OSS V4 signature.
|
||||
//
|
||||
// It rewrites the AWS style x-amz-* headers into their x-oss-*
|
||||
// equivalents before signing.
|
||||
//
|
||||
// Implements the HTTPSignerV4 interface.
|
||||
func (s *ossV4Signer) SignHTTP(ctx context.Context, credentials aws.Credentials, req *http.Request, payloadHash string, service string, region string, signingTime time.Time, optFns ...func(*v4signer.SignerOptions)) error {
|
||||
uri := s.ossCanonicalURI(req.URL)
|
||||
// an object request has a path after the /bucket/ prefix
|
||||
hasKey := strings.Contains(strings.Trim(uri, "/"), "/")
|
||||
ossTranslateRequest(req, hasKey)
|
||||
|
||||
utcTime := signingTime.UTC()
|
||||
datetime := utcTime.Format("20060102T150405Z")
|
||||
date := utcTime.Format("20060102")
|
||||
req.Header.Set("Date", utcTime.Format(http.TimeFormat))
|
||||
req.Header.Set("x-oss-date", datetime)
|
||||
// OSS only supports unsigned payloads with V4 signatures
|
||||
req.Header.Set("x-oss-content-sha256", ossUnsignedPayload)
|
||||
if credentials.SessionToken != "" {
|
||||
req.Header.Set("x-oss-security-token", credentials.SessionToken)
|
||||
}
|
||||
|
||||
canonicalRequest := strings.Join([]string{
|
||||
req.Method,
|
||||
uri,
|
||||
ossCanonicalQuery(req.URL.RawQuery),
|
||||
ossCanonicalHeaders(req.Header),
|
||||
"", // no additional signed headers
|
||||
ossUnsignedPayload,
|
||||
}, "\n")
|
||||
|
||||
hash := sha256.Sum256([]byte(canonicalRequest))
|
||||
scope := date + "/" + s.region + "/" + s.product + "/aliyun_v4_request"
|
||||
stringToSign := ossAlgorithm + "\n" + datetime + "\n" + scope + "\n" + hex.EncodeToString(hash[:])
|
||||
|
||||
key := ossHMAC([]byte("aliyun_v4"+credentials.SecretAccessKey), date)
|
||||
key = ossHMAC(key, s.region)
|
||||
key = ossHMAC(key, s.product)
|
||||
key = ossHMAC(key, "aliyun_v4_request")
|
||||
signature := hex.EncodeToString(ossHMAC(key, stringToSign))
|
||||
|
||||
req.Header.Set("Authorization", ossAlgorithm+" Credential="+credentials.AccessKeyID+"/"+scope+",Signature="+signature)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ossTranslateResponse copies x-oss-* response headers to their
|
||||
// x-amz-* equivalents so the SDK can parse them, eg for user
|
||||
// metadata, version IDs and storage class.
|
||||
func ossTranslateResponse(header http.Header) {
|
||||
additions := make(map[string][]string)
|
||||
for k, vs := range header {
|
||||
suffix, ok := strings.CutPrefix(strings.ToLower(k), ossPrefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
amzKey := ossAmzPrefix + suffix
|
||||
switch suffix {
|
||||
case "server-side-encryption-key-id":
|
||||
amzKey = "x-amz-server-side-encryption-aws-kms-key-id"
|
||||
case "storage-class":
|
||||
// OSS uses different case sensitive storage class names
|
||||
mapped := make([]string, len(vs))
|
||||
for i, v := range vs {
|
||||
if aws, ok := ossStorageClassFromOSS[v]; ok {
|
||||
mapped[i] = aws
|
||||
} else {
|
||||
mapped[i] = v
|
||||
}
|
||||
}
|
||||
vs = mapped
|
||||
}
|
||||
canonical := textproto.CanonicalMIMEHeaderKey(amzKey)
|
||||
if _, exists := header[canonical]; !exists {
|
||||
additions[canonical] = vs
|
||||
}
|
||||
}
|
||||
for k, vs := range additions {
|
||||
header[k] = vs
|
||||
}
|
||||
}
|
||||
|
||||
// ossTransport is an http.RoundTripper which makes OSS responses
|
||||
// look like S3 responses to the SDK.
|
||||
type ossTransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper
|
||||
func (t *ossTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
if resp != nil {
|
||||
ossTranslateResponse(resp.Header)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// ossFixupResponses returns a copy of client whose responses have
|
||||
// their x-oss-* headers copied to the x-amz-* equivalents.
|
||||
func ossFixupResponses(client *http.Client) *http.Client {
|
||||
newClient := *client
|
||||
if newClient.Transport == nil {
|
||||
newClient.Transport = http.DefaultTransport
|
||||
}
|
||||
newClient.Transport = &ossTransport{base: newClient.Transport}
|
||||
return &newClient
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ossV4TestRequest makes the test request used by the signing test
|
||||
// vectors from the official OSS Go SDK:
|
||||
// https://github.com/aliyun/alibabacloud-oss-go-sdk-v2/blob/master/oss/signer/signer_test.go
|
||||
func ossV4TestRequest(t *testing.T, host string) *http.Request {
|
||||
req, err := http.NewRequest("PUT", "http://"+host+"/1234%2B-/123/1.txt", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("x-oss-head1", "value")
|
||||
req.Header.Add("abc", "value")
|
||||
req.Header.Add("ZAbc", "value")
|
||||
req.Header.Add("XYZ", "value")
|
||||
req.Header.Add("content-type", "text/plain")
|
||||
values := url.Values{}
|
||||
values.Add("param1", "value1")
|
||||
values.Add("+param1", "value3")
|
||||
values.Add("|param1", "value4")
|
||||
values.Add("+param2", "")
|
||||
values.Add("|param2", "")
|
||||
values.Add("param2", "")
|
||||
req.URL.RawQuery = values.Encode()
|
||||
return req
|
||||
}
|
||||
|
||||
func TestOSSV4SignHTTP(t *testing.T) {
|
||||
signer := &ossV4Signer{
|
||||
region: "cn-hangzhou",
|
||||
product: "oss",
|
||||
endpointHost: "oss-cn-hangzhou.aliyuncs.com",
|
||||
}
|
||||
req := ossV4TestRequest(t, "bucket.oss-cn-hangzhou.aliyuncs.com")
|
||||
creds := aws.Credentials{AccessKeyID: "ak", SecretAccessKey: "sk"}
|
||||
err := signer.SignHTTP(context.Background(), creds, req, "", "s3", "us-east-1", time.Unix(1702743657, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "OSS4-HMAC-SHA256 Credential=ak/20231216/cn-hangzhou/oss/aliyun_v4_request,Signature=e21d18daa82167720f9b1047ae7e7f1ce7cb77a31e8203a7d5f4624fa0284afe", req.Header.Get("Authorization"))
|
||||
assert.Equal(t, time.Unix(1702743657, 0).UTC().Format("20060102T150405Z"), req.Header.Get("x-oss-date"))
|
||||
assert.Equal(t, "UNSIGNED-PAYLOAD", req.Header.Get("x-oss-content-sha256"))
|
||||
}
|
||||
|
||||
func TestOSSV4SignHTTPToken(t *testing.T) {
|
||||
signer := &ossV4Signer{
|
||||
region: "cn-hangzhou",
|
||||
product: "oss",
|
||||
endpointHost: "oss-cn-hangzhou.aliyuncs.com",
|
||||
}
|
||||
req := ossV4TestRequest(t, "bucket.oss-cn-hangzhou.aliyuncs.com")
|
||||
creds := aws.Credentials{AccessKeyID: "ak", SecretAccessKey: "sk", SessionToken: "token"}
|
||||
err := signer.SignHTTP(context.Background(), creds, req, "", "s3", "us-east-1", time.Unix(1702784856, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "OSS4-HMAC-SHA256 Credential=ak/20231217/cn-hangzhou/oss/aliyun_v4_request,Signature=b94a3f999cf85bcdc00d332fbd3734ba03e48382c36fa4d5af5df817395bd9ea", req.Header.Get("Authorization"))
|
||||
assert.Equal(t, "token", req.Header.Get("x-oss-security-token"))
|
||||
}
|
||||
|
||||
func TestOSSV4SignHTTPCloudBox(t *testing.T) {
|
||||
signer := &ossV4Signer{
|
||||
region: "cb-123",
|
||||
product: "oss-cloudbox",
|
||||
endpointHost: "cb-123.cn-hangzhou.oss-cloudbox.aliyuncs.com",
|
||||
}
|
||||
req := ossV4TestRequest(t, "bucket.cb-123.cn-hangzhou.oss-cloudbox.aliyuncs.com")
|
||||
creds := aws.Credentials{AccessKeyID: "ak", SecretAccessKey: "sk"}
|
||||
err := signer.SignHTTP(context.Background(), creds, req, "", "s3", "us-east-1", time.Unix(1702743657, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "OSS4-HMAC-SHA256 Credential=ak/20231216/cb-123/oss-cloudbox/aliyun_v4_request,Signature=94ce1f12c17d148ea681030275a94449d3357f5b5b21133996eec80af3e08a43", req.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestOSSRegionProduct(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
host string
|
||||
region string
|
||||
product string
|
||||
}{
|
||||
{"oss-cn-hangzhou.aliyuncs.com", "cn-hangzhou", "oss"},
|
||||
{"oss-cn-hangzhou-internal.aliyuncs.com", "cn-hangzhou", "oss"},
|
||||
{"oss-cn-jinan-acdr-ut-1-internal.aliyuncs.com", "cn-jinan-acdr-ut-1", "oss"},
|
||||
{"oss-us-east-1.aliyuncs.com", "us-east-1", "oss"},
|
||||
{"oss-accelerate.aliyuncs.com", "", "oss"},
|
||||
{"oss-accelerate-overseas.aliyuncs.com", "", "oss"},
|
||||
{"cb-123.cn-hangzhou.oss-cloudbox.aliyuncs.com", "cb-123", "oss-cloudbox"},
|
||||
{"cb-123.cn-hangzhou.oss-cloudbox-internal.aliyuncs.com", "cb-123", "oss-cloudbox"},
|
||||
{"my.custom.domain.example.com", "", "oss"},
|
||||
{"", "", "oss"},
|
||||
} {
|
||||
region, product := ossRegionProduct(test.host)
|
||||
assert.Equal(t, test.region, region, test.host)
|
||||
assert.Equal(t, test.product, product, test.host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSSEndpointHost(t *testing.T) {
|
||||
assert.Equal(t, "oss-cn-hangzhou.aliyuncs.com", ossEndpointHost("oss-cn-hangzhou.aliyuncs.com"))
|
||||
assert.Equal(t, "oss-cn-hangzhou.aliyuncs.com", ossEndpointHost("https://oss-cn-hangzhou.aliyuncs.com"))
|
||||
assert.Equal(t, "oss-cn-hangzhou.aliyuncs.com", ossEndpointHost("http://OSS-cn-hangzhou.aliyuncs.com:8080"))
|
||||
assert.Equal(t, "", ossEndpointHost(""))
|
||||
}
|
||||
|
||||
func TestOSSCanonicalURI(t *testing.T) {
|
||||
signer := &ossV4Signer{endpointHost: "oss-cn-hangzhou.aliyuncs.com"}
|
||||
for _, test := range []struct {
|
||||
url string
|
||||
want string
|
||||
hasKey bool
|
||||
}{
|
||||
// virtual hosted style
|
||||
{"https://bucket.oss-cn-hangzhou.aliyuncs.com/", "/bucket/", false},
|
||||
{"https://bucket.oss-cn-hangzhou.aliyuncs.com/key", "/bucket/key", true},
|
||||
{"https://bucket.oss-cn-hangzhou.aliyuncs.com/dir/key", "/bucket/dir/key", true},
|
||||
{"https://bucket.oss-cn-hangzhou.aliyuncs.com", "/bucket/", false},
|
||||
// path style
|
||||
{"https://oss-cn-hangzhou.aliyuncs.com/", "/", false},
|
||||
{"https://oss-cn-hangzhou.aliyuncs.com/bucket", "/bucket", false},
|
||||
{"https://oss-cn-hangzhou.aliyuncs.com/bucket/key", "/bucket/key", true},
|
||||
} {
|
||||
u, err := url.Parse(test.url)
|
||||
require.NoError(t, err)
|
||||
uri := signer.ossCanonicalURI(u)
|
||||
assert.Equal(t, test.want, uri, test.url)
|
||||
hasKey := strings.Contains(strings.Trim(uri, "/"), "/")
|
||||
assert.Equal(t, test.hasKey, hasKey, test.url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSSTranslateRequest(t *testing.T) {
|
||||
req, err := http.NewRequest("PUT", "https://bucket.oss-cn-hangzhou.aliyuncs.com/key", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-Amz-Meta-Mtime", "1400000000.000000")
|
||||
req.Header.Set("X-Amz-Copy-Source", "bucket/path/file")
|
||||
req.Header.Set("X-Amz-Acl", "private")
|
||||
req.Header.Set("X-Amz-Storage-Class", "GLACIER")
|
||||
req.Header.Set("X-Amz-Date", "junk")
|
||||
req.Header.Set("X-Amz-Content-Sha256", "junk")
|
||||
req.Header.Set("X-Amz-Security-Token", "junk")
|
||||
req.Header.Set("X-Amz-Server-Side-Encryption-Aws-Kms-Key-Id", "keyid")
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
ossTranslateRequest(req, true)
|
||||
assert.Equal(t, "1400000000.000000", req.Header.Get("x-oss-meta-mtime"))
|
||||
assert.Equal(t, "/bucket/path/file", req.Header.Get("x-oss-copy-source"))
|
||||
assert.Equal(t, "private", req.Header.Get("x-oss-object-acl"))
|
||||
assert.Equal(t, "Archive", req.Header.Get("x-oss-storage-class"))
|
||||
assert.Equal(t, "keyid", req.Header.Get("x-oss-server-side-encryption-key-id"))
|
||||
assert.Equal(t, "text/plain", req.Header.Get("Content-Type"))
|
||||
for k := range req.Header {
|
||||
assert.False(t, strings.HasPrefix(strings.ToLower(k), "x-amz-"), k)
|
||||
}
|
||||
|
||||
// a copy source which already has a leading / is left alone
|
||||
req.Header.Set("X-Amz-Copy-Source", "/bucket/path/file")
|
||||
ossTranslateRequest(req, true)
|
||||
assert.Equal(t, "/bucket/path/file", req.Header.Get("x-oss-copy-source"))
|
||||
|
||||
// storage class names are mapped to the native OSS names
|
||||
for _, test := range []struct{ in, want string }{
|
||||
{"STANDARD", "Standard"},
|
||||
{"STANDARD_IA", "IA"},
|
||||
{"GLACIER", "Archive"},
|
||||
{"DEEP_ARCHIVE", "ColdArchive"},
|
||||
{"DEEPCOLDARCHIVE", "DeepColdArchive"},
|
||||
{"Standard", "Standard"},
|
||||
{"IA", "IA"},
|
||||
{"unknown", "unknown"},
|
||||
} {
|
||||
req.Header.Del("x-oss-storage-class")
|
||||
req.Header.Set("X-Amz-Storage-Class", test.in)
|
||||
ossTranslateRequest(req, true)
|
||||
assert.Equal(t, test.want, req.Header.Get("x-oss-storage-class"), test.in)
|
||||
}
|
||||
|
||||
// bucket level requests use x-oss-acl
|
||||
req, err = http.NewRequest("PUT", "https://bucket.oss-cn-hangzhou.aliyuncs.com/", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-Amz-Acl", "private")
|
||||
ossTranslateRequest(req, false)
|
||||
assert.Equal(t, "private", req.Header.Get("x-oss-acl"))
|
||||
}
|
||||
|
||||
func TestOSSTranslateResponse(t *testing.T) {
|
||||
header := http.Header{}
|
||||
header.Set("X-Oss-Meta-Mtime", "1400000000.000000")
|
||||
header.Set("X-Oss-Request-Id", "1234")
|
||||
header.Set("X-Oss-Version-Id", "abcd")
|
||||
header.Set("X-Oss-Storage-Class", "Standard")
|
||||
header.Set("X-Oss-Server-Side-Encryption-Key-Id", "keyid")
|
||||
header.Set("Etag", `"abc"`)
|
||||
ossTranslateResponse(header)
|
||||
assert.Equal(t, "1400000000.000000", header.Get("x-amz-meta-mtime"))
|
||||
assert.Equal(t, "1234", header.Get("x-amz-request-id"))
|
||||
assert.Equal(t, "abcd", header.Get("x-amz-version-id"))
|
||||
assert.Equal(t, "STANDARD", header.Get("x-amz-storage-class"))
|
||||
assert.Equal(t, "keyid", header.Get("x-amz-server-side-encryption-aws-kms-key-id"))
|
||||
// the x-oss- headers are left in place
|
||||
assert.Equal(t, "1234", header.Get("x-oss-request-id"))
|
||||
// an existing x-amz- header is not overwritten
|
||||
header = http.Header{}
|
||||
header.Set("X-Oss-Meta-Mtime", "1")
|
||||
header.Set("X-Amz-Meta-Mtime", "2")
|
||||
ossTranslateResponse(header)
|
||||
assert.Equal(t, "2", header.Get("x-amz-meta-mtime"))
|
||||
}
|
||||
@@ -35,3 +35,4 @@ storage_class:
|
||||
bucket_acl: true
|
||||
quirks:
|
||||
use_multipart_etag: false # multipar ETags differ from AWS
|
||||
use_oss_v4_auth: true # some endpoints only accept OSS V4 signatures
|
||||
@@ -30,6 +30,7 @@ type Quirks struct {
|
||||
MightGzip *bool `yaml:"might_gzip,omitempty"`
|
||||
UseMultipartUploads *bool `yaml:"use_multipart_uploads,omitempty"`
|
||||
UseUnsignedPayload *bool `yaml:"use_unsigned_payload,omitempty"`
|
||||
UseOSSV4Auth *bool `yaml:"use_oss_v4_auth,omitempty"`
|
||||
ListVersionsOldestFirst *bool `yaml:"list_versions_oldest_first,omitempty"`
|
||||
UseXID *bool `yaml:"use_x_id,omitempty"`
|
||||
SignAcceptEncoding *bool `yaml:"sign_accept_encoding,omitempty"`
|
||||
|
||||
@@ -393,6 +393,25 @@ If it is set then rclone will use v2 authentication.
|
||||
Use this only if v4 signatures don't work, e.g. pre Jewel/v10 CEPH.`,
|
||||
Default: false,
|
||||
Advanced: true,
|
||||
}, {
|
||||
Name: "use_oss_v4_auth",
|
||||
Help: `If true use Alibaba Cloud OSS native V4 authentication (OSS4-HMAC-SHA256).
|
||||
|
||||
Some Alibaba Cloud OSS endpoints (for example dedicated regions) only
|
||||
accept OSS V4 signatures and reject AWS style signatures with the
|
||||
error "Only support authorization for OSS4-HMAC-SHA256". Alibaba
|
||||
require OSS V4 signatures for accounts created after March 2025 and
|
||||
buckets created after September 2025.
|
||||
|
||||
This is enabled by default for the Alibaba provider.
|
||||
|
||||
The region for the signature is read from the endpoint if possible,
|
||||
otherwise the region option is used.
|
||||
|
||||
Note that presigned requests (rclone link and use_presigned_request)
|
||||
are still signed with AWS style signatures.`,
|
||||
Default: fs.Tristate{},
|
||||
Advanced: true,
|
||||
}, {
|
||||
Name: "use_dual_stack",
|
||||
Help: `If true use AWS S3 dual-stack endpoint (IPv6 support).
|
||||
@@ -1109,6 +1128,7 @@ type Options struct {
|
||||
UploadConcurrency int `config:"upload_concurrency"`
|
||||
ForcePathStyle bool `config:"force_path_style"`
|
||||
V2Auth bool `config:"v2_auth"`
|
||||
UseOSSV4Auth fs.Tristate `config:"use_oss_v4_auth"`
|
||||
UseAccelerateEndpoint bool `config:"use_accelerate_endpoint"`
|
||||
UseARNRegion bool `config:"use_arn_region"`
|
||||
LeavePartsOnError bool `config:"leave_parts_on_error"`
|
||||
@@ -1586,6 +1606,11 @@ func s3Connection(ctx context.Context, opt *Options, client *http.Client) (s3Cli
|
||||
}
|
||||
|
||||
setQuirks(opt, provider)
|
||||
useOSSV4Auth := opt.UseOSSV4Auth.Value && !opt.V2Auth && opt.Region != "other-v2-signature"
|
||||
if useOSSV4Auth {
|
||||
// Make OSS responses look like S3 responses
|
||||
client = ossFixupResponses(client)
|
||||
}
|
||||
awsConfig.RetryMaxAttempts = ci.LowLevelRetries
|
||||
awsConfig.HTTPClient = client
|
||||
|
||||
@@ -1636,6 +1661,12 @@ func s3Connection(ctx context.Context, opt *Options, client *http.Client) (s3Cli
|
||||
s3Opt.HTTPSignerV4 = &v2Signer{opt: opt}
|
||||
})
|
||||
}
|
||||
} else if useOSSV4Auth {
|
||||
signer := newOSSV4Signer(opt)
|
||||
fs.Debugf(nil, "Using OSS V4 auth with signing region %q", signer.region)
|
||||
options = append(options, func(s3Opt *s3.Options) {
|
||||
s3Opt.HTTPSignerV4 = signer
|
||||
})
|
||||
}
|
||||
|
||||
// Fixup the request if needed
|
||||
@@ -1809,6 +1840,7 @@ func setQuirks(opt *Options, provider *Provider) {
|
||||
opt.UploadCutoff = math.MaxInt64
|
||||
}
|
||||
set(&opt.UseUnsignedPayload, true, provider.Quirks.UseUnsignedPayload)
|
||||
set(&opt.UseOSSV4Auth, false, provider.Quirks.UseOSSV4Auth)
|
||||
set(&opt.UseXID, true, provider.Quirks.UseXID)
|
||||
set(&opt.SignAcceptEncoding, true, provider.Quirks.SignAcceptEncoding)
|
||||
set(&opt.ObjectLockSupported, true, provider.Quirks.ObjectLockSupported)
|
||||
|
||||
Reference in new issue
Block a user