diff options
Diffstat (limited to 'cmd/git-lfs-server')
-rw-r--r-- | cmd/git-lfs-server/main.go | 158 |
1 files changed, 138 insertions, 20 deletions
diff --git a/cmd/git-lfs-server/main.go b/cmd/git-lfs-server/main.go index 191a696..c7feeff 100644 --- a/cmd/git-lfs-server/main.go +++ b/cmd/git-lfs-server/main.go | |||
@@ -163,19 +163,35 @@ func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj par | |||
163 | return makeObjError(obj, "Failed to query object information", http.StatusInternalServerError) | 163 | return makeObjError(obj, "Failed to query object information", http.StatusInternalServerError) |
164 | } | 164 | } |
165 | if info.ChecksumSHA256 != "" && strings.ToLower(info.ChecksumSHA256) != obj.fullHash { | 165 | if info.ChecksumSHA256 != "" && strings.ToLower(info.ChecksumSHA256) != obj.fullHash { |
166 | return makeObjError(obj, "Corrupted file", http.StatusUnprocessableEntity) | 166 | return makeObjError(obj, "Object corrupted", http.StatusUnprocessableEntity) |
167 | } | 167 | } |
168 | if info.Size != obj.size { | 168 | if info.Size != obj.size { |
169 | return makeObjError(obj, "Incorrect size specified for object", http.StatusUnprocessableEntity) | 169 | return makeObjError(obj, "Incorrect size specified for object or object currupted", http.StatusUnprocessableEntity) |
170 | } | 170 | } |
171 | 171 | ||
172 | presigned, err := h.mc.PresignedGetObject(ctx, h.bucket, fullPath, expiresIn, url.Values{}) | 172 | claims := handleObjectCustomClaims{ |
173 | Gitolfs3: handleObjectGitolfs3Claims{ | ||
174 | Type: "basic-transfer", | ||
175 | Operation: operationDownload, | ||
176 | Repository: repo, | ||
177 | OID: obj.fullHash, | ||
178 | Size: obj.size, | ||
179 | }, | ||
180 | RegisteredClaims: &jwt.RegisteredClaims{ | ||
181 | IssuedAt: jwt.NewNumericDate(time.Now()), | ||
182 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)), | ||
183 | }, | ||
184 | } | ||
185 | |||
186 | token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) | ||
187 | ss, err := token.SignedString(h.privateKey) | ||
173 | if err != nil { | 188 | if err != nil { |
174 | // TODO: consider not making this an object-specific, but rather a | 189 | // TODO: consider not making this an object-specific, but rather a |
175 | // generic error such that the entire Batch API request fails. | 190 | // generic error such that the entire Batch API request fails. |
176 | reqlog(ctx, "Failed to generate action href (full path: %s): %s", fullPath, err) | 191 | reqlog(ctx, "Fatal: failed to generate JWT: %s", err) |
177 | return makeObjError(obj, "Failed to generate action href", http.StatusInternalServerError) | 192 | return makeObjError(obj, "Failed to generate token", http.StatusInternalServerError) |
178 | } | 193 | } |
194 | uploadPath := path.Join(repo+".git", "info/lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) | ||
179 | 195 | ||
180 | authenticated := true | 196 | authenticated := true |
181 | return batchResponseObject{ | 197 | return batchResponseObject{ |
@@ -184,21 +200,26 @@ func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj par | |||
184 | Authenticated: &authenticated, | 200 | Authenticated: &authenticated, |
185 | Actions: map[operation]batchAction{ | 201 | Actions: map[operation]batchAction{ |
186 | operationDownload: { | 202 | operationDownload: { |
187 | HRef: presigned.String(), | 203 | Header: map[string]string{ |
204 | "Authorization": "Bearer " + ss, | ||
205 | }, | ||
206 | HRef: h.baseURL.ResolveReference(&url.URL{Path: uploadPath}).String(), | ||
188 | ExpiresIn: int64(expiresIn.Seconds()), | 207 | ExpiresIn: int64(expiresIn.Seconds()), |
189 | }, | 208 | }, |
190 | }, | 209 | }, |
191 | } | 210 | } |
192 | } | 211 | } |
193 | 212 | ||
194 | type uploadObjectGitolfs3Claims struct { | 213 | type handleObjectGitolfs3Claims struct { |
195 | Repository string `json:"repository"` | 214 | Type string `json:"type"` |
196 | OID string `json:"oid"` | 215 | Operation operation `json:"operation"` |
197 | Size int64 `json:"size"` | 216 | Repository string `json:"repository"` |
217 | OID string `json:"oid"` | ||
218 | Size int64 `json:"size"` | ||
198 | } | 219 | } |
199 | 220 | ||
200 | type uploadObjectCustomClaims struct { | 221 | type handleObjectCustomClaims struct { |
201 | Gitolfs3 uploadObjectGitolfs3Claims `json:"gitolfs3"` | 222 | Gitolfs3 handleObjectGitolfs3Claims `json:"gitolfs3"` |
202 | *jwt.RegisteredClaims | 223 | *jwt.RegisteredClaims |
203 | } | 224 | } |
204 | 225 | ||
@@ -221,8 +242,10 @@ func (h *handler) handleUploadObject(ctx context.Context, repo string, obj parse | |||
221 | } | 242 | } |
222 | 243 | ||
223 | expiresIn := time.Hour * 24 | 244 | expiresIn := time.Hour * 24 |
224 | claims := uploadObjectCustomClaims{ | 245 | claims := handleObjectCustomClaims{ |
225 | Gitolfs3: uploadObjectGitolfs3Claims{ | 246 | Gitolfs3: handleObjectGitolfs3Claims{ |
247 | Type: "basic-transfer", | ||
248 | Operation: operationUpload, | ||
226 | Repository: repo, | 249 | Repository: repo, |
227 | OID: obj.fullHash, | 250 | OID: obj.fullHash, |
228 | Size: obj.size, | 251 | Size: obj.size, |
@@ -327,7 +350,7 @@ func (h *handler) handlePutObject(w http.ResponseWriter, r *http.Request, repo, | |||
327 | } | 350 | } |
328 | authz = strings.TrimPrefix(authz, "Bearer ") | 351 | authz = strings.TrimPrefix(authz, "Bearer ") |
329 | 352 | ||
330 | var claims uploadObjectCustomClaims | 353 | var claims handleObjectCustomClaims |
331 | _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) { | 354 | _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) { |
332 | if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { | 355 | if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { |
333 | return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"]) | 356 | return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"]) |
@@ -338,6 +361,10 @@ func (h *handler) handlePutObject(w http.ResponseWriter, r *http.Request, repo, | |||
338 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | 361 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) |
339 | return | 362 | return |
340 | } | 363 | } |
364 | if claims.Gitolfs3.Type != "basic-transfer" { | ||
365 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
366 | return | ||
367 | } | ||
341 | if claims.Gitolfs3.Repository != repo { | 368 | if claims.Gitolfs3.Repository != repo { |
342 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | 369 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) |
343 | return | 370 | return |
@@ -346,6 +373,10 @@ func (h *handler) handlePutObject(w http.ResponseWriter, r *http.Request, repo, | |||
346 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | 373 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) |
347 | return | 374 | return |
348 | } | 375 | } |
376 | if claims.Gitolfs3.Operation != operationUpload { | ||
377 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
378 | return | ||
379 | } | ||
349 | 380 | ||
350 | // Check with claims | 381 | // Check with claims |
351 | if lengthStr := r.Header.Get("Content-Length"); lengthStr != "" { | 382 | if lengthStr := r.Header.Get("Content-Length"); lengthStr != "" { |
@@ -387,6 +418,85 @@ func (h *handler) handlePutObject(w http.ResponseWriter, r *http.Request, repo, | |||
387 | } | 418 | } |
388 | } | 419 | } |
389 | 420 | ||
421 | func (h *handler) handleGetObject(w http.ResponseWriter, r *http.Request, repo, oid string) { | ||
422 | ctx := r.Context() | ||
423 | |||
424 | authz := r.Header.Get("Authorization") | ||
425 | if authz == "" { | ||
426 | makeRespError(ctx, w, "Missing Authorization header", http.StatusBadRequest) | ||
427 | return | ||
428 | } | ||
429 | if !strings.HasPrefix(authz, "Bearer ") { | ||
430 | makeRespError(ctx, w, "Invalid Authorization header", http.StatusBadRequest) | ||
431 | return | ||
432 | } | ||
433 | authz = strings.TrimPrefix(authz, "Bearer ") | ||
434 | |||
435 | var claims handleObjectCustomClaims | ||
436 | _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) { | ||
437 | if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { | ||
438 | return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"]) | ||
439 | } | ||
440 | return h.privateKey.Public(), nil | ||
441 | }) | ||
442 | if err != nil { | ||
443 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
444 | return | ||
445 | } | ||
446 | if claims.Gitolfs3.Type != "basic-transfer" { | ||
447 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
448 | return | ||
449 | } | ||
450 | if claims.Gitolfs3.Repository != repo { | ||
451 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
452 | return | ||
453 | } | ||
454 | if claims.Gitolfs3.OID != oid { | ||
455 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
456 | return | ||
457 | } | ||
458 | if claims.Gitolfs3.Operation != operationDownload { | ||
459 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
460 | return | ||
461 | } | ||
462 | |||
463 | sha256Raw, err := hex.DecodeString(oid) | ||
464 | if err != nil || len(sha256Raw) != sha256.Size { | ||
465 | makeRespError(ctx, w, "Invalid OID", http.StatusBadRequest) | ||
466 | return | ||
467 | } | ||
468 | |||
469 | fullPath := path.Join(repo+".git", "lfs/objects", oid[:2], oid[2:4], oid) | ||
470 | obj, err := h.mc.GetObject(ctx, h.bucket, fullPath, minio.GetObjectOptions{}) | ||
471 | |||
472 | var resp minio.ErrorResponse | ||
473 | if errors.As(err, &resp) && resp.StatusCode != http.StatusNotFound { | ||
474 | makeRespError(ctx, w, "Not found", http.StatusNotFound) | ||
475 | return | ||
476 | } else if err != nil { | ||
477 | reqlog(ctx, "Failed to get object: %s", err) | ||
478 | makeRespError(ctx, w, "Failed to get object", http.StatusInternalServerError) | ||
479 | return | ||
480 | } | ||
481 | |||
482 | stat, err := obj.Stat() | ||
483 | if err != nil { | ||
484 | reqlog(ctx, "Failed to stat: %s", err) | ||
485 | makeRespError(ctx, w, "Internal server error", http.StatusInternalServerError) | ||
486 | } | ||
487 | |||
488 | if stat.Size != claims.Gitolfs3.Size { | ||
489 | reqlog(ctx, "Claims size does not match S3 object size") | ||
490 | makeRespError(ctx, w, "Internal server error", http.StatusInternalServerError) | ||
491 | } | ||
492 | |||
493 | vr := newValidatingReader(claims.Gitolfs3.Size, sha256Raw, obj) | ||
494 | _, err = io.Copy(w, vr) | ||
495 | if errors.Is(err, errBadSum) { | ||
496 | reqlog(ctx, "Bad object checksum") | ||
497 | } | ||
498 | } | ||
499 | |||
390 | type parsedBatchObject struct { | 500 | type parsedBatchObject struct { |
391 | firstByte string | 501 | firstByte string |
392 | secondByte string | 502 | secondByte string |
@@ -414,6 +524,7 @@ var requestIDKey requestID | |||
414 | 524 | ||
415 | // TODO: make a shared package for this | 525 | // TODO: make a shared package for this |
416 | type lfsAuthGitolfs3Claims struct { | 526 | type lfsAuthGitolfs3Claims struct { |
527 | Type string `json:"type"` | ||
417 | Repository string `json:"repository"` | 528 | Repository string `json:"repository"` |
418 | Permission operation `json:"permission"` | 529 | Permission operation `json:"permission"` |
419 | } | 530 | } |
@@ -448,7 +559,7 @@ func (h *handler) getGitoliteAccess(repo, user, gitolitePerm string, refspec *st | |||
448 | return true, nil | 559 | return true, nil |
449 | } | 560 | } |
450 | 561 | ||
451 | func (h *handler) authorize(w http.ResponseWriter, r *http.Request, or operationRequest) bool { | 562 | func (h *handler) authorizeBatchAPI(w http.ResponseWriter, r *http.Request, or operationRequest) bool { |
452 | user := h.anonUser | 563 | user := h.anonUser |
453 | ctx := r.Context() | 564 | ctx := r.Context() |
454 | 565 | ||
@@ -471,6 +582,10 @@ func (h *handler) authorize(w http.ResponseWriter, r *http.Request, or operation | |||
471 | return false | 582 | return false |
472 | } | 583 | } |
473 | 584 | ||
585 | if claims.Gitolfs3.Type != "batch-api" { | ||
586 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
587 | return false | ||
588 | } | ||
474 | if claims.Gitolfs3.Repository != or.repository { | 589 | if claims.Gitolfs3.Repository != or.repository { |
475 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | 590 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) |
476 | return false | 591 | return false |
@@ -539,7 +654,7 @@ func (h *handler) handleBatchAPI(w http.ResponseWriter, r *http.Request, repo st | |||
539 | if body.Ref != nil { | 654 | if body.Ref != nil { |
540 | or.refspec = &body.Ref.Name | 655 | or.refspec = &body.Ref.Name |
541 | } | 656 | } |
542 | if !h.authorize(w, r.WithContext(ctx), or) { | 657 | if !h.authorizeBatchAPI(w, r.WithContext(ctx), or) { |
543 | return | 658 | return |
544 | } | 659 | } |
545 | 660 | ||
@@ -632,12 +747,15 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
632 | repo := strings.TrimPrefix(path.Clean(submatches[1]), "/") | 747 | repo := strings.TrimPrefix(path.Clean(submatches[1]), "/") |
633 | reqlog(ctx, "Handling object PUT for repository: %s, OID: %s", repo, oid) | 748 | reqlog(ctx, "Handling object PUT for repository: %s, OID: %s", repo, oid) |
634 | 749 | ||
635 | if r.Method != http.MethodPut { | 750 | switch r.Method { |
751 | case http.MethodGet: | ||
752 | h.handleGetObject(w, r.WithContext(ctx), repo, oid) | ||
753 | case http.MethodPut: | ||
754 | h.handlePutObject(w, r.WithContext(ctx), repo, oid) | ||
755 | default: | ||
636 | makeRespError(ctx, w, "Method not allowed", http.StatusMethodNotAllowed) | 756 | makeRespError(ctx, w, "Method not allowed", http.StatusMethodNotAllowed) |
637 | return | ||
638 | } | 757 | } |
639 | 758 | ||
640 | h.handlePutObject(w, r.WithContext(ctx), repo, oid) | ||
641 | return | 759 | return |
642 | } | 760 | } |
643 | 761 | ||