diff options
author | Rutger Broekhoff | 2023-12-30 21:51:50 +0100 |
---|---|---|
committer | Rutger Broekhoff | 2023-12-30 21:51:50 +0100 |
commit | 45939098e918041f0ce524fa125cf0a1cdfd49c0 (patch) | |
tree | a8846a610c5535dbbeaf73f17edb5286ad751903 | |
parent | dc85c97f3730b066f3603081d21df67349f9a84d (diff) | |
download | gitolfs3-45939098e918041f0ce524fa125cf0a1cdfd49c0.tar.gz gitolfs3-45939098e918041f0ce524fa125cf0a1cdfd49c0.zip |
Implement authorization in git-lfs-server, test presigned PUTs
-rw-r--r-- | cmd/git-lfs-authenticate/main.go | 7 | ||||
-rw-r--r-- | cmd/git-lfs-server/main.go | 206 |
2 files changed, 182 insertions, 31 deletions
diff --git a/cmd/git-lfs-authenticate/main.go b/cmd/git-lfs-authenticate/main.go index f48fe5c..027a2f9 100644 --- a/cmd/git-lfs-authenticate/main.go +++ b/cmd/git-lfs-authenticate/main.go | |||
@@ -100,8 +100,11 @@ type authenticateResponse struct { | |||
100 | Header map[string]string `json:"header"` | 100 | Header map[string]string `json:"header"` |
101 | // In seconds. | 101 | // In seconds. |
102 | ExpiresIn int64 `json:"expires_in,omitempty"` | 102 | ExpiresIn int64 `json:"expires_in,omitempty"` |
103 | // expires_at (RFC3339) could also be used, but we leave it out since we | 103 | // The expires_at (RFC3339) property could also be used, but we leave it |
104 | // don't use it. | 104 | // out since we don't use it. It is also possibleto specify the href |
105 | // property, making the Git LFS use this instead of the usual Service | ||
106 | // Discovery mechanism. See | ||
107 | // https://github.com/git-lfs/git-lfs/blob/baf40ac99850a62fe98515175d52df5c513463ec/docs/api/server-discovery.md#ssh | ||
105 | } | 108 | } |
106 | 109 | ||
107 | func wipe(b []byte) { | 110 | func wipe(b []byte) { |
diff --git a/cmd/git-lfs-server/main.go b/cmd/git-lfs-server/main.go index c76c5d9..934f2ea 100644 --- a/cmd/git-lfs-server/main.go +++ b/cmd/git-lfs-server/main.go | |||
@@ -1,7 +1,10 @@ | |||
1 | package main | 1 | package main |
2 | 2 | ||
3 | import ( | 3 | import ( |
4 | "bytes" | ||
4 | "context" | 5 | "context" |
6 | "crypto/ed25519" | ||
7 | "encoding/hex" | ||
5 | "encoding/json" | 8 | "encoding/json" |
6 | "errors" | 9 | "errors" |
7 | "fmt" | 10 | "fmt" |
@@ -14,10 +17,12 @@ import ( | |||
14 | "path" | 17 | "path" |
15 | "regexp" | 18 | "regexp" |
16 | "slices" | 19 | "slices" |
20 | "strconv" | ||
17 | "strings" | 21 | "strings" |
18 | "time" | 22 | "time" |
19 | "unicode" | 23 | "unicode" |
20 | 24 | ||
25 | "github.com/golang-jwt/jwt/v5" | ||
21 | "github.com/minio/minio-go/v7" | 26 | "github.com/minio/minio-go/v7" |
22 | "github.com/minio/minio-go/v7/pkg/credentials" | 27 | "github.com/minio/minio-go/v7/pkg/credentials" |
23 | "github.com/rs/xid" | 28 | "github.com/rs/xid" |
@@ -86,6 +91,7 @@ type handler struct { | |||
86 | bucket string | 91 | bucket string |
87 | anonUser string | 92 | anonUser string |
88 | gitolitePath string | 93 | gitolitePath string |
94 | publicKey ed25519.PublicKey | ||
89 | } | 95 | } |
90 | 96 | ||
91 | // Requires lowercase hash | 97 | // Requires lowercase hash |
@@ -172,6 +178,36 @@ func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj par | |||
172 | } | 178 | } |
173 | } | 179 | } |
174 | 180 | ||
181 | func (h *handler) handleUploadObject(ctx context.Context, repo string, obj parsedBatchObject) batchResponseObject { | ||
182 | fullPath := path.Join(repo+".git", "lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) | ||
183 | expiresIn := time.Hour * 24 | ||
184 | |||
185 | presigned, err := h.mc.Presign(ctx, http.MethodPut, h.bucket, fullPath, expiresIn, url.Values{ | ||
186 | "x-amz-sdk-checksum-algorithm": {"sha256"}, | ||
187 | "x-amz-checksum-sha256": {obj.fullHash}, | ||
188 | "Content-Length": {strconv.FormatUint(obj.size, 10)}, | ||
189 | }) | ||
190 | if err != nil { | ||
191 | // TODO: consider not making this an object-specific, but rather a | ||
192 | // generic error such that the entire Batch API request fails. | ||
193 | reqlog(ctx, "Failed to generate action href (full path: %s): %s", fullPath, err) | ||
194 | return makeObjError(obj, "Failed to generate action href", http.StatusInternalServerError) | ||
195 | } | ||
196 | |||
197 | authenticated := true | ||
198 | return batchResponseObject{ | ||
199 | OID: obj.fullHash, | ||
200 | Size: obj.size, | ||
201 | Authenticated: &authenticated, | ||
202 | Actions: map[operation]batchAction{ | ||
203 | operationUpload: { | ||
204 | HRef: presigned.String(), | ||
205 | ExpiresIn: int64(expiresIn.Seconds()), | ||
206 | }, | ||
207 | }, | ||
208 | } | ||
209 | } | ||
210 | |||
175 | type parsedBatchObject struct { | 211 | type parsedBatchObject struct { |
176 | firstByte string | 212 | firstByte string |
177 | secondByte string | 213 | secondByte string |
@@ -196,6 +232,103 @@ type requestID struct{} | |||
196 | 232 | ||
197 | var requestIDKey requestID | 233 | var requestIDKey requestID |
198 | 234 | ||
235 | // TODO: make a shared package for this | ||
236 | type gitolfs3Claims struct { | ||
237 | Repository string `json:"repository"` | ||
238 | Permission operation `json:"permission"` | ||
239 | } | ||
240 | |||
241 | type customClaims struct { | ||
242 | Gitolfs3 gitolfs3Claims `json:"gitolfs3"` | ||
243 | *jwt.RegisteredClaims | ||
244 | } | ||
245 | |||
246 | // Request to perform <operation> in <repository> [on reference <refspec>] | ||
247 | type operationRequest struct { | ||
248 | operation operation | ||
249 | repository string | ||
250 | refspec *string | ||
251 | } | ||
252 | |||
253 | func getGitoliteAccess(repo, user, gitolitePerm string, refspec *string) (bool, error) { | ||
254 | // gitolite access -q: returns only exit code | ||
255 | gitoliteArgs := []string{"access", "-q", repo, user, gitolitePerm} | ||
256 | if refspec != nil { | ||
257 | gitoliteArgs = append(gitoliteArgs, *refspec) | ||
258 | } | ||
259 | cmd := exec.Command("gitolite", gitoliteArgs...) | ||
260 | err := cmd.Run() | ||
261 | if err != nil { | ||
262 | var exitErr *exec.ExitError | ||
263 | if !errors.As(err, &exitErr) { | ||
264 | return false, fmt.Errorf("(running %s): %w", cmd, err) | ||
265 | } | ||
266 | return false, nil | ||
267 | } | ||
268 | return true, nil | ||
269 | } | ||
270 | |||
271 | func (h *handler) authorize(ctx context.Context, w http.ResponseWriter, r *http.Request, or operationRequest) bool { | ||
272 | user := h.anonUser | ||
273 | |||
274 | if authz := r.Header.Get("Authorization"); authz != "" { | ||
275 | if !strings.HasPrefix(authz, "Bearer ") { | ||
276 | makeRespError(ctx, w, "Invalid Authorization header", http.StatusBadRequest) | ||
277 | return false | ||
278 | } | ||
279 | authz = strings.TrimPrefix(authz, "Bearer ") | ||
280 | |||
281 | var claims customClaims | ||
282 | _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) { | ||
283 | if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { | ||
284 | return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"]) | ||
285 | } | ||
286 | return h.publicKey, nil | ||
287 | }) | ||
288 | if err != nil { | ||
289 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
290 | return false | ||
291 | } | ||
292 | |||
293 | if claims.Gitolfs3.Repository != or.repository { | ||
294 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
295 | return false | ||
296 | } | ||
297 | if claims.Gitolfs3.Permission == operationDownload && or.operation == operationUpload { | ||
298 | makeRespError(ctx, w, "Forbidden", http.StatusForbidden) | ||
299 | return false | ||
300 | } | ||
301 | |||
302 | user = claims.Subject | ||
303 | } | ||
304 | |||
305 | readAccess, err := getGitoliteAccess(or.repository, user, "R", or.refspec) | ||
306 | if err != nil { | ||
307 | reqlog(ctx, "Error checking access info: %s", err) | ||
308 | makeRespError(ctx, w, "Failed to query access information", http.StatusInternalServerError) | ||
309 | return false | ||
310 | } | ||
311 | if !readAccess { | ||
312 | makeRespError(ctx, w, "Repository not found", http.StatusNotFound) | ||
313 | return false | ||
314 | } | ||
315 | if or.operation == operationUpload { | ||
316 | writeAccess, err := getGitoliteAccess(or.repository, user, "W", or.refspec) | ||
317 | if err != nil { | ||
318 | reqlog(ctx, "Error checking access info: %s", err) | ||
319 | makeRespError(ctx, w, "Failed to query access information", http.StatusInternalServerError) | ||
320 | return false | ||
321 | } | ||
322 | // User has read access but no write access | ||
323 | if !writeAccess { | ||
324 | makeRespError(ctx, w, "Forbidden", http.StatusForbidden) | ||
325 | return false | ||
326 | } | ||
327 | } | ||
328 | |||
329 | return true | ||
330 | } | ||
331 | |||
199 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 332 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
200 | ctx := context.WithValue(r.Context(), requestIDKey, xid.New().String()) | 333 | ctx := context.WithValue(r.Context(), requestIDKey, xid.New().String()) |
201 | 334 | ||
@@ -229,42 +362,29 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
229 | makeRespError(ctx, w, "Failed to parse request body as JSON", http.StatusBadRequest) | 362 | makeRespError(ctx, w, "Failed to parse request body as JSON", http.StatusBadRequest) |
230 | return | 363 | return |
231 | } | 364 | } |
232 | 365 | if body.Operation != operationDownload && body.Operation != operationUpload { | |
233 | if body.HashAlgo != hashAlgoSHA256 { | 366 | makeRespError(ctx, w, "Invalid operation specified", http.StatusBadRequest) |
234 | makeRespError(ctx, w, "Unsupported hash algorithm specified", http.StatusConflict) | ||
235 | return | 367 | return |
236 | } | 368 | } |
237 | 369 | ||
238 | // TODO: handle authentication | 370 | or := operationRequest{ |
239 | // right now, we're just trying to make everything publically accessible | 371 | operation: body.Operation, |
240 | if body.Operation == operationUpload { | 372 | repository: repo, |
241 | makeRespError(ctx, w, "Upload operations are currently not supported", http.StatusForbidden) | ||
242 | return | ||
243 | } | 373 | } |
244 | 374 | if body.Ref != nil { | |
245 | if len(body.Transfers) != 0 && !slices.Contains(body.Transfers, transferAdapterBasic) { | 375 | or.refspec = &body.Ref.Name |
246 | makeRespError(ctx, w, "Unsupported transfer adapter specified (supported: basic)", http.StatusConflict) | 376 | } |
377 | if !h.authorize(ctx, w, r, or) { | ||
247 | return | 378 | return |
248 | } | 379 | } |
249 | 380 | ||
250 | gitoliteArgs := []string{"access", "-q", repo, h.anonUser, "R"} | 381 | if body.HashAlgo != hashAlgoSHA256 { |
251 | if body.Ref != nil && body.Ref.Name != "" { | 382 | makeRespError(ctx, w, "Unsupported hash algorithm specified", http.StatusConflict) |
252 | gitoliteArgs = append(gitoliteArgs, body.Ref.Name) | ||
253 | } | ||
254 | cmd := exec.Command(h.gitolitePath, gitoliteArgs...) | ||
255 | err := cmd.Run() | ||
256 | permGranted := err == nil | ||
257 | var exitErr *exec.ExitError | ||
258 | if err != nil && !errors.As(err, &exitErr) { | ||
259 | reqlog(ctx, "Error checking access info (running %s): %s", cmd, err) | ||
260 | makeRespError(ctx, w, "Failed to query access information", http.StatusInternalServerError) | ||
261 | return | 383 | return |
262 | } | 384 | } |
263 | if !permGranted { | 385 | |
264 | // TODO: when handling authorization, make sure to return 403 Forbidden | 386 | if len(body.Transfers) != 0 && !slices.Contains(body.Transfers, transferAdapterBasic) { |
265 | // here when the user *does* have read permissions, but is not allowed | 387 | makeRespError(ctx, w, "Unsupported transfer adapter specified (supported: basic)", http.StatusConflict) |
266 | // to write when requesting an upload operation. | ||
267 | makeRespError(ctx, w, "Repository not found", http.StatusNotFound) | ||
268 | return | 388 | return |
269 | } | 389 | } |
270 | 390 | ||
@@ -288,7 +408,12 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
288 | HashAlgo: hashAlgoSHA256, | 408 | HashAlgo: hashAlgoSHA256, |
289 | } | 409 | } |
290 | for _, obj := range objects { | 410 | for _, obj := range objects { |
291 | resp.Objects = append(resp.Objects, h.handleDownloadObject(ctx, repo, obj)) | 411 | switch body.Operation { |
412 | case operationDownload: | ||
413 | resp.Objects = append(resp.Objects, h.handleDownloadObject(ctx, repo, obj)) | ||
414 | case operationUpload: | ||
415 | resp.Objects = append(resp.Objects, h.handleUploadObject(ctx, repo, obj)) | ||
416 | } | ||
292 | } | 417 | } |
293 | 418 | ||
294 | w.Header().Set("Content-Type", lfsMIME) | 419 | w.Header().Set("Content-Type", lfsMIME) |
@@ -316,6 +441,23 @@ func die(msg string, args ...any) { | |||
316 | os.Exit(1) | 441 | os.Exit(1) |
317 | } | 442 | } |
318 | 443 | ||
444 | func loadPublicKey(path string) ed25519.PublicKey { | ||
445 | raw, err := os.ReadFile(path) | ||
446 | if err != nil { | ||
447 | die("Failed to open specified public key: %s", err) | ||
448 | } | ||
449 | raw = bytes.TrimSpace(raw) | ||
450 | |||
451 | if hex.DecodedLen(len(raw)) != ed25519.PublicKeySize { | ||
452 | die("Specified public key file does not contain key of appropriate length") | ||
453 | } | ||
454 | decoded := make([]byte, hex.DecodedLen(len(raw))) | ||
455 | if _, err = hex.Decode(decoded, raw); err != nil { | ||
456 | die("Failed to decode specified public key: %s", err) | ||
457 | } | ||
458 | return decoded | ||
459 | } | ||
460 | |||
319 | func main() { | 461 | func main() { |
320 | log("Environment variables:") | 462 | log("Environment variables:") |
321 | for _, s := range os.Environ() { | 463 | for _, s := range os.Environ() { |
@@ -323,6 +465,7 @@ func main() { | |||
323 | } | 465 | } |
324 | 466 | ||
325 | anonUser := os.Getenv("ANON_USER") | 467 | anonUser := os.Getenv("ANON_USER") |
468 | publicKeyPath := os.Getenv("GITOLFS3_PUBLIC_KEY_PATH") | ||
326 | endpoint := os.Getenv("S3_ENDPOINT") | 469 | endpoint := os.Getenv("S3_ENDPOINT") |
327 | bucket := os.Getenv("S3_BUCKET") | 470 | bucket := os.Getenv("S3_BUCKET") |
328 | accessKeyIDFile := os.Getenv("S3_ACCESS_KEY_ID_FILE") | 471 | accessKeyIDFile := os.Getenv("S3_ACCESS_KEY_ID_FILE") |
@@ -336,6 +479,9 @@ func main() { | |||
336 | if anonUser == "" { | 479 | if anonUser == "" { |
337 | die("Fatal: expected environment variable ANON_USER to be set") | 480 | die("Fatal: expected environment variable ANON_USER to be set") |
338 | } | 481 | } |
482 | if publicKeyPath == "" { | ||
483 | die("Fatal: expected environment variable GITOLFS3_PUBLIC_KEY_PATH to be set") | ||
484 | } | ||
339 | if endpoint == "" { | 485 | if endpoint == "" { |
340 | die("Fatal: expected environment variable S3_ENDPOINT to be set") | 486 | die("Fatal: expected environment variable S3_ENDPOINT to be set") |
341 | } | 487 | } |
@@ -359,6 +505,8 @@ func main() { | |||
359 | die("Fatal: failed to read secret access key from specified file: %s", err) | 505 | die("Fatal: failed to read secret access key from specified file: %s", err) |
360 | } | 506 | } |
361 | 507 | ||
508 | publicKey := loadPublicKey(publicKeyPath) | ||
509 | |||
362 | mc, err := minio.New(endpoint, &minio.Options{ | 510 | mc, err := minio.New(endpoint, &minio.Options{ |
363 | Creds: credentials.NewStaticV4(string(accessKeyID), string(secretAccessKey), ""), | 511 | Creds: credentials.NewStaticV4(string(accessKeyID), string(secretAccessKey), ""), |
364 | Secure: true, | 512 | Secure: true, |
@@ -367,7 +515,7 @@ func main() { | |||
367 | die("Fatal: failed to create S3 client: %s", err) | 515 | die("Fatal: failed to create S3 client: %s", err) |
368 | } | 516 | } |
369 | 517 | ||
370 | if err = cgi.Serve(&handler{mc, bucket, anonUser, gitolitePath}); err != nil { | 518 | if err = cgi.Serve(&handler{mc, bucket, anonUser, gitolitePath, publicKey}); err != nil { |
371 | die("Fatal: failed to serve CGI: %s", err) | 519 | die("Fatal: failed to serve CGI: %s", err) |
372 | } | 520 | } |
373 | } | 521 | } |