auto_signature_v4_test.go 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633
  1. package s3api
  2. import (
  3. "bytes"
  4. "crypto/md5"
  5. "crypto/sha256"
  6. "encoding/base64"
  7. "encoding/hex"
  8. "fmt"
  9. "io"
  10. "net/http"
  11. "sort"
  12. "strings"
  13. "sync"
  14. "testing"
  15. "time"
  16. "unicode/utf8"
  17. "github.com/gorilla/mux"
  18. "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
  19. "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
  20. "github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
  21. "github.com/stretchr/testify/assert"
  22. )
  23. // TestIsRequestPresignedSignatureV4 - Test validates the logic for presign signature version v4 detection.
  24. func TestIsRequestPresignedSignatureV4(t *testing.T) {
  25. testCases := []struct {
  26. inputQueryKey string
  27. inputQueryValue string
  28. expectedResult bool
  29. }{
  30. // Test case - 1.
  31. // Test case with query key ""X-Amz-Credential" set.
  32. {"", "", false},
  33. // Test case - 2.
  34. {"X-Amz-Credential", "", true},
  35. // Test case - 3.
  36. {"X-Amz-Content-Sha256", "", false},
  37. }
  38. for i, testCase := range testCases {
  39. // creating an input HTTP request.
  40. // Only the query parameters are relevant for this particular test.
  41. inputReq, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
  42. if err != nil {
  43. t.Fatalf("Error initializing input HTTP request: %v", err)
  44. }
  45. q := inputReq.URL.Query()
  46. q.Add(testCase.inputQueryKey, testCase.inputQueryValue)
  47. inputReq.URL.RawQuery = q.Encode()
  48. actualResult := isRequestPresignedSignatureV4(inputReq)
  49. if testCase.expectedResult != actualResult {
  50. t.Errorf("Test %d: Expected the result to `%v`, but instead got `%v`", i+1, testCase.expectedResult, actualResult)
  51. }
  52. }
  53. }
  54. // Tests is requested authenticated function, tests replies for s3 errors.
  55. func TestIsReqAuthenticated(t *testing.T) {
  56. iam := &IdentityAccessManagement{
  57. hashes: make(map[string]*sync.Pool),
  58. hashCounters: make(map[string]*int32),
  59. }
  60. _ = iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
  61. Identities: []*iam_pb.Identity{
  62. {
  63. Name: "someone",
  64. Credentials: []*iam_pb.Credential{
  65. {
  66. AccessKey: "access_key_1",
  67. SecretKey: "secret_key_1",
  68. },
  69. },
  70. Actions: []string{"Read", "Write"},
  71. },
  72. },
  73. })
  74. // List of test cases for validating http request authentication.
  75. testCases := []struct {
  76. req *http.Request
  77. s3Error s3err.ErrorCode
  78. }{
  79. // When request is unsigned, access denied is returned.
  80. {mustNewRequest(http.MethodGet, "http://127.0.0.1:9000", 0, nil, t), s3err.ErrAccessDenied},
  81. // When request is properly signed, error is none.
  82. {mustNewSignedRequest(http.MethodGet, "http://127.0.0.1:9000", 0, nil, t), s3err.ErrNone},
  83. }
  84. // Validates all testcases.
  85. for i, testCase := range testCases {
  86. if _, s3Error := iam.reqSignatureV4Verify(testCase.req); s3Error != testCase.s3Error {
  87. io.ReadAll(testCase.req.Body)
  88. t.Fatalf("Test %d: Unexpected S3 error: want %d - got %d", i, testCase.s3Error, s3Error)
  89. }
  90. }
  91. }
  92. func TestCheckaAnonymousRequestAuthType(t *testing.T) {
  93. iam := &IdentityAccessManagement{
  94. hashes: make(map[string]*sync.Pool),
  95. hashCounters: make(map[string]*int32),
  96. }
  97. _ = iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
  98. Identities: []*iam_pb.Identity{
  99. {
  100. Name: "anonymous",
  101. Actions: []string{s3_constants.ACTION_READ},
  102. },
  103. },
  104. })
  105. testCases := []struct {
  106. Request *http.Request
  107. ErrCode s3err.ErrorCode
  108. Action Action
  109. }{
  110. {Request: mustNewRequest(http.MethodGet, "http://127.0.0.1:9000/bucket", 0, nil, t), ErrCode: s3err.ErrNone, Action: s3_constants.ACTION_READ},
  111. {Request: mustNewRequest(http.MethodPut, "http://127.0.0.1:9000/bucket", 0, nil, t), ErrCode: s3err.ErrAccessDenied, Action: s3_constants.ACTION_WRITE},
  112. }
  113. for i, testCase := range testCases {
  114. _, s3Error := iam.authRequest(testCase.Request, testCase.Action)
  115. if s3Error != testCase.ErrCode {
  116. t.Errorf("Test %d: Unexpected s3error returned wanted %d, got %d", i, testCase.ErrCode, s3Error)
  117. }
  118. if testCase.Request.Header.Get(s3_constants.AmzAuthType) != "Anonymous" {
  119. t.Errorf("Test %d: Unexpected AuthType returned wanted %s, got %s", i, "Anonymous", testCase.Request.Header.Get(s3_constants.AmzAuthType))
  120. }
  121. }
  122. }
  123. func TestCheckAdminRequestAuthType(t *testing.T) {
  124. iam := &IdentityAccessManagement{
  125. hashes: make(map[string]*sync.Pool),
  126. hashCounters: make(map[string]*int32),
  127. }
  128. _ = iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
  129. Identities: []*iam_pb.Identity{
  130. {
  131. Name: "someone",
  132. Credentials: []*iam_pb.Credential{
  133. {
  134. AccessKey: "access_key_1",
  135. SecretKey: "secret_key_1",
  136. },
  137. },
  138. Actions: []string{"Admin", "Read", "Write"},
  139. },
  140. },
  141. })
  142. testCases := []struct {
  143. Request *http.Request
  144. ErrCode s3err.ErrorCode
  145. }{
  146. {Request: mustNewRequest(http.MethodGet, "http://127.0.0.1:9000", 0, nil, t), ErrCode: s3err.ErrAccessDenied},
  147. {Request: mustNewSignedRequest(http.MethodGet, "http://127.0.0.1:9000", 0, nil, t), ErrCode: s3err.ErrNone},
  148. {Request: mustNewPresignedRequest(iam, http.MethodGet, "http://127.0.0.1:9000", 0, nil, t), ErrCode: s3err.ErrNone},
  149. }
  150. for i, testCase := range testCases {
  151. if _, s3Error := iam.reqSignatureV4Verify(testCase.Request); s3Error != testCase.ErrCode {
  152. t.Errorf("Test %d: Unexpected s3error returned wanted %d, got %d", i, testCase.ErrCode, s3Error)
  153. }
  154. }
  155. }
  156. func BenchmarkGetSignature(b *testing.B) {
  157. t := time.Now()
  158. b.ReportAllocs()
  159. b.ResetTimer()
  160. for i := 0; i < b.N; i++ {
  161. signingKey := getSigningKey("secret-key", t.Format(yyyymmdd), "us-east-1", "s3")
  162. getSignature(signingKey, "random data")
  163. }
  164. }
  165. // Provides a fully populated http request instance, fails otherwise.
  166. func mustNewRequest(method string, urlStr string, contentLength int64, body io.ReadSeeker, t *testing.T) *http.Request {
  167. req, err := newTestRequest(method, urlStr, contentLength, body)
  168. if err != nil {
  169. t.Fatalf("Unable to initialize new http request %s", err)
  170. }
  171. return req
  172. }
  173. // This is similar to mustNewRequest but additionally the request
  174. // is signed with AWS Signature V4, fails if not able to do so.
  175. func mustNewSignedRequest(method string, urlStr string, contentLength int64, body io.ReadSeeker, t *testing.T) *http.Request {
  176. req := mustNewRequest(method, urlStr, contentLength, body, t)
  177. cred := &Credential{"access_key_1", "secret_key_1"}
  178. if err := signRequestV4(req, cred.AccessKey, cred.SecretKey); err != nil {
  179. t.Fatalf("Unable to initialized new signed http request %s", err)
  180. }
  181. return req
  182. }
  183. // This is similar to mustNewRequest but additionally the request
  184. // is presigned with AWS Signature V4, fails if not able to do so.
  185. func mustNewPresignedRequest(iam *IdentityAccessManagement, method string, urlStr string, contentLength int64, body io.ReadSeeker, t *testing.T) *http.Request {
  186. req := mustNewRequest(method, urlStr, contentLength, body, t)
  187. cred := &Credential{"access_key_1", "secret_key_1"}
  188. if err := preSignV4(iam, req, cred.AccessKey, cred.SecretKey, int64(10*time.Minute.Seconds())); err != nil {
  189. t.Fatalf("Unable to initialized new signed http request %s", err)
  190. }
  191. return req
  192. }
  193. // preSignV4 adds presigned URL parameters to the request
  194. func preSignV4(iam *IdentityAccessManagement, req *http.Request, accessKey, secretKey string, expires int64) error {
  195. // Create credential scope
  196. now := time.Now().UTC()
  197. dateStr := now.Format(iso8601Format)
  198. // Create credential header
  199. scope := fmt.Sprintf("%s/%s/%s/%s", now.Format(yyyymmdd), "us-east-1", "s3", "aws4_request")
  200. credential := fmt.Sprintf("%s/%s", accessKey, scope)
  201. // Get the query parameters
  202. query := req.URL.Query()
  203. query.Set("X-Amz-Algorithm", signV4Algorithm)
  204. query.Set("X-Amz-Credential", credential)
  205. query.Set("X-Amz-Date", dateStr)
  206. query.Set("X-Amz-Expires", fmt.Sprintf("%d", expires))
  207. query.Set("X-Amz-SignedHeaders", "host")
  208. // Set the query on the URL (without signature yet)
  209. req.URL.RawQuery = query.Encode()
  210. // Get the payload hash
  211. hashedPayload := getContentSha256Cksum(req)
  212. // Extract signed headers
  213. extractedSignedHeaders := make(http.Header)
  214. extractedSignedHeaders["host"] = []string{req.Host}
  215. // Get canonical request
  216. canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, req.URL.RawQuery, req.URL.Path, req.Method)
  217. // Get string to sign
  218. stringToSign := getStringToSign(canonicalRequest, now, scope)
  219. // Get signing key
  220. signingKey := getSigningKey(secretKey, now.Format(yyyymmdd), "us-east-1", "s3")
  221. // Calculate signature
  222. signature := getSignature(signingKey, stringToSign)
  223. // Add signature to query
  224. query.Set("X-Amz-Signature", signature)
  225. req.URL.RawQuery = query.Encode()
  226. return nil
  227. }
  228. // newTestIAM creates a test IAM with a standard test user
  229. func newTestIAM() *IdentityAccessManagement {
  230. iam := &IdentityAccessManagement{}
  231. iam.identities = []*Identity{
  232. {
  233. Name: "testuser",
  234. Credentials: []*Credential{{AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}},
  235. Actions: []Action{s3_constants.ACTION_ADMIN, s3_constants.ACTION_READ, s3_constants.ACTION_WRITE},
  236. },
  237. }
  238. // Initialize the access key map for lookup
  239. iam.accessKeyIdent = make(map[string]*Identity)
  240. iam.accessKeyIdent["AKIAIOSFODNN7EXAMPLE"] = iam.identities[0]
  241. return iam
  242. }
  243. // Test X-Forwarded-Prefix support for reverse proxy scenarios
  244. func TestSignatureV4WithForwardedPrefix(t *testing.T) {
  245. tests := []struct {
  246. name string
  247. forwardedPrefix string
  248. expectedPath string
  249. }{
  250. {
  251. name: "prefix without trailing slash",
  252. forwardedPrefix: "/s3",
  253. expectedPath: "/s3/test-bucket/test-object",
  254. },
  255. {
  256. name: "prefix with trailing slash",
  257. forwardedPrefix: "/s3/",
  258. expectedPath: "/s3/test-bucket/test-object",
  259. },
  260. }
  261. for _, tt := range tests {
  262. t.Run(tt.name, func(t *testing.T) {
  263. iam := newTestIAM()
  264. // Create a request with X-Forwarded-Prefix header
  265. r, err := newTestRequest("GET", "https://example.com/test-bucket/test-object", 0, nil)
  266. if err != nil {
  267. t.Fatalf("Failed to create test request: %v", err)
  268. }
  269. // Set the mux variables manually since we're not going through the actual router
  270. r = mux.SetURLVars(r, map[string]string{
  271. "bucket": "test-bucket",
  272. "object": "test-object",
  273. })
  274. r.Header.Set("X-Forwarded-Prefix", tt.forwardedPrefix)
  275. r.Header.Set("Host", "example.com")
  276. r.Header.Set("X-Forwarded-Host", "example.com")
  277. // Sign the request with the expected normalized path
  278. signV4WithPath(r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", tt.expectedPath)
  279. // Test signature verification
  280. _, errCode := iam.doesSignatureMatch(getContentSha256Cksum(r), r)
  281. if errCode != s3err.ErrNone {
  282. t.Errorf("Expected successful signature validation with X-Forwarded-Prefix %q, got error: %v (code: %d)", tt.forwardedPrefix, errCode, int(errCode))
  283. }
  284. })
  285. }
  286. }
  287. // Test X-Forwarded-Prefix with trailing slash preservation (GitHub issue #7223)
  288. // This tests the specific bug where S3 SDK signs paths with trailing slashes
  289. // but path.Clean() would remove them, causing signature verification to fail
  290. func TestSignatureV4WithForwardedPrefixTrailingSlash(t *testing.T) {
  291. tests := []struct {
  292. name string
  293. forwardedPrefix string
  294. urlPath string
  295. expectedPath string
  296. }{
  297. {
  298. name: "bucket listObjects with trailing slash",
  299. forwardedPrefix: "/oss-sf-nnct",
  300. urlPath: "/s3user-bucket1/",
  301. expectedPath: "/oss-sf-nnct/s3user-bucket1/",
  302. },
  303. {
  304. name: "prefix path with trailing slash",
  305. forwardedPrefix: "/s3",
  306. urlPath: "/my-bucket/folder/",
  307. expectedPath: "/s3/my-bucket/folder/",
  308. },
  309. {
  310. name: "root bucket with trailing slash",
  311. forwardedPrefix: "/api/s3",
  312. urlPath: "/test-bucket/",
  313. expectedPath: "/api/s3/test-bucket/",
  314. },
  315. {
  316. name: "nested folder with trailing slash",
  317. forwardedPrefix: "/storage",
  318. urlPath: "/bucket/path/to/folder/",
  319. expectedPath: "/storage/bucket/path/to/folder/",
  320. },
  321. }
  322. for _, tt := range tests {
  323. t.Run(tt.name, func(t *testing.T) {
  324. iam := newTestIAM()
  325. // Create a request with the URL path that has a trailing slash
  326. r, err := newTestRequest("GET", "https://example.com"+tt.urlPath, 0, nil)
  327. if err != nil {
  328. t.Fatalf("Failed to create test request: %v", err)
  329. }
  330. // Manually set the URL path with trailing slash to ensure it's preserved
  331. r.URL.Path = tt.urlPath
  332. r.Header.Set("X-Forwarded-Prefix", tt.forwardedPrefix)
  333. r.Header.Set("Host", "example.com")
  334. r.Header.Set("X-Forwarded-Host", "example.com")
  335. // Sign the request with the full path including the trailing slash
  336. // This simulates what S3 SDK does for listObjects operations
  337. signV4WithPath(r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", tt.expectedPath)
  338. // Test signature verification - this should succeed even with trailing slashes
  339. _, errCode := iam.doesSignatureMatch(getContentSha256Cksum(r), r)
  340. if errCode != s3err.ErrNone {
  341. t.Errorf("Expected successful signature validation with trailing slash in path %q, got error: %v (code: %d)", tt.urlPath, errCode, int(errCode))
  342. }
  343. })
  344. }
  345. }
  346. // Test X-Forwarded-Port support for reverse proxy scenarios
  347. func TestSignatureV4WithForwardedPort(t *testing.T) {
  348. tests := []struct {
  349. name string
  350. host string
  351. forwardedHost string
  352. forwardedPort string
  353. forwardedProto string
  354. expectedHost string
  355. }{
  356. {
  357. name: "HTTP with non-standard port",
  358. host: "backend:8333",
  359. forwardedHost: "example.com",
  360. forwardedPort: "8080",
  361. forwardedProto: "http",
  362. expectedHost: "example.com:8080",
  363. },
  364. {
  365. name: "HTTPS with non-standard port",
  366. host: "backend:8333",
  367. forwardedHost: "example.com",
  368. forwardedPort: "8443",
  369. forwardedProto: "https",
  370. expectedHost: "example.com:8443",
  371. },
  372. {
  373. name: "HTTP with standard port (80)",
  374. host: "backend:8333",
  375. forwardedHost: "example.com",
  376. forwardedPort: "80",
  377. forwardedProto: "http",
  378. expectedHost: "example.com",
  379. },
  380. {
  381. name: "HTTPS with standard port (443)",
  382. host: "backend:8333",
  383. forwardedHost: "example.com",
  384. forwardedPort: "443",
  385. forwardedProto: "https",
  386. expectedHost: "example.com",
  387. },
  388. {
  389. name: "empty proto with non-standard port",
  390. host: "backend:8333",
  391. forwardedHost: "example.com",
  392. forwardedPort: "8080",
  393. forwardedProto: "",
  394. expectedHost: "example.com:8080",
  395. },
  396. {
  397. name: "empty proto with standard http port",
  398. host: "backend:8333",
  399. forwardedHost: "example.com",
  400. forwardedPort: "80",
  401. forwardedProto: "",
  402. expectedHost: "example.com",
  403. },
  404. }
  405. for _, tt := range tests {
  406. t.Run(tt.name, func(t *testing.T) {
  407. iam := newTestIAM()
  408. // Create a request
  409. r, err := newTestRequest("GET", "https://"+tt.host+"/test-bucket/test-object", 0, nil)
  410. if err != nil {
  411. t.Fatalf("Failed to create test request: %v", err)
  412. }
  413. // Set the mux variables manually since we're not going through the actual router
  414. r = mux.SetURLVars(r, map[string]string{
  415. "bucket": "test-bucket",
  416. "object": "test-object",
  417. })
  418. // Set forwarded headers
  419. r.Header.Set("Host", tt.host)
  420. r.Header.Set("X-Forwarded-Host", tt.forwardedHost)
  421. r.Header.Set("X-Forwarded-Port", tt.forwardedPort)
  422. r.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
  423. // Sign the request with the expected host header
  424. // We need to temporarily modify the Host header for signing
  425. signV4WithPath(r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", r.URL.Path)
  426. // Test signature verification
  427. _, errCode := iam.doesSignatureMatch(getContentSha256Cksum(r), r)
  428. if errCode != s3err.ErrNone {
  429. t.Errorf("Expected successful signature validation with forwarded port, got error: %v (code: %d)", errCode, int(errCode))
  430. }
  431. })
  432. }
  433. }
  434. // Test basic presigned URL functionality without prefix
  435. func TestPresignedSignatureV4Basic(t *testing.T) {
  436. iam := newTestIAM()
  437. // Create a presigned request without X-Forwarded-Prefix header
  438. r, err := newTestRequest("GET", "https://example.com/test-bucket/test-object", 0, nil)
  439. if err != nil {
  440. t.Fatalf("Failed to create test request: %v", err)
  441. }
  442. // Set the mux variables manually since we're not going through the actual router
  443. r = mux.SetURLVars(r, map[string]string{
  444. "bucket": "test-bucket",
  445. "object": "test-object",
  446. })
  447. r.Header.Set("Host", "example.com")
  448. // Create presigned URL with the normal path (no prefix)
  449. err = preSignV4WithPath(iam, r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 3600, r.URL.Path)
  450. if err != nil {
  451. t.Errorf("Failed to presign request: %v", err)
  452. }
  453. // Test presigned signature verification
  454. _, errCode := iam.doesPresignedSignatureMatch(getContentSha256Cksum(r), r)
  455. if errCode != s3err.ErrNone {
  456. t.Errorf("Expected successful presigned signature validation, got error: %v (code: %d)", errCode, int(errCode))
  457. }
  458. }
  459. // Test X-Forwarded-Prefix support for presigned URLs
  460. func TestPresignedSignatureV4WithForwardedPrefix(t *testing.T) {
  461. tests := []struct {
  462. name string
  463. forwardedPrefix string
  464. originalPath string
  465. expectedPath string
  466. }{
  467. {
  468. name: "prefix without trailing slash",
  469. forwardedPrefix: "/s3",
  470. originalPath: "/s3/test-bucket/test-object",
  471. expectedPath: "/s3/test-bucket/test-object",
  472. },
  473. {
  474. name: "prefix with trailing slash",
  475. forwardedPrefix: "/s3/",
  476. originalPath: "/s3/test-bucket/test-object",
  477. expectedPath: "/s3/test-bucket/test-object",
  478. },
  479. }
  480. for _, tt := range tests {
  481. t.Run(tt.name, func(t *testing.T) {
  482. iam := newTestIAM()
  483. // Create a presigned request that simulates reverse proxy scenario:
  484. // 1. Client generates presigned URL with prefixed path
  485. // 2. Proxy strips prefix and forwards to SeaweedFS with X-Forwarded-Prefix header
  486. // Start with the original request URL (what client sees)
  487. r, err := newTestRequest("GET", "https://example.com"+tt.originalPath, 0, nil)
  488. if err != nil {
  489. t.Fatalf("Failed to create test request: %v", err)
  490. }
  491. // Generate presigned URL with the original prefixed path
  492. err = preSignV4WithPath(iam, r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 3600, tt.originalPath)
  493. if err != nil {
  494. t.Errorf("Failed to presign request: %v", err)
  495. return
  496. }
  497. // Now simulate what the reverse proxy does:
  498. // 1. Strip the prefix from the URL path
  499. r.URL.Path = "/test-bucket/test-object"
  500. // 2. Set the mux variables for the stripped path
  501. r = mux.SetURLVars(r, map[string]string{
  502. "bucket": "test-bucket",
  503. "object": "test-object",
  504. })
  505. // 3. Add the forwarded headers
  506. r.Header.Set("X-Forwarded-Prefix", tt.forwardedPrefix)
  507. r.Header.Set("Host", "example.com")
  508. r.Header.Set("X-Forwarded-Host", "example.com")
  509. // Test presigned signature verification
  510. _, errCode := iam.doesPresignedSignatureMatch(getContentSha256Cksum(r), r)
  511. if errCode != s3err.ErrNone {
  512. t.Errorf("Expected successful presigned signature validation with X-Forwarded-Prefix %q, got error: %v (code: %d)", tt.forwardedPrefix, errCode, int(errCode))
  513. }
  514. })
  515. }
  516. }
  517. // Test X-Forwarded-Prefix with trailing slash preservation for presigned URLs (GitHub issue #7223)
  518. func TestPresignedSignatureV4WithForwardedPrefixTrailingSlash(t *testing.T) {
  519. tests := []struct {
  520. name string
  521. forwardedPrefix string
  522. originalPath string
  523. strippedPath string
  524. }{
  525. {
  526. name: "bucket listObjects with trailing slash",
  527. forwardedPrefix: "/oss-sf-nnct",
  528. originalPath: "/oss-sf-nnct/s3user-bucket1/",
  529. strippedPath: "/s3user-bucket1/",
  530. },
  531. {
  532. name: "prefix path with trailing slash",
  533. forwardedPrefix: "/s3",
  534. originalPath: "/s3/my-bucket/folder/",
  535. strippedPath: "/my-bucket/folder/",
  536. },
  537. {
  538. name: "api path with trailing slash",
  539. forwardedPrefix: "/api/s3",
  540. originalPath: "/api/s3/test-bucket/",
  541. strippedPath: "/test-bucket/",
  542. },
  543. }
  544. for _, tt := range tests {
  545. t.Run(tt.name, func(t *testing.T) {
  546. iam := newTestIAM()
  547. // Create a presigned request that simulates reverse proxy scenario with trailing slashes:
  548. // 1. Client generates presigned URL with prefixed path including trailing slash
  549. // 2. Proxy strips prefix and forwards to SeaweedFS with X-Forwarded-Prefix header
  550. // Start with the original request URL (what client sees) with trailing slash
  551. r, err := newTestRequest("GET", "https://example.com"+tt.originalPath, 0, nil)
  552. if err != nil {
  553. t.Fatalf("Failed to create test request: %v", err)
  554. }
  555. // Generate presigned URL with the original prefixed path including trailing slash
  556. err = preSignV4WithPath(iam, r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 3600, tt.originalPath)
  557. if err != nil {
  558. t.Errorf("Failed to presign request: %v", err)
  559. return
  560. }
  561. // Now simulate what the reverse proxy does:
  562. // 1. Strip the prefix from the URL path but preserve the trailing slash
  563. r.URL.Path = tt.strippedPath
  564. // 2. Add the forwarded headers
  565. r.Header.Set("X-Forwarded-Prefix", tt.forwardedPrefix)
  566. r.Header.Set("Host", "example.com")
  567. r.Header.Set("X-Forwarded-Host", "example.com")
  568. // Test presigned signature verification - this should succeed with trailing slashes
  569. _, errCode := iam.doesPresignedSignatureMatch(getContentSha256Cksum(r), r)
  570. if errCode != s3err.ErrNone {
  571. t.Errorf("Expected successful presigned signature validation with trailing slash in path %q, got error: %v (code: %d)", tt.strippedPath, errCode, int(errCode))
  572. }
  573. })
  574. }
  575. }
  576. // preSignV4WithPath adds presigned URL parameters to the request with a custom path
  577. func preSignV4WithPath(iam *IdentityAccessManagement, req *http.Request, accessKey, secretKey string, expires int64, urlPath string) error {
  578. // Create credential scope
  579. now := time.Now().UTC()
  580. dateStr := now.Format(iso8601Format)
  581. // Create credential header
  582. scope := fmt.Sprintf("%s/%s/%s/%s", now.Format(yyyymmdd), "us-east-1", "s3", "aws4_request")
  583. credential := fmt.Sprintf("%s/%s", accessKey, scope)
  584. // Get the query parameters
  585. query := req.URL.Query()
  586. query.Set("X-Amz-Algorithm", signV4Algorithm)
  587. query.Set("X-Amz-Credential", credential)
  588. query.Set("X-Amz-Date", dateStr)
  589. query.Set("X-Amz-Expires", fmt.Sprintf("%d", expires))
  590. query.Set("X-Amz-SignedHeaders", "host")
  591. // Set the query on the URL (without signature yet)
  592. req.URL.RawQuery = query.Encode()
  593. // Get the payload hash
  594. hashedPayload := getContentSha256Cksum(req)
  595. // Extract signed headers
  596. extractedSignedHeaders := make(http.Header)
  597. extractedSignedHeaders["host"] = []string{extractHostHeader(req)}
  598. // Get canonical request with custom path
  599. canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, req.URL.RawQuery, urlPath, req.Method)
  600. // Get string to sign
  601. stringToSign := getStringToSign(canonicalRequest, now, scope)
  602. // Get signing key
  603. signingKey := getSigningKey(secretKey, now.Format(yyyymmdd), "us-east-1", "s3")
  604. // Calculate signature
  605. signature := getSignature(signingKey, stringToSign)
  606. // Add signature to query
  607. query.Set("X-Amz-Signature", signature)
  608. req.URL.RawQuery = query.Encode()
  609. return nil
  610. }
  611. // signV4WithPath signs a request with a custom path
  612. func signV4WithPath(req *http.Request, accessKey, secretKey, urlPath string) {
  613. // Create credential scope
  614. now := time.Now().UTC()
  615. dateStr := now.Format(iso8601Format)
  616. // Set required headers
  617. req.Header.Set("X-Amz-Date", dateStr)
  618. // Create credential header
  619. scope := fmt.Sprintf("%s/%s/%s/%s", now.Format(yyyymmdd), "us-east-1", "s3", "aws4_request")
  620. credential := fmt.Sprintf("%s/%s", accessKey, scope)
  621. // Get signed headers
  622. signedHeaders := "host;x-amz-date"
  623. // Extract signed headers
  624. extractedSignedHeaders := make(http.Header)
  625. extractedSignedHeaders["host"] = []string{extractHostHeader(req)}
  626. extractedSignedHeaders["x-amz-date"] = []string{dateStr}
  627. // Get the payload hash
  628. hashedPayload := getContentSha256Cksum(req)
  629. // Get canonical request with custom path
  630. canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, req.URL.RawQuery, urlPath, req.Method)
  631. // Get string to sign
  632. stringToSign := getStringToSign(canonicalRequest, now, scope)
  633. // Get signing key
  634. signingKey := getSigningKey(secretKey, now.Format(yyyymmdd), "us-east-1", "s3")
  635. // Calculate signature
  636. signature := getSignature(signingKey, stringToSign)
  637. // Set Authorization header
  638. authorization := fmt.Sprintf("%s Credential=%s, SignedHeaders=%s, Signature=%s",
  639. signV4Algorithm, credential, signedHeaders, signature)
  640. req.Header.Set("Authorization", authorization)
  641. }
  642. // Returns new HTTP request object.
  643. func newTestRequest(method, urlStr string, contentLength int64, body io.ReadSeeker) (*http.Request, error) {
  644. if method == "" {
  645. method = http.MethodPost
  646. }
  647. // Save for subsequent use
  648. var hashedPayload string
  649. var md5Base64 string
  650. switch {
  651. case body == nil:
  652. hashedPayload = getSHA256Hash([]byte{})
  653. default:
  654. payloadBytes, err := io.ReadAll(body)
  655. if err != nil {
  656. return nil, err
  657. }
  658. hashedPayload = getSHA256Hash(payloadBytes)
  659. md5Base64 = getMD5HashBase64(payloadBytes)
  660. }
  661. // Seek back to beginning.
  662. if body != nil {
  663. body.Seek(0, 0)
  664. } else {
  665. body = bytes.NewReader([]byte(""))
  666. }
  667. req, err := http.NewRequest(method, urlStr, body)
  668. if err != nil {
  669. return nil, err
  670. }
  671. if md5Base64 != "" {
  672. req.Header.Set("Content-Md5", md5Base64)
  673. }
  674. req.Header.Set("x-amz-content-sha256", hashedPayload)
  675. // Add Content-Length
  676. req.ContentLength = contentLength
  677. return req, nil
  678. }
  679. // getMD5HashBase64 returns MD5 hash in base64 encoding of given data.
  680. func getMD5HashBase64(data []byte) string {
  681. return base64.StdEncoding.EncodeToString(getMD5Sum(data))
  682. }
  683. // getSHA256Sum returns SHA-256 sum of given data.
  684. func getSHA256Sum(data []byte) []byte {
  685. hash := sha256.New()
  686. hash.Write(data)
  687. return hash.Sum(nil)
  688. }
  689. // getMD5Sum returns MD5 sum of given data.
  690. func getMD5Sum(data []byte) []byte {
  691. hash := md5.New()
  692. hash.Write(data)
  693. return hash.Sum(nil)
  694. }
  695. // getMD5Hash returns MD5 hash in hex encoding of given data.
  696. func getMD5Hash(data []byte) string {
  697. return hex.EncodeToString(getMD5Sum(data))
  698. }
  699. var ignoredHeaders = map[string]bool{
  700. "Authorization": true,
  701. "Content-Type": true,
  702. "Content-Length": true,
  703. "User-Agent": true,
  704. }
  705. // Tests the test helper with an example from the AWS Doc.
  706. // https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
  707. // This time it's a PUT request uploading the file with content "Welcome to Amazon S3."
  708. func TestGetStringToSignPUT(t *testing.T) {
  709. canonicalRequest := `PUT
  710. /test%24file.text
  711. date:Fri, 24 May 2013 00:00:00 GMT
  712. host:examplebucket.s3.amazonaws.com
  713. x-amz-content-sha256:44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072
  714. x-amz-date:20130524T000000Z
  715. x-amz-storage-class:REDUCED_REDUNDANCY
  716. date;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class
  717. 44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072`
  718. date, err := time.Parse(iso8601Format, "20130524T000000Z")
  719. if err != nil {
  720. t.Fatalf("Error parsing date: %v", err)
  721. }
  722. scope := "20130524/us-east-1/s3/aws4_request"
  723. stringToSign := getStringToSign(canonicalRequest, date, scope)
  724. expected := `AWS4-HMAC-SHA256
  725. 20130524T000000Z
  726. 20130524/us-east-1/s3/aws4_request
  727. 9e0e90d9c76de8fa5b200d8c849cd5b8dc7a3be3951ddb7f6a76b4158342019d`
  728. assert.Equal(t, expected, stringToSign)
  729. }
  730. // Tests the test helper with an example from the AWS Doc.
  731. // https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
  732. // The GET request example with empty string hash.
  733. func TestGetStringToSignGETEmptyStringHash(t *testing.T) {
  734. canonicalRequest := `GET
  735. /test.txt
  736. host:examplebucket.s3.amazonaws.com
  737. range:bytes=0-9
  738. x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
  739. x-amz-date:20130524T000000Z
  740. host;range;x-amz-content-sha256;x-amz-date
  741. e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
  742. date, err := time.Parse(iso8601Format, "20130524T000000Z")
  743. if err != nil {
  744. t.Fatalf("Error parsing date: %v", err)
  745. }
  746. scope := "20130524/us-east-1/s3/aws4_request"
  747. stringToSign := getStringToSign(canonicalRequest, date, scope)
  748. expected := `AWS4-HMAC-SHA256
  749. 20130524T000000Z
  750. 20130524/us-east-1/s3/aws4_request
  751. 7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972`
  752. assert.Equal(t, expected, stringToSign)
  753. }
  754. // Sign given request using Signature V4.
  755. func signRequestV4(req *http.Request, accessKey, secretKey string) error {
  756. // Get hashed payload.
  757. hashedPayload := req.Header.Get("x-amz-content-sha256")
  758. if hashedPayload == "" {
  759. return fmt.Errorf("Invalid hashed payload")
  760. }
  761. currTime := time.Now()
  762. // Set x-amz-date.
  763. req.Header.Set("x-amz-date", currTime.Format(iso8601Format))
  764. // Get header map.
  765. headerMap := make(map[string][]string)
  766. for k, vv := range req.Header {
  767. // If request header key is not in ignored headers, then add it.
  768. if _, ok := ignoredHeaders[http.CanonicalHeaderKey(k)]; !ok {
  769. headerMap[strings.ToLower(k)] = vv
  770. }
  771. }
  772. // Get header keys.
  773. headers := []string{"host"}
  774. for k := range headerMap {
  775. headers = append(headers, k)
  776. }
  777. sort.Strings(headers)
  778. region := "us-east-1"
  779. // Get canonical headers.
  780. var buf bytes.Buffer
  781. for _, k := range headers {
  782. buf.WriteString(k)
  783. buf.WriteByte(':')
  784. switch {
  785. case k == "host":
  786. buf.WriteString(req.URL.Host)
  787. fallthrough
  788. default:
  789. for idx, v := range headerMap[k] {
  790. if idx > 0 {
  791. buf.WriteByte(',')
  792. }
  793. buf.WriteString(v)
  794. }
  795. buf.WriteByte('\n')
  796. }
  797. }
  798. canonicalHeaders := buf.String()
  799. // Get signed headers.
  800. signedHeaders := strings.Join(headers, ";")
  801. // Get canonical query string.
  802. req.URL.RawQuery = strings.Replace(req.URL.Query().Encode(), "+", "%20", -1)
  803. // Get canonical URI.
  804. canonicalURI := EncodePath(req.URL.Path)
  805. // Get canonical request.
  806. // canonicalRequest =
  807. // <HTTPMethod>\n
  808. // <CanonicalURI>\n
  809. // <CanonicalQueryString>\n
  810. // <CanonicalHeaders>\n
  811. // <SignedHeaders>\n
  812. // <HashedPayload>
  813. //
  814. canonicalRequest := strings.Join([]string{
  815. req.Method,
  816. canonicalURI,
  817. req.URL.RawQuery,
  818. canonicalHeaders,
  819. signedHeaders,
  820. hashedPayload,
  821. }, "\n")
  822. // Get scope.
  823. scope := strings.Join([]string{
  824. currTime.Format(yyyymmdd),
  825. region,
  826. "s3",
  827. "aws4_request",
  828. }, "/")
  829. stringToSign := "AWS4-HMAC-SHA256" + "\n" + currTime.Format(iso8601Format) + "\n"
  830. stringToSign = stringToSign + scope + "\n"
  831. stringToSign = stringToSign + getSHA256Hash([]byte(canonicalRequest))
  832. date := sumHMAC([]byte("AWS4"+secretKey), []byte(currTime.Format(yyyymmdd)))
  833. regionHMAC := sumHMAC(date, []byte(region))
  834. service := sumHMAC(regionHMAC, []byte("s3"))
  835. signingKey := sumHMAC(service, []byte("aws4_request"))
  836. signature := hex.EncodeToString(sumHMAC(signingKey, []byte(stringToSign)))
  837. // final Authorization header
  838. parts := []string{
  839. "AWS4-HMAC-SHA256" + " Credential=" + accessKey + "/" + scope,
  840. "SignedHeaders=" + signedHeaders,
  841. "Signature=" + signature,
  842. }
  843. auth := strings.Join(parts, ", ")
  844. req.Header.Set("Authorization", auth)
  845. return nil
  846. }
  847. // EncodePath encode the strings from UTF-8 byte representations to HTML hex escape sequences
  848. //
  849. // This is necessary since regular url.Parse() and url.Encode() functions do not support UTF-8
  850. // non english characters cannot be parsed due to the nature in which url.Encode() is written
  851. //
  852. // This function on the other hand is a direct replacement for url.Encode() technique to support
  853. // pretty much every UTF-8 character.
  854. func EncodePath(pathName string) string {
  855. if reservedObjectNames.MatchString(pathName) {
  856. return pathName
  857. }
  858. var encodedPathname string
  859. for _, s := range pathName {
  860. if 'A' <= s && s <= 'Z' || 'a' <= s && s <= 'z' || '0' <= s && s <= '9' { // §2.3 Unreserved characters (mark)
  861. encodedPathname = encodedPathname + string(s)
  862. continue
  863. }
  864. switch s {
  865. case '-', '_', '.', '~', '/': // §2.3 Unreserved characters (mark)
  866. encodedPathname = encodedPathname + string(s)
  867. continue
  868. default:
  869. runeLen := utf8.RuneLen(s)
  870. if runeLen < 0 {
  871. // if utf8 cannot convert return the same string as is
  872. return pathName
  873. }
  874. u := make([]byte, runeLen)
  875. utf8.EncodeRune(u, s)
  876. for _, r := range u {
  877. hex := hex.EncodeToString([]byte{r})
  878. encodedPathname = encodedPathname + "%" + strings.ToUpper(hex)
  879. }
  880. }
  881. }
  882. return encodedPathname
  883. }
  884. // Test that IAM requests correctly compute payload hash from request body
  885. // This addresses the regression described in GitHub issue #7080
  886. func TestIAMPayloadHashComputation(t *testing.T) {
  887. // Create test IAM instance
  888. iam := &IdentityAccessManagement{
  889. hashes: make(map[string]*sync.Pool),
  890. hashCounters: make(map[string]*int32),
  891. }
  892. // Load test configuration with a user
  893. err := iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
  894. Identities: []*iam_pb.Identity{
  895. {
  896. Name: "testuser",
  897. Credentials: []*iam_pb.Credential{
  898. {
  899. AccessKey: "AKIAIOSFODNN7EXAMPLE",
  900. SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  901. },
  902. },
  903. Actions: []string{"Admin"},
  904. },
  905. },
  906. })
  907. assert.NoError(t, err)
  908. // Test payload for IAM request (typical CreateAccessKey request)
  909. testPayload := "Action=CreateAccessKey&UserName=testuser&Version=2010-05-08"
  910. // Create request with body (typical IAM request)
  911. req, err := http.NewRequest("POST", "http://localhost:8111/", strings.NewReader(testPayload))
  912. assert.NoError(t, err)
  913. // Set required headers for IAM request
  914. req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
  915. req.Header.Set("Host", "localhost:8111")
  916. // Compute expected payload hash
  917. expectedHash := sha256.Sum256([]byte(testPayload))
  918. expectedHashStr := hex.EncodeToString(expectedHash[:])
  919. // Create an IAM-style authorization header with "iam" service instead of "s3"
  920. now := time.Now().UTC()
  921. dateStr := now.Format("20060102T150405Z")
  922. credentialScope := now.Format("20060102") + "/us-east-1/iam/aws4_request"
  923. req.Header.Set("X-Amz-Date", dateStr)
  924. // Create authorization header with "iam" service (this is the key difference from S3)
  925. authHeader := "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/" + credentialScope +
  926. ", SignedHeaders=content-type;host;x-amz-date, Signature=dummysignature"
  927. req.Header.Set("Authorization", authHeader)
  928. // Test the doesSignatureMatch function directly
  929. // This should now compute the correct payload hash for IAM requests
  930. identity, errCode := iam.doesSignatureMatch(expectedHashStr, req)
  931. // Even though the signature will fail (dummy signature),
  932. // the fact that we get past the credential parsing means the payload hash was computed correctly
  933. // We expect ErrSignatureDoesNotMatch because we used a dummy signature,
  934. // but NOT ErrAccessDenied or other auth errors
  935. assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode)
  936. assert.Nil(t, identity)
  937. // More importantly, test that the request body is preserved after reading
  938. // The fix should restore the body after reading it
  939. bodyBytes := make([]byte, len(testPayload))
  940. n, err := req.Body.Read(bodyBytes)
  941. assert.NoError(t, err)
  942. assert.Equal(t, len(testPayload), n)
  943. assert.Equal(t, testPayload, string(bodyBytes))
  944. }
  945. // Test that S3 requests still work correctly (no regression)
  946. func TestS3PayloadHashNoRegression(t *testing.T) {
  947. // Create test IAM instance
  948. iam := &IdentityAccessManagement{
  949. hashes: make(map[string]*sync.Pool),
  950. hashCounters: make(map[string]*int32),
  951. }
  952. // Load test configuration
  953. err := iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
  954. Identities: []*iam_pb.Identity{
  955. {
  956. Name: "testuser",
  957. Credentials: []*iam_pb.Credential{
  958. {
  959. AccessKey: "AKIAIOSFODNN7EXAMPLE",
  960. SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  961. },
  962. },
  963. Actions: []string{"Admin"},
  964. },
  965. },
  966. })
  967. assert.NoError(t, err)
  968. // Create S3 request (no body, should use emptySHA256)
  969. req, err := http.NewRequest("GET", "http://localhost:8333/bucket/object", nil)
  970. assert.NoError(t, err)
  971. req.Header.Set("Host", "localhost:8333")
  972. // Create S3-style authorization header with "s3" service
  973. now := time.Now().UTC()
  974. dateStr := now.Format("20060102T150405Z")
  975. credentialScope := now.Format("20060102") + "/us-east-1/s3/aws4_request"
  976. req.Header.Set("X-Amz-Date", dateStr)
  977. req.Header.Set("X-Amz-Content-Sha256", emptySHA256)
  978. authHeader := "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/" + credentialScope +
  979. ", SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=dummysignature"
  980. req.Header.Set("Authorization", authHeader)
  981. // This should use the emptySHA256 hash and not try to read the body
  982. identity, errCode := iam.doesSignatureMatch(emptySHA256, req)
  983. // Should get signature mismatch (because of dummy signature) but not other errors
  984. assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode)
  985. assert.Nil(t, identity)
  986. }
  987. // Test edge case: IAM request with empty body should still use emptySHA256
  988. func TestIAMEmptyBodyPayloadHash(t *testing.T) {
  989. // Create test IAM instance
  990. iam := &IdentityAccessManagement{
  991. hashes: make(map[string]*sync.Pool),
  992. hashCounters: make(map[string]*int32),
  993. }
  994. // Load test configuration
  995. err := iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
  996. Identities: []*iam_pb.Identity{
  997. {
  998. Name: "testuser",
  999. Credentials: []*iam_pb.Credential{
  1000. {
  1001. AccessKey: "AKIAIOSFODNN7EXAMPLE",
  1002. SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  1003. },
  1004. },
  1005. Actions: []string{"Admin"},
  1006. },
  1007. },
  1008. })
  1009. assert.NoError(t, err)
  1010. // Create IAM request with empty body
  1011. req, err := http.NewRequest("POST", "http://localhost:8111/", bytes.NewReader([]byte{}))
  1012. assert.NoError(t, err)
  1013. req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
  1014. req.Header.Set("Host", "localhost:8111")
  1015. // Create IAM-style authorization header
  1016. now := time.Now().UTC()
  1017. dateStr := now.Format("20060102T150405Z")
  1018. credentialScope := now.Format("20060102") + "/us-east-1/iam/aws4_request"
  1019. req.Header.Set("X-Amz-Date", dateStr)
  1020. authHeader := "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/" + credentialScope +
  1021. ", SignedHeaders=content-type;host;x-amz-date, Signature=dummysignature"
  1022. req.Header.Set("Authorization", authHeader)
  1023. // Even with an IAM request, empty body should result in emptySHA256
  1024. identity, errCode := iam.doesSignatureMatch(emptySHA256, req)
  1025. // Should get signature mismatch (because of dummy signature) but not other errors
  1026. assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode)
  1027. assert.Nil(t, identity)
  1028. }
  1029. // Test that non-S3 services (like STS) also get payload hash computation
  1030. func TestSTSPayloadHashComputation(t *testing.T) {
  1031. // Create test IAM instance
  1032. iam := &IdentityAccessManagement{
  1033. hashes: make(map[string]*sync.Pool),
  1034. hashCounters: make(map[string]*int32),
  1035. }
  1036. // Load test configuration
  1037. err := iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
  1038. Identities: []*iam_pb.Identity{
  1039. {
  1040. Name: "testuser",
  1041. Credentials: []*iam_pb.Credential{
  1042. {
  1043. AccessKey: "AKIAIOSFODNN7EXAMPLE",
  1044. SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  1045. },
  1046. },
  1047. Actions: []string{"Admin"},
  1048. },
  1049. },
  1050. })
  1051. assert.NoError(t, err)
  1052. // Test payload for STS request (AssumeRole request)
  1053. testPayload := "Action=AssumeRole&RoleArn=arn:aws:iam::123456789012:role/TestRole&RoleSessionName=test&Version=2011-06-15"
  1054. // Create request with body (typical STS request)
  1055. req, err := http.NewRequest("POST", "http://localhost:8112/", strings.NewReader(testPayload))
  1056. assert.NoError(t, err)
  1057. // Set required headers for STS request
  1058. req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
  1059. req.Header.Set("Host", "localhost:8112")
  1060. // Compute expected payload hash
  1061. expectedHash := sha256.Sum256([]byte(testPayload))
  1062. expectedHashStr := hex.EncodeToString(expectedHash[:])
  1063. // Create an STS-style authorization header with "sts" service
  1064. now := time.Now().UTC()
  1065. dateStr := now.Format("20060102T150405Z")
  1066. credentialScope := now.Format("20060102") + "/us-east-1/sts/aws4_request"
  1067. req.Header.Set("X-Amz-Date", dateStr)
  1068. authHeader := "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/" + credentialScope +
  1069. ", SignedHeaders=content-type;host;x-amz-date, Signature=dummysignature"
  1070. req.Header.Set("Authorization", authHeader)
  1071. // Test the doesSignatureMatch function
  1072. // This should compute the correct payload hash for STS requests (non-S3 service)
  1073. identity, errCode := iam.doesSignatureMatch(expectedHashStr, req)
  1074. // Should get signature mismatch (dummy signature) but payload hash should be computed correctly
  1075. assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode)
  1076. assert.Nil(t, identity)
  1077. // Verify body is preserved after reading
  1078. bodyBytes := make([]byte, len(testPayload))
  1079. n, err := req.Body.Read(bodyBytes)
  1080. assert.NoError(t, err)
  1081. assert.Equal(t, len(testPayload), n)
  1082. assert.Equal(t, testPayload, string(bodyBytes))
  1083. }
  1084. // Test the specific scenario from GitHub issue #7080
  1085. func TestGitHubIssue7080Scenario(t *testing.T) {
  1086. // Create test IAM instance
  1087. iam := &IdentityAccessManagement{
  1088. hashes: make(map[string]*sync.Pool),
  1089. hashCounters: make(map[string]*int32),
  1090. }
  1091. // Load test configuration matching the issue scenario
  1092. err := iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
  1093. Identities: []*iam_pb.Identity{
  1094. {
  1095. Name: "testuser",
  1096. Credentials: []*iam_pb.Credential{
  1097. {
  1098. AccessKey: "testkey",
  1099. SecretKey: "testsecret",
  1100. },
  1101. },
  1102. Actions: []string{"Admin"},
  1103. },
  1104. },
  1105. })
  1106. assert.NoError(t, err)
  1107. // Simulate the payload from the GitHub issue (CreateAccessKey request)
  1108. testPayload := "Action=CreateAccessKey&UserName=admin&Version=2010-05-08"
  1109. // Create the request that was failing
  1110. req, err := http.NewRequest("POST", "http://localhost:8111/", strings.NewReader(testPayload))
  1111. assert.NoError(t, err)
  1112. req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
  1113. req.Header.Set("Host", "localhost:8111")
  1114. // Create authorization header with IAM service (this was the failing case)
  1115. now := time.Now().UTC()
  1116. dateStr := now.Format("20060102T150405Z")
  1117. credentialScope := now.Format("20060102") + "/us-east-1/iam/aws4_request"
  1118. req.Header.Set("X-Amz-Date", dateStr)
  1119. authHeader := "AWS4-HMAC-SHA256 Credential=testkey/" + credentialScope +
  1120. ", SignedHeaders=content-type;host;x-amz-date, Signature=testsignature"
  1121. req.Header.Set("Authorization", authHeader)
  1122. // Before the fix, this would have failed with payload hash mismatch
  1123. // After the fix, it should properly compute the payload hash and proceed to signature verification
  1124. // Since we're using a dummy signature, we expect signature mismatch, but the important
  1125. // thing is that it doesn't fail earlier due to payload hash computation issues
  1126. identity, errCode := iam.doesSignatureMatch(emptySHA256, req)
  1127. // The error should be signature mismatch, not payload related
  1128. assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode)
  1129. assert.Nil(t, identity)
  1130. // Verify the request body is still accessible (fix preserves body)
  1131. bodyBytes := make([]byte, len(testPayload))
  1132. n, err := req.Body.Read(bodyBytes)
  1133. assert.NoError(t, err)
  1134. assert.Equal(t, len(testPayload), n)
  1135. assert.Equal(t, testPayload, string(bodyBytes))
  1136. }
  1137. // TestIAMSignatureServiceMatching tests that IAM requests use the correct service in signature computation
  1138. // This reproduces the bug described in GitHub issue #7080 where the service was hardcoded to "s3"
  1139. func TestIAMSignatureServiceMatching(t *testing.T) {
  1140. // Create test IAM instance
  1141. iam := &IdentityAccessManagement{}
  1142. // Load test configuration with credentials that match the logs
  1143. err := iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
  1144. Identities: []*iam_pb.Identity{
  1145. {
  1146. Name: "power_user",
  1147. Credentials: []*iam_pb.Credential{
  1148. {
  1149. AccessKey: "power_user_key",
  1150. SecretKey: "power_user_secret",
  1151. },
  1152. },
  1153. Actions: []string{"Admin"},
  1154. },
  1155. },
  1156. })
  1157. assert.NoError(t, err)
  1158. // Use the exact payload and headers from the failing logs
  1159. testPayload := "Action=CreateAccessKey&UserName=admin&Version=2010-05-08"
  1160. // Create request exactly as shown in logs
  1161. req, err := http.NewRequest("POST", "http://localhost:8111/", strings.NewReader(testPayload))
  1162. assert.NoError(t, err)
  1163. req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
  1164. req.Header.Set("Host", "localhost:8111")
  1165. req.Header.Set("X-Amz-Date", "20250805T082934Z")
  1166. // Calculate the expected signature using the correct IAM service
  1167. // This simulates what botocore/AWS SDK would calculate
  1168. credentialScope := "20250805/us-east-1/iam/aws4_request"
  1169. // Calculate the actual payload hash for our test payload
  1170. actualPayloadHash := getSHA256Hash([]byte(testPayload))
  1171. // Build the canonical request with the actual payload hash
  1172. canonicalRequest := "POST\n/\n\ncontent-type:application/x-www-form-urlencoded; charset=utf-8\nhost:localhost:8111\nx-amz-date:20250805T082934Z\n\ncontent-type;host;x-amz-date\n" + actualPayloadHash
  1173. // Calculate the canonical request hash
  1174. canonicalRequestHash := getSHA256Hash([]byte(canonicalRequest))
  1175. // Build the string to sign
  1176. stringToSign := "AWS4-HMAC-SHA256\n20250805T082934Z\n" + credentialScope + "\n" + canonicalRequestHash
  1177. // Calculate expected signature using IAM service (what client sends)
  1178. expectedSigningKey := getSigningKey("power_user_secret", "20250805", "us-east-1", "iam")
  1179. expectedSignature := getSignature(expectedSigningKey, stringToSign)
  1180. // Create authorization header with the correct signature
  1181. authHeader := "AWS4-HMAC-SHA256 Credential=power_user_key/" + credentialScope +
  1182. ", SignedHeaders=content-type;host;x-amz-date, Signature=" + expectedSignature
  1183. req.Header.Set("Authorization", authHeader)
  1184. // Now test that SeaweedFS computes the same signature with our fix
  1185. identity, errCode := iam.doesSignatureMatch(actualPayloadHash, req)
  1186. // With the fix, the signatures should match and we should get a successful authentication
  1187. assert.Equal(t, s3err.ErrNone, errCode)
  1188. assert.NotNil(t, identity)
  1189. assert.Equal(t, "power_user", identity.Name)
  1190. }
  1191. // TestStreamingSignatureServiceField tests that the s3ChunkedReader struct correctly stores the service
  1192. // This verifies the fix for streaming uploads where getChunkSignature was hardcoding "s3"
  1193. func TestStreamingSignatureServiceField(t *testing.T) {
  1194. // Test that the s3ChunkedReader correctly uses the service field
  1195. // Create a mock s3ChunkedReader with IAM service
  1196. chunkedReader := &s3ChunkedReader{
  1197. seedDate: time.Now(),
  1198. region: "us-east-1",
  1199. service: "iam", // This should be used instead of hardcoded "s3"
  1200. seedSignature: "testsignature",
  1201. cred: &Credential{
  1202. AccessKey: "testkey",
  1203. SecretKey: "testsecret",
  1204. },
  1205. }
  1206. // Test that getScope is called with the correct service
  1207. scope := getScope(chunkedReader.seedDate, chunkedReader.region, chunkedReader.service)
  1208. assert.Contains(t, scope, "/iam/aws4_request")
  1209. assert.NotContains(t, scope, "/s3/aws4_request")
  1210. // Test that getSigningKey would be called with the correct service
  1211. signingKey := getSigningKey(
  1212. chunkedReader.cred.SecretKey,
  1213. chunkedReader.seedDate.Format(yyyymmdd),
  1214. chunkedReader.region,
  1215. chunkedReader.service,
  1216. )
  1217. assert.NotNil(t, signingKey)
  1218. // The main point is that chunkedReader.service is "iam" and gets used correctly
  1219. // This ensures that IAM streaming uploads will use "iam" service instead of hardcoded "s3"
  1220. assert.Equal(t, "iam", chunkedReader.service)
  1221. }
  1222. // Test that large IAM request bodies are truncated for security (DoS prevention)
  1223. func TestIAMLargeBodySecurityLimit(t *testing.T) {
  1224. // Create test IAM instance
  1225. iam := &IdentityAccessManagement{
  1226. hashes: make(map[string]*sync.Pool),
  1227. hashCounters: make(map[string]*int32),
  1228. }
  1229. // Load test configuration
  1230. err := iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
  1231. Identities: []*iam_pb.Identity{
  1232. {
  1233. Name: "testuser",
  1234. Credentials: []*iam_pb.Credential{
  1235. {
  1236. AccessKey: "AKIAIOSFODNN7EXAMPLE",
  1237. SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  1238. },
  1239. },
  1240. Actions: []string{"Admin"},
  1241. },
  1242. },
  1243. })
  1244. assert.NoError(t, err)
  1245. // Create a payload larger than the 10 MiB limit
  1246. largePayload := strings.Repeat("A", 11*(1<<20)) // 11 MiB
  1247. // Create IAM request with large body
  1248. req, err := http.NewRequest("POST", "http://localhost:8111/", strings.NewReader(largePayload))
  1249. assert.NoError(t, err)
  1250. req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
  1251. req.Header.Set("Host", "localhost:8111")
  1252. // Create IAM-style authorization header
  1253. now := time.Now().UTC()
  1254. dateStr := now.Format("20060102T150405Z")
  1255. credentialScope := now.Format("20060102") + "/us-east-1/iam/aws4_request"
  1256. req.Header.Set("X-Amz-Date", dateStr)
  1257. authHeader := "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/" + credentialScope +
  1258. ", SignedHeaders=content-type;host;x-amz-date, Signature=dummysignature"
  1259. req.Header.Set("Authorization", authHeader)
  1260. // The function should complete successfully but limit the body to 10 MiB
  1261. identity, errCode := iam.doesSignatureMatch(emptySHA256, req)
  1262. // Should get signature mismatch (dummy signature) but not internal error
  1263. assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode)
  1264. assert.Nil(t, identity)
  1265. // Verify the body was truncated to the limit (10 MiB)
  1266. bodyBytes, err := io.ReadAll(req.Body)
  1267. assert.NoError(t, err)
  1268. assert.Equal(t, 10*(1<<20), len(bodyBytes)) // Should be exactly 10 MiB
  1269. assert.Equal(t, strings.Repeat("A", 10*(1<<20)), string(bodyBytes)) // All As, but truncated
  1270. }
  1271. // Test the streaming hash implementation directly
  1272. func TestStreamHashRequestBody(t *testing.T) {
  1273. testCases := []struct {
  1274. name string
  1275. payload string
  1276. }{
  1277. {
  1278. name: "empty body",
  1279. payload: "",
  1280. },
  1281. {
  1282. name: "small payload",
  1283. payload: "Action=CreateAccessKey&UserName=testuser&Version=2010-05-08",
  1284. },
  1285. {
  1286. name: "medium payload",
  1287. payload: strings.Repeat("A", 1024), // 1KB
  1288. },
  1289. {
  1290. name: "large payload within limit",
  1291. payload: strings.Repeat("B", 1<<20), // 1MB
  1292. },
  1293. }
  1294. for _, tc := range testCases {
  1295. t.Run(tc.name, func(t *testing.T) {
  1296. // Create request with the test payload
  1297. req, err := http.NewRequest("POST", "http://localhost:8111/", strings.NewReader(tc.payload))
  1298. assert.NoError(t, err)
  1299. // Compute expected hash directly for comparison
  1300. expectedHashStr := emptySHA256
  1301. if tc.payload != "" {
  1302. expectedHash := sha256.Sum256([]byte(tc.payload))
  1303. expectedHashStr = hex.EncodeToString(expectedHash[:])
  1304. }
  1305. // Test the streaming function
  1306. hash, err := streamHashRequestBody(req, iamRequestBodyLimit)
  1307. assert.NoError(t, err)
  1308. assert.Equal(t, expectedHashStr, hash)
  1309. // Verify the body is preserved and readable
  1310. bodyBytes, err := io.ReadAll(req.Body)
  1311. assert.NoError(t, err)
  1312. assert.Equal(t, tc.payload, string(bodyBytes))
  1313. })
  1314. }
  1315. }
  1316. // Test streaming vs non-streaming approach produces identical results
  1317. func TestStreamingVsNonStreamingConsistency(t *testing.T) {
  1318. testPayloads := []string{
  1319. "",
  1320. "small",
  1321. "Action=CreateAccessKey&UserName=testuser&Version=2010-05-08",
  1322. strings.Repeat("X", 8192), // Exactly one chunk
  1323. strings.Repeat("Y", 16384), // Two chunks
  1324. strings.Repeat("Z", 12345), // Non-aligned chunks
  1325. }
  1326. for i, payload := range testPayloads {
  1327. t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
  1328. // Test streaming approach
  1329. req1, err := http.NewRequest("POST", "http://localhost:8111/", strings.NewReader(payload))
  1330. assert.NoError(t, err)
  1331. streamHash, err := streamHashRequestBody(req1, iamRequestBodyLimit)
  1332. assert.NoError(t, err)
  1333. // Test direct approach for comparison
  1334. directHashStr := emptySHA256
  1335. if payload != "" {
  1336. directHash := sha256.Sum256([]byte(payload))
  1337. directHashStr = hex.EncodeToString(directHash[:])
  1338. }
  1339. // Both approaches should produce identical results
  1340. assert.Equal(t, directHashStr, streamHash)
  1341. // Verify body preservation
  1342. bodyBytes, err := io.ReadAll(req1.Body)
  1343. assert.NoError(t, err)
  1344. assert.Equal(t, payload, string(bodyBytes))
  1345. })
  1346. }
  1347. }
  1348. // Test streaming with size limit enforcement
  1349. func TestStreamingWithSizeLimit(t *testing.T) {
  1350. // Create a payload larger than the limit
  1351. largePayload := strings.Repeat("A", 11*(1<<20)) // 11 MiB
  1352. req, err := http.NewRequest("POST", "http://localhost:8111/", strings.NewReader(largePayload))
  1353. assert.NoError(t, err)
  1354. // Stream with the limit
  1355. hash, err := streamHashRequestBody(req, iamRequestBodyLimit)
  1356. assert.NoError(t, err)
  1357. // Verify the hash is computed for the truncated content (10 MiB)
  1358. truncatedPayload := strings.Repeat("A", 10*(1<<20))
  1359. expectedHash := sha256.Sum256([]byte(truncatedPayload))
  1360. expectedHashStr := hex.EncodeToString(expectedHash[:])
  1361. assert.Equal(t, expectedHashStr, hash)
  1362. // Verify the body was truncated
  1363. bodyBytes, err := io.ReadAll(req.Body)
  1364. assert.NoError(t, err)
  1365. assert.Equal(t, 10*(1<<20), len(bodyBytes))
  1366. assert.Equal(t, truncatedPayload, string(bodyBytes))
  1367. }
  1368. // Benchmark streaming vs non-streaming memory usage
  1369. func BenchmarkStreamingVsNonStreaming(b *testing.B) {
  1370. // Test with 1MB payload to show memory efficiency
  1371. payload := strings.Repeat("A", 1<<20) // 1MB
  1372. b.Run("streaming", func(b *testing.B) {
  1373. b.ResetTimer()
  1374. for i := 0; i < b.N; i++ {
  1375. req, _ := http.NewRequest("POST", "http://localhost:8111/", strings.NewReader(payload))
  1376. streamHashRequestBody(req, iamRequestBodyLimit)
  1377. }
  1378. })
  1379. b.Run("direct", func(b *testing.B) {
  1380. b.ResetTimer()
  1381. for i := 0; i < b.N; i++ {
  1382. // Simulate the old approach of reading all at once
  1383. req, _ := http.NewRequest("POST", "http://localhost:8111/", strings.NewReader(payload))
  1384. io.ReadAll(req.Body)
  1385. sha256.Sum256([]byte(payload))
  1386. }
  1387. })
  1388. }