auto_signature_v4_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. package s3api
  2. import (
  3. "bytes"
  4. "crypto/md5"
  5. "crypto/sha256"
  6. "encoding/base64"
  7. "encoding/hex"
  8. "errors"
  9. "fmt"
  10. "io"
  11. "net/http"
  12. "net/url"
  13. "sort"
  14. "strconv"
  15. "strings"
  16. "sync"
  17. "testing"
  18. "time"
  19. "unicode/utf8"
  20. "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
  21. "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
  22. "github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
  23. "github.com/stretchr/testify/assert"
  24. )
  25. // TestIsRequestPresignedSignatureV4 - Test validates the logic for presign signature version v4 detection.
  26. func TestIsRequestPresignedSignatureV4(t *testing.T) {
  27. testCases := []struct {
  28. inputQueryKey string
  29. inputQueryValue string
  30. expectedResult bool
  31. }{
  32. // Test case - 1.
  33. // Test case with query key ""X-Amz-Credential" set.
  34. {"", "", false},
  35. // Test case - 2.
  36. {"X-Amz-Credential", "", true},
  37. // Test case - 3.
  38. {"X-Amz-Content-Sha256", "", false},
  39. }
  40. for i, testCase := range testCases {
  41. // creating an input HTTP request.
  42. // Only the query parameters are relevant for this particular test.
  43. inputReq, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
  44. if err != nil {
  45. t.Fatalf("Error initializing input HTTP request: %v", err)
  46. }
  47. q := inputReq.URL.Query()
  48. q.Add(testCase.inputQueryKey, testCase.inputQueryValue)
  49. inputReq.URL.RawQuery = q.Encode()
  50. actualResult := isRequestPresignedSignatureV4(inputReq)
  51. if testCase.expectedResult != actualResult {
  52. t.Errorf("Test %d: Expected the result to `%v`, but instead got `%v`", i+1, testCase.expectedResult, actualResult)
  53. }
  54. }
  55. }
  56. // Tests is requested authenticated function, tests replies for s3 errors.
  57. func TestIsReqAuthenticated(t *testing.T) {
  58. iam := &IdentityAccessManagement{
  59. hashes: make(map[string]*sync.Pool),
  60. hashCounters: make(map[string]*int32),
  61. }
  62. _ = iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
  63. Identities: []*iam_pb.Identity{
  64. {
  65. Name: "someone",
  66. Credentials: []*iam_pb.Credential{
  67. {
  68. AccessKey: "access_key_1",
  69. SecretKey: "secret_key_1",
  70. },
  71. },
  72. Actions: []string{},
  73. },
  74. },
  75. })
  76. // List of test cases for validating http request authentication.
  77. testCases := []struct {
  78. req *http.Request
  79. s3Error s3err.ErrorCode
  80. }{
  81. // When request is unsigned, access denied is returned.
  82. {mustNewRequest(http.MethodGet, "http://127.0.0.1:9000", 0, nil, t), s3err.ErrAccessDenied},
  83. // When request is properly signed, error is none.
  84. {mustNewSignedRequest(http.MethodGet, "http://127.0.0.1:9000", 0, nil, t), s3err.ErrNone},
  85. }
  86. // Validates all testcases.
  87. for i, testCase := range testCases {
  88. if _, s3Error := iam.reqSignatureV4Verify(testCase.req); s3Error != testCase.s3Error {
  89. io.ReadAll(testCase.req.Body)
  90. t.Fatalf("Test %d: Unexpected S3 error: want %d - got %d", i, testCase.s3Error, s3Error)
  91. }
  92. }
  93. }
  94. func TestCheckaAnonymousRequestAuthType(t *testing.T) {
  95. iam := &IdentityAccessManagement{
  96. hashes: make(map[string]*sync.Pool),
  97. hashCounters: make(map[string]*int32),
  98. }
  99. _ = iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
  100. Identities: []*iam_pb.Identity{
  101. {
  102. Name: "anonymous",
  103. Actions: []string{s3_constants.ACTION_READ},
  104. },
  105. },
  106. })
  107. testCases := []struct {
  108. Request *http.Request
  109. ErrCode s3err.ErrorCode
  110. Action Action
  111. }{
  112. {Request: mustNewRequest(http.MethodGet, "http://127.0.0.1:9000/bucket", 0, nil, t), ErrCode: s3err.ErrNone, Action: s3_constants.ACTION_READ},
  113. {Request: mustNewRequest(http.MethodPut, "http://127.0.0.1:9000/bucket", 0, nil, t), ErrCode: s3err.ErrAccessDenied, Action: s3_constants.ACTION_WRITE},
  114. }
  115. for i, testCase := range testCases {
  116. _, s3Error := iam.authRequest(testCase.Request, testCase.Action)
  117. if s3Error != testCase.ErrCode {
  118. t.Errorf("Test %d: Unexpected s3error returned wanted %d, got %d", i, testCase.ErrCode, s3Error)
  119. }
  120. if testCase.Request.Header.Get(s3_constants.AmzAuthType) != "Anonymous" {
  121. t.Errorf("Test %d: Unexpected AuthType returned wanted %s, got %s", i, "Anonymous", testCase.Request.Header.Get(s3_constants.AmzAuthType))
  122. }
  123. }
  124. }
  125. func TestCheckAdminRequestAuthType(t *testing.T) {
  126. iam := &IdentityAccessManagement{
  127. hashes: make(map[string]*sync.Pool),
  128. hashCounters: make(map[string]*int32),
  129. }
  130. _ = iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
  131. Identities: []*iam_pb.Identity{
  132. {
  133. Name: "someone",
  134. Credentials: []*iam_pb.Credential{
  135. {
  136. AccessKey: "access_key_1",
  137. SecretKey: "secret_key_1",
  138. },
  139. },
  140. Actions: []string{},
  141. },
  142. },
  143. })
  144. testCases := []struct {
  145. Request *http.Request
  146. ErrCode s3err.ErrorCode
  147. }{
  148. {Request: mustNewRequest(http.MethodGet, "http://127.0.0.1:9000", 0, nil, t), ErrCode: s3err.ErrAccessDenied},
  149. {Request: mustNewSignedRequest(http.MethodGet, "http://127.0.0.1:9000", 0, nil, t), ErrCode: s3err.ErrNone},
  150. {Request: mustNewPresignedRequest(iam, http.MethodGet, "http://127.0.0.1:9000", 0, nil, t), ErrCode: s3err.ErrNone},
  151. }
  152. for i, testCase := range testCases {
  153. if _, s3Error := iam.reqSignatureV4Verify(testCase.Request); s3Error != testCase.ErrCode {
  154. t.Errorf("Test %d: Unexpected s3error returned wanted %d, got %d", i, testCase.ErrCode, s3Error)
  155. }
  156. }
  157. }
  158. func BenchmarkGetSignature(b *testing.B) {
  159. t := time.Now()
  160. iam := IdentityAccessManagement{
  161. hashes: make(map[string]*sync.Pool),
  162. hashCounters: make(map[string]*int32),
  163. }
  164. b.ReportAllocs()
  165. b.ResetTimer()
  166. for i := 0; i < b.N; i++ {
  167. iam.getSignature("secret-key", t, "us-east-1", "s3", "random data")
  168. }
  169. }
  170. // Provides a fully populated http request instance, fails otherwise.
  171. func mustNewRequest(method string, urlStr string, contentLength int64, body io.ReadSeeker, t *testing.T) *http.Request {
  172. req, err := newTestRequest(method, urlStr, contentLength, body)
  173. if err != nil {
  174. t.Fatalf("Unable to initialize new http request %s", err)
  175. }
  176. return req
  177. }
  178. // This is similar to mustNewRequest but additionally the request
  179. // is signed with AWS Signature V4, fails if not able to do so.
  180. func mustNewSignedRequest(method string, urlStr string, contentLength int64, body io.ReadSeeker, t *testing.T) *http.Request {
  181. req := mustNewRequest(method, urlStr, contentLength, body, t)
  182. cred := &Credential{"access_key_1", "secret_key_1"}
  183. if err := signRequestV4(req, cred.AccessKey, cred.SecretKey); err != nil {
  184. t.Fatalf("Unable to initialized new signed http request %s", err)
  185. }
  186. return req
  187. }
  188. // This is similar to mustNewRequest but additionally the request
  189. // is presigned with AWS Signature V4, fails if not able to do so.
  190. func mustNewPresignedRequest(iam *IdentityAccessManagement, method string, urlStr string, contentLength int64, body io.ReadSeeker, t *testing.T) *http.Request {
  191. req := mustNewRequest(method, urlStr, contentLength, body, t)
  192. cred := &Credential{"access_key_1", "secret_key_1"}
  193. if err := preSignV4(iam, req, cred.AccessKey, cred.SecretKey, int64(10*time.Minute.Seconds())); err != nil {
  194. t.Fatalf("Unable to initialized new signed http request %s", err)
  195. }
  196. return req
  197. }
  198. // Returns new HTTP request object.
  199. func newTestRequest(method, urlStr string, contentLength int64, body io.ReadSeeker) (*http.Request, error) {
  200. if method == "" {
  201. method = http.MethodPost
  202. }
  203. // Save for subsequent use
  204. var hashedPayload string
  205. var md5Base64 string
  206. switch {
  207. case body == nil:
  208. hashedPayload = getSHA256Hash([]byte{})
  209. default:
  210. payloadBytes, err := io.ReadAll(body)
  211. if err != nil {
  212. return nil, err
  213. }
  214. hashedPayload = getSHA256Hash(payloadBytes)
  215. md5Base64 = getMD5HashBase64(payloadBytes)
  216. }
  217. // Seek back to beginning.
  218. if body != nil {
  219. body.Seek(0, 0)
  220. } else {
  221. body = bytes.NewReader([]byte(""))
  222. }
  223. req, err := http.NewRequest(method, urlStr, body)
  224. if err != nil {
  225. return nil, err
  226. }
  227. if md5Base64 != "" {
  228. req.Header.Set("Content-Md5", md5Base64)
  229. }
  230. req.Header.Set("x-amz-content-sha256", hashedPayload)
  231. // Add Content-Length
  232. req.ContentLength = contentLength
  233. return req, nil
  234. }
  235. // getSHA256Hash returns SHA-256 hash in hex encoding of given data.
  236. func getSHA256Hash(data []byte) string {
  237. return hex.EncodeToString(getSHA256Sum(data))
  238. }
  239. // getMD5HashBase64 returns MD5 hash in base64 encoding of given data.
  240. func getMD5HashBase64(data []byte) string {
  241. return base64.StdEncoding.EncodeToString(getMD5Sum(data))
  242. }
  243. // getSHA256Sum returns SHA-256 sum of given data.
  244. func getSHA256Sum(data []byte) []byte {
  245. hash := sha256.New()
  246. hash.Write(data)
  247. return hash.Sum(nil)
  248. }
  249. // getMD5Sum returns MD5 sum of given data.
  250. func getMD5Sum(data []byte) []byte {
  251. hash := md5.New()
  252. hash.Write(data)
  253. return hash.Sum(nil)
  254. }
  255. // getMD5Hash returns MD5 hash in hex encoding of given data.
  256. func getMD5Hash(data []byte) string {
  257. return hex.EncodeToString(getMD5Sum(data))
  258. }
  259. var ignoredHeaders = map[string]bool{
  260. "Authorization": true,
  261. "Content-Type": true,
  262. "Content-Length": true,
  263. "User-Agent": true,
  264. }
  265. // Tests the test helper with an example from the AWS Doc.
  266. // https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
  267. // This time it's a PUT request uploading the file with content "Welcome to Amazon S3."
  268. func TestGetStringToSignPUT(t *testing.T) {
  269. canonicalRequest := `PUT
  270. /test%24file.text
  271. date:Fri, 24 May 2013 00:00:00 GMT
  272. host:examplebucket.s3.amazonaws.com
  273. x-amz-content-sha256:44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072
  274. x-amz-date:20130524T000000Z
  275. x-amz-storage-class:REDUCED_REDUNDANCY
  276. date;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class
  277. 44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072`
  278. date, err := time.Parse(iso8601Format, "20130524T000000Z")
  279. if err != nil {
  280. t.Fatalf("Error parsing date: %v", err)
  281. }
  282. scope := "20130524/us-east-1/s3/aws4_request"
  283. stringToSign := getStringToSign(canonicalRequest, date, scope)
  284. expected := `AWS4-HMAC-SHA256
  285. 20130524T000000Z
  286. 20130524/us-east-1/s3/aws4_request
  287. 9e0e90d9c76de8fa5b200d8c849cd5b8dc7a3be3951ddb7f6a76b4158342019d`
  288. assert.Equal(t, expected, stringToSign)
  289. }
  290. // Tests the test helper with an example from the AWS Doc.
  291. // https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
  292. // The GET request example with empty string hash.
  293. func TestGetStringToSignGETEmptyStringHash(t *testing.T) {
  294. canonicalRequest := `GET
  295. /test.txt
  296. host:examplebucket.s3.amazonaws.com
  297. range:bytes=0-9
  298. x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
  299. x-amz-date:20130524T000000Z
  300. host;range;x-amz-content-sha256;x-amz-date
  301. e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
  302. date, err := time.Parse(iso8601Format, "20130524T000000Z")
  303. if err != nil {
  304. t.Fatalf("Error parsing date: %v", err)
  305. }
  306. scope := "20130524/us-east-1/s3/aws4_request"
  307. stringToSign := getStringToSign(canonicalRequest, date, scope)
  308. expected := `AWS4-HMAC-SHA256
  309. 20130524T000000Z
  310. 20130524/us-east-1/s3/aws4_request
  311. 7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972`
  312. assert.Equal(t, expected, stringToSign)
  313. }
  314. // Sign given request using Signature V4.
  315. func signRequestV4(req *http.Request, accessKey, secretKey string) error {
  316. // Get hashed payload.
  317. hashedPayload := req.Header.Get("x-amz-content-sha256")
  318. if hashedPayload == "" {
  319. return fmt.Errorf("Invalid hashed payload")
  320. }
  321. currTime := time.Now()
  322. // Set x-amz-date.
  323. req.Header.Set("x-amz-date", currTime.Format(iso8601Format))
  324. // Get header map.
  325. headerMap := make(map[string][]string)
  326. for k, vv := range req.Header {
  327. // If request header key is not in ignored headers, then add it.
  328. if _, ok := ignoredHeaders[http.CanonicalHeaderKey(k)]; !ok {
  329. headerMap[strings.ToLower(k)] = vv
  330. }
  331. }
  332. // Get header keys.
  333. headers := []string{"host"}
  334. for k := range headerMap {
  335. headers = append(headers, k)
  336. }
  337. sort.Strings(headers)
  338. region := "us-east-1"
  339. // Get canonical headers.
  340. var buf bytes.Buffer
  341. for _, k := range headers {
  342. buf.WriteString(k)
  343. buf.WriteByte(':')
  344. switch {
  345. case k == "host":
  346. buf.WriteString(req.URL.Host)
  347. fallthrough
  348. default:
  349. for idx, v := range headerMap[k] {
  350. if idx > 0 {
  351. buf.WriteByte(',')
  352. }
  353. buf.WriteString(v)
  354. }
  355. buf.WriteByte('\n')
  356. }
  357. }
  358. canonicalHeaders := buf.String()
  359. // Get signed headers.
  360. signedHeaders := strings.Join(headers, ";")
  361. // Get canonical query string.
  362. req.URL.RawQuery = strings.Replace(req.URL.Query().Encode(), "+", "%20", -1)
  363. // Get canonical URI.
  364. canonicalURI := EncodePath(req.URL.Path)
  365. // Get canonical request.
  366. // canonicalRequest =
  367. // <HTTPMethod>\n
  368. // <CanonicalURI>\n
  369. // <CanonicalQueryString>\n
  370. // <CanonicalHeaders>\n
  371. // <SignedHeaders>\n
  372. // <HashedPayload>
  373. //
  374. canonicalRequest := strings.Join([]string{
  375. req.Method,
  376. canonicalURI,
  377. req.URL.RawQuery,
  378. canonicalHeaders,
  379. signedHeaders,
  380. hashedPayload,
  381. }, "\n")
  382. // Get scope.
  383. scope := strings.Join([]string{
  384. currTime.Format(yyyymmdd),
  385. region,
  386. "s3",
  387. "aws4_request",
  388. }, "/")
  389. stringToSign := "AWS4-HMAC-SHA256" + "\n" + currTime.Format(iso8601Format) + "\n"
  390. stringToSign = stringToSign + scope + "\n"
  391. stringToSign = stringToSign + getSHA256Hash([]byte(canonicalRequest))
  392. date := sumHMAC([]byte("AWS4"+secretKey), []byte(currTime.Format(yyyymmdd)))
  393. regionHMAC := sumHMAC(date, []byte(region))
  394. service := sumHMAC(regionHMAC, []byte("s3"))
  395. signingKey := sumHMAC(service, []byte("aws4_request"))
  396. signature := hex.EncodeToString(sumHMAC(signingKey, []byte(stringToSign)))
  397. // final Authorization header
  398. parts := []string{
  399. "AWS4-HMAC-SHA256" + " Credential=" + accessKey + "/" + scope,
  400. "SignedHeaders=" + signedHeaders,
  401. "Signature=" + signature,
  402. }
  403. auth := strings.Join(parts, ", ")
  404. req.Header.Set("Authorization", auth)
  405. return nil
  406. }
  407. // preSignV4 presign the request, in accordance with
  408. // http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html.
  409. func preSignV4(iam *IdentityAccessManagement, req *http.Request, accessKeyID, secretAccessKey string, expires int64) error {
  410. // Presign is not needed for anonymous credentials.
  411. if accessKeyID == "" || secretAccessKey == "" {
  412. return errors.New("Presign cannot be generated without access and secret keys")
  413. }
  414. region := "us-east-1"
  415. date := time.Now().UTC()
  416. scope := getScope(date, region)
  417. credential := fmt.Sprintf("%s/%s", accessKeyID, scope)
  418. // Set URL query.
  419. query := req.URL.Query()
  420. query.Set("X-Amz-Algorithm", signV4Algorithm)
  421. query.Set("X-Amz-Date", date.Format(iso8601Format))
  422. query.Set("X-Amz-Expires", strconv.FormatInt(expires, 10))
  423. query.Set("X-Amz-SignedHeaders", "host")
  424. query.Set("X-Amz-Credential", credential)
  425. query.Set("X-Amz-Content-Sha256", unsignedPayload)
  426. // "host" is the only header required to be signed for Presigned URLs.
  427. extractedSignedHeaders := make(http.Header)
  428. extractedSignedHeaders.Set("host", req.Host)
  429. queryStr := strings.Replace(query.Encode(), "+", "%20", -1)
  430. canonicalRequest := getCanonicalRequest(extractedSignedHeaders, unsignedPayload, queryStr, req.URL.Path, req.Method)
  431. stringToSign := getStringToSign(canonicalRequest, date, scope)
  432. signature := iam.getSignature(secretAccessKey, date, region, "s3", stringToSign)
  433. req.URL.RawQuery = query.Encode()
  434. // Add signature header to RawQuery.
  435. req.URL.RawQuery += "&X-Amz-Signature=" + url.QueryEscape(signature)
  436. // Construct the final presigned URL.
  437. return nil
  438. }
  439. // EncodePath encode the strings from UTF-8 byte representations to HTML hex escape sequences
  440. //
  441. // This is necessary since regular url.Parse() and url.Encode() functions do not support UTF-8
  442. // non english characters cannot be parsed due to the nature in which url.Encode() is written
  443. //
  444. // This function on the other hand is a direct replacement for url.Encode() technique to support
  445. // pretty much every UTF-8 character.
  446. func EncodePath(pathName string) string {
  447. if reservedObjectNames.MatchString(pathName) {
  448. return pathName
  449. }
  450. var encodedPathname string
  451. for _, s := range pathName {
  452. if 'A' <= s && s <= 'Z' || 'a' <= s && s <= 'z' || '0' <= s && s <= '9' { // §2.3 Unreserved characters (mark)
  453. encodedPathname = encodedPathname + string(s)
  454. continue
  455. }
  456. switch s {
  457. case '-', '_', '.', '~', '/': // §2.3 Unreserved characters (mark)
  458. encodedPathname = encodedPathname + string(s)
  459. continue
  460. default:
  461. len := utf8.RuneLen(s)
  462. if len < 0 {
  463. // if utf8 cannot convert return the same string as is
  464. return pathName
  465. }
  466. u := make([]byte, len)
  467. utf8.EncodeRune(u, s)
  468. for _, r := range u {
  469. hex := hex.EncodeToString([]byte{r})
  470. encodedPathname = encodedPathname + "%" + strings.ToUpper(hex)
  471. }
  472. }
  473. }
  474. return encodedPathname
  475. }