diff options
author | Rutger Broekhoff | 2024-01-02 00:41:25 +0100 |
---|---|---|
committer | Rutger Broekhoff | 2024-01-02 00:41:25 +0100 |
commit | 9e8463e8dbc4bffcd22f9dd1896176829385dfde (patch) | |
tree | 449b5d4ce09c53fcd606c8b30f688c701db93607 | |
parent | 9a5376c9023098d0c6bedb27ce672bc6b083d76a (diff) | |
download | gitolfs3-9e8463e8dbc4bffcd22f9dd1896176829385dfde.tar.gz gitolfs3-9e8463e8dbc4bffcd22f9dd1896176829385dfde.zip |
Upload validation by proxying
Yes, the code is a mess
-rw-r--r-- | cmd/git-lfs-server/main.go | 321 |
1 files changed, 263 insertions, 58 deletions
diff --git a/cmd/git-lfs-server/main.go b/cmd/git-lfs-server/main.go index b886557..fe62724 100644 --- a/cmd/git-lfs-server/main.go +++ b/cmd/git-lfs-server/main.go | |||
@@ -4,11 +4,14 @@ import ( | |||
4 | "bytes" | 4 | "bytes" |
5 | "context" | 5 | "context" |
6 | "crypto/ed25519" | 6 | "crypto/ed25519" |
7 | "crypto/sha256" | ||
7 | "encoding/base64" | 8 | "encoding/base64" |
8 | "encoding/hex" | 9 | "encoding/hex" |
9 | "encoding/json" | 10 | "encoding/json" |
10 | "errors" | 11 | "errors" |
11 | "fmt" | 12 | "fmt" |
13 | "hash" | ||
14 | "io" | ||
12 | "mime" | 15 | "mime" |
13 | "net/http" | 16 | "net/http" |
14 | "net/http/cgi" | 17 | "net/http/cgi" |
@@ -48,7 +51,7 @@ type batchRef struct { | |||
48 | 51 | ||
49 | type batchRequestObject struct { | 52 | type batchRequestObject struct { |
50 | OID string `json:"oid"` | 53 | OID string `json:"oid"` |
51 | Size uint64 `json:"size"` | 54 | Size int64 `json:"size"` |
52 | } | 55 | } |
53 | 56 | ||
54 | type batchRequest struct { | 57 | type batchRequest struct { |
@@ -75,7 +78,7 @@ type batchError struct { | |||
75 | 78 | ||
76 | type batchResponseObject struct { | 79 | type batchResponseObject struct { |
77 | OID string `json:"oid"` | 80 | OID string `json:"oid"` |
78 | Size uint64 `json:"size"` | 81 | Size int64 `json:"size"` |
79 | Authenticated *bool `json:"authenticated"` | 82 | Authenticated *bool `json:"authenticated"` |
80 | Actions map[operation]batchAction `json:"actions,omitempty"` | 83 | Actions map[operation]batchAction `json:"actions,omitempty"` |
81 | Error *batchError `json:"error,omitempty"` | 84 | Error *batchError `json:"error,omitempty"` |
@@ -92,10 +95,10 @@ type handler struct { | |||
92 | bucket string | 95 | bucket string |
93 | anonUser string | 96 | anonUser string |
94 | gitolitePath string | 97 | gitolitePath string |
95 | publicKey ed25519.PublicKey | 98 | privateKey ed25519.PrivateKey |
99 | baseURL *url.URL | ||
96 | } | 100 | } |
97 | 101 | ||
98 | // Requires lowercase hash | ||
99 | func isValidSHA256Hash(hash string) bool { | 102 | func isValidSHA256Hash(hash string) bool { |
100 | if len(hash) != 64 { | 103 | if len(hash) != 64 { |
101 | return false | 104 | return false |
@@ -161,7 +164,7 @@ func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj par | |||
161 | if info.ChecksumSHA256 != "" && strings.ToLower(info.ChecksumSHA256) != obj.fullHash { | 164 | if info.ChecksumSHA256 != "" && strings.ToLower(info.ChecksumSHA256) != obj.fullHash { |
162 | return makeObjError(obj, "Corrupted file", http.StatusUnprocessableEntity) | 165 | return makeObjError(obj, "Corrupted file", http.StatusUnprocessableEntity) |
163 | } | 166 | } |
164 | if uint64(info.Size) != obj.size { | 167 | if info.Size != obj.size { |
165 | return makeObjError(obj, "Incorrect size specified for object", http.StatusUnprocessableEntity) | 168 | return makeObjError(obj, "Incorrect size specified for object", http.StatusUnprocessableEntity) |
166 | } | 169 | } |
167 | 170 | ||
@@ -187,42 +190,198 @@ func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj par | |||
187 | } | 190 | } |
188 | } | 191 | } |
189 | 192 | ||
190 | func (h *handler) handleUploadObject(ctx context.Context, repo string, obj parsedBatchObject) batchResponseObject { | 193 | type uploadObjectGitolfs3Claims struct { |
194 | Repository string `json:"repository"` | ||
195 | OID string `json:"oid"` | ||
196 | Size int64 `json:"size"` | ||
197 | } | ||
198 | |||
199 | type uploadObjectCustomClaims struct { | ||
200 | Gitolfs3 uploadObjectGitolfs3Claims `json:"gitolfs3"` | ||
201 | *jwt.RegisteredClaims | ||
202 | } | ||
203 | |||
204 | // Return nil when the object already exists | ||
205 | func (h *handler) handleUploadObject(ctx context.Context, repo string, obj parsedBatchObject) *batchResponseObject { | ||
191 | fullPath := path.Join(repo+".git", "lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) | 206 | fullPath := path.Join(repo+".git", "lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) |
207 | _, err := h.mc.StatObject(ctx, h.bucket, fullPath, minio.GetObjectOptions{}) | ||
208 | if err == nil { | ||
209 | // The object exists | ||
210 | return nil | ||
211 | } | ||
212 | |||
213 | var resp minio.ErrorResponse | ||
214 | if !errors.As(err, &resp) || resp.StatusCode != http.StatusNotFound { | ||
215 | // TODO: consider not making this an object-specific, but rather a | ||
216 | // generic error such that the entire Batch API request fails. | ||
217 | reqlog(ctx, "Failed to generate action href (full path: %s): %s", fullPath, err) | ||
218 | objErr := makeObjError(obj, "Failed to generate action href", http.StatusInternalServerError) | ||
219 | return &objErr | ||
220 | } | ||
221 | |||
192 | expiresIn := time.Hour * 24 | 222 | expiresIn := time.Hour * 24 |
223 | claims := uploadObjectCustomClaims{ | ||
224 | Gitolfs3: uploadObjectGitolfs3Claims{ | ||
225 | Repository: repo, | ||
226 | OID: obj.fullHash, | ||
227 | Size: obj.size, | ||
228 | }, | ||
229 | RegisteredClaims: &jwt.RegisteredClaims{ | ||
230 | IssuedAt: jwt.NewNumericDate(time.Now()), | ||
231 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)), | ||
232 | }, | ||
233 | } | ||
193 | 234 | ||
194 | presigned, err := h.mc.Presign(ctx, http.MethodPut, h.bucket, fullPath, expiresIn, url.Values{ | 235 | token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) |
195 | "x-amz-sdk-checksum-algorithm": {"sha256"}, | 236 | ss, err := token.SignedString(h.privateKey) |
196 | "x-amz-checksum-sha256": {sha256AsBase64(obj.fullHash)}, | ||
197 | "x-amz-content-sha256": {obj.fullHash}, | ||
198 | "Content-Length": {strconv.FormatUint(obj.size, 10)}, | ||
199 | }) | ||
200 | if err != nil { | 237 | if err != nil { |
201 | // TODO: consider not making this an object-specific, but rather a | 238 | // TODO: consider not making this an object-specific, but rather a |
202 | // generic error such that the entire Batch API request fails. | 239 | // generic error such that the entire Batch API request fails. |
203 | reqlog(ctx, "Failed to generate action href (full path: %s): %s", fullPath, err) | 240 | reqlog(ctx, "Fatal: failed to generate JWT: %s", err) |
204 | return makeObjError(obj, "Failed to generate action href", http.StatusInternalServerError) | 241 | objErr := makeObjError(obj, "Failed to generate token", http.StatusInternalServerError) |
242 | return &objErr | ||
205 | } | 243 | } |
206 | 244 | ||
245 | uploadPath := path.Join(repo+".git", "info/lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) | ||
246 | uploadHRef := h.baseURL.ResolveReference(&url.URL{Path: uploadPath}).String() | ||
247 | // The object does not exist. | ||
207 | authenticated := true | 248 | authenticated := true |
208 | return batchResponseObject{ | 249 | return &batchResponseObject{ |
209 | OID: obj.fullHash, | 250 | OID: obj.fullHash, |
210 | Size: obj.size, | 251 | Size: obj.size, |
211 | Authenticated: &authenticated, | 252 | Authenticated: &authenticated, |
212 | Actions: map[operation]batchAction{ | 253 | Actions: map[operation]batchAction{ |
213 | operationUpload: { | 254 | operationUpload: { |
214 | HRef: presigned.String(), | 255 | Header: map[string]string{ |
256 | "Authorization": "Bearer " + ss, | ||
257 | }, | ||
258 | HRef: uploadHRef, | ||
215 | ExpiresIn: int64(expiresIn.Seconds()), | 259 | ExpiresIn: int64(expiresIn.Seconds()), |
216 | }, | 260 | }, |
217 | }, | 261 | }, |
218 | } | 262 | } |
219 | } | 263 | } |
220 | 264 | ||
265 | type validatingReader struct { | ||
266 | promisedSize int64 | ||
267 | promisedSha256 []byte | ||
268 | |||
269 | reader io.Reader | ||
270 | bytesRead int64 | ||
271 | current hash.Hash | ||
272 | err error | ||
273 | } | ||
274 | |||
275 | func newValidatingReader(promisedSize int64, promisedSha256 []byte, r io.Reader) *validatingReader { | ||
276 | return &validatingReader{ | ||
277 | promisedSize: promisedSize, | ||
278 | promisedSha256: promisedSha256, | ||
279 | reader: r, | ||
280 | current: sha256.New(), | ||
281 | } | ||
282 | } | ||
283 | |||
284 | var errTooBig = errors.New("validator: uploaded file bigger than indicated") | ||
285 | var errTooSmall = errors.New("validator: uploaded file smaller than indicated") | ||
286 | var errBadSum = errors.New("validator: bad checksum provided or file corrupted") | ||
287 | |||
288 | func (i *validatingReader) Read(b []byte) (int, error) { | ||
289 | if i.err != nil { | ||
290 | return 0, i.err | ||
291 | } | ||
292 | n, err := i.reader.Read(b) | ||
293 | i.bytesRead += int64(n) | ||
294 | if i.bytesRead > i.promisedSize { | ||
295 | i.err = errTooBig | ||
296 | return 0, i.err | ||
297 | } | ||
298 | if err != nil && errors.Is(err, io.EOF) { | ||
299 | if i.bytesRead < i.promisedSize { | ||
300 | i.err = errTooSmall | ||
301 | return n, i.err | ||
302 | } | ||
303 | } | ||
304 | // According to the documentation, Hash.Write never returns an error | ||
305 | i.current.Write(b[:n]) | ||
306 | if i.bytesRead == i.promisedSize { | ||
307 | if !bytes.Equal(i.promisedSha256, i.current.Sum(nil)) { | ||
308 | i.err = errBadSum | ||
309 | return 0, i.err | ||
310 | } | ||
311 | } | ||
312 | return n, err | ||
313 | } | ||
314 | |||
315 | func (h *handler) handlePutObject(w http.ResponseWriter, r *http.Request, repo, oid string) { | ||
316 | ctx := r.Context() | ||
317 | |||
318 | authz := r.Header.Get("Authorization") | ||
319 | if authz == "" { | ||
320 | makeRespError(ctx, w, "Missing Authorization header", http.StatusBadRequest) | ||
321 | return | ||
322 | } | ||
323 | if !strings.HasPrefix(authz, "Bearer ") { | ||
324 | makeRespError(ctx, w, "Invalid Authorization header", http.StatusBadRequest) | ||
325 | return | ||
326 | } | ||
327 | authz = strings.TrimPrefix(authz, "Bearer ") | ||
328 | |||
329 | var claims uploadObjectCustomClaims | ||
330 | _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) { | ||
331 | if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { | ||
332 | return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"]) | ||
333 | } | ||
334 | return h.privateKey.Public(), nil | ||
335 | }) | ||
336 | if err != nil { | ||
337 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
338 | return | ||
339 | } | ||
340 | if claims.Gitolfs3.Repository != repo { | ||
341 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
342 | return | ||
343 | } | ||
344 | if claims.Gitolfs3.OID != oid { | ||
345 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
346 | return | ||
347 | } | ||
348 | |||
349 | // Check with claims | ||
350 | if lengthStr := r.Header.Get("Content-Length"); lengthStr != "" { | ||
351 | length, err := strconv.ParseInt(lengthStr, 10, 64) | ||
352 | if err != nil { | ||
353 | makeRespError(ctx, w, "Bad Content-Length format", http.StatusBadRequest) | ||
354 | return | ||
355 | } | ||
356 | if length != claims.Gitolfs3.Size { | ||
357 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
358 | return | ||
359 | } | ||
360 | } | ||
361 | |||
362 | sha256Raw, err := hex.DecodeString(oid) | ||
363 | if err != nil || len(sha256Raw) != sha256.Size { | ||
364 | makeRespError(ctx, w, "Invalid OID", http.StatusBadRequest) | ||
365 | return | ||
366 | } | ||
367 | |||
368 | reader := newValidatingReader(claims.Gitolfs3.Size, sha256Raw, r.Body) | ||
369 | |||
370 | fullPath := path.Join(repo+".git", "lfs/objects", oid[:2], oid[2:4], oid) | ||
371 | _, err = h.mc.PutObject(ctx, h.bucket, fullPath, reader, int64(claims.Gitolfs3.Size), minio.PutObjectOptions{ | ||
372 | SendContentMd5: true, | ||
373 | }) | ||
374 | if err != nil { | ||
375 | makeRespError(ctx, w, "Failed to upload object", http.StatusInternalServerError) | ||
376 | return | ||
377 | } | ||
378 | } | ||
379 | |||
221 | type parsedBatchObject struct { | 380 | type parsedBatchObject struct { |
222 | firstByte string | 381 | firstByte string |
223 | secondByte string | 382 | secondByte string |
224 | fullHash string | 383 | fullHash string |
225 | size uint64 | 384 | size int64 |
226 | } | 385 | } |
227 | 386 | ||
228 | func isLFSMediaType(t string) bool { | 387 | func isLFSMediaType(t string) bool { |
@@ -236,20 +395,21 @@ func isLFSMediaType(t string) bool { | |||
236 | return false | 395 | return false |
237 | } | 396 | } |
238 | 397 | ||
239 | var re = regexp.MustCompile(`^([a-zA-Z0-9-_/]+)\.git/info/lfs/objects/batch$`) | 398 | var reBatchAPI = regexp.MustCompile(`^([a-zA-Z0-9-_/]+)\.git/info/lfs/objects/batch$`) |
399 | var reObjUpload = regexp.MustCompile(`^([a-zA-Z0-9-_/]+)\.git/info/lfs/objects/([0-9a-f]{2})/([0-9a-f]{2})/([0-9a-f]{2}){64}$`) | ||
240 | 400 | ||
241 | type requestID struct{} | 401 | type requestID struct{} |
242 | 402 | ||
243 | var requestIDKey requestID | 403 | var requestIDKey requestID |
244 | 404 | ||
245 | // TODO: make a shared package for this | 405 | // TODO: make a shared package for this |
246 | type gitolfs3Claims struct { | 406 | type lfsAuthGitolfs3Claims struct { |
247 | Repository string `json:"repository"` | 407 | Repository string `json:"repository"` |
248 | Permission operation `json:"permission"` | 408 | Permission operation `json:"permission"` |
249 | } | 409 | } |
250 | 410 | ||
251 | type customClaims struct { | 411 | type lfsAuthCustomClaims struct { |
252 | Gitolfs3 gitolfs3Claims `json:"gitolfs3"` | 412 | Gitolfs3 lfsAuthGitolfs3Claims `json:"gitolfs3"` |
253 | *jwt.RegisteredClaims | 413 | *jwt.RegisteredClaims |
254 | } | 414 | } |
255 | 415 | ||
@@ -278,8 +438,9 @@ func (h *handler) getGitoliteAccess(repo, user, gitolitePerm string, refspec *st | |||
278 | return true, nil | 438 | return true, nil |
279 | } | 439 | } |
280 | 440 | ||
281 | func (h *handler) authorize(ctx context.Context, w http.ResponseWriter, r *http.Request, or operationRequest) bool { | 441 | func (h *handler) authorize(w http.ResponseWriter, r *http.Request, or operationRequest) bool { |
282 | user := h.anonUser | 442 | user := h.anonUser |
443 | ctx := r.Context() | ||
283 | 444 | ||
284 | if authz := r.Header.Get("Authorization"); authz != "" { | 445 | if authz := r.Header.Get("Authorization"); authz != "" { |
285 | if !strings.HasPrefix(authz, "Bearer ") { | 446 | if !strings.HasPrefix(authz, "Bearer ") { |
@@ -288,12 +449,12 @@ func (h *handler) authorize(ctx context.Context, w http.ResponseWriter, r *http. | |||
288 | } | 449 | } |
289 | authz = strings.TrimPrefix(authz, "Bearer ") | 450 | authz = strings.TrimPrefix(authz, "Bearer ") |
290 | 451 | ||
291 | var claims customClaims | 452 | var claims lfsAuthCustomClaims |
292 | _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) { | 453 | _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) { |
293 | if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { | 454 | if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { |
294 | return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"]) | 455 | return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"]) |
295 | } | 456 | } |
296 | return h.publicKey, nil | 457 | return h.privateKey.Public(), nil |
297 | }) | 458 | }) |
298 | if err != nil { | 459 | if err != nil { |
299 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | 460 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) |
@@ -339,29 +500,8 @@ func (h *handler) authorize(ctx context.Context, w http.ResponseWriter, r *http. | |||
339 | return true | 500 | return true |
340 | } | 501 | } |
341 | 502 | ||
342 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 503 | func (h *handler) handleBatchAPI(w http.ResponseWriter, r *http.Request, repo string) { |
343 | ctx := context.WithValue(r.Context(), requestIDKey, xid.New().String()) | 504 | ctx := r.Context() |
344 | |||
345 | if r.Method != http.MethodPost { | ||
346 | makeRespError(ctx, w, "Method not allowed", http.StatusMethodNotAllowed) | ||
347 | return | ||
348 | } | ||
349 | |||
350 | reqPath := os.Getenv("PATH_INFO") | ||
351 | if reqPath == "" { | ||
352 | reqPath = r.URL.Path | ||
353 | } | ||
354 | reqlog(ctx, "reqPath: %s", reqPath) | ||
355 | reqPath = strings.TrimPrefix(path.Clean(reqPath), "/") | ||
356 | reqlog(ctx, "Cleaned reqPath: %s", reqPath) | ||
357 | submatches := re.FindStringSubmatch(reqPath) | ||
358 | if len(submatches) != 2 { | ||
359 | reqlog(ctx, "Got path: %s, did not match regex", reqPath) | ||
360 | makeRespError(ctx, w, "Not found", http.StatusNotFound) | ||
361 | return | ||
362 | } | ||
363 | repo := strings.TrimPrefix(path.Clean(submatches[1]), "/") | ||
364 | reqlog(ctx, "Repository: %s", repo) | ||
365 | 505 | ||
366 | if !slices.ContainsFunc(r.Header.Values("Accept"), isLFSMediaType) { | 506 | if !slices.ContainsFunc(r.Header.Values("Accept"), isLFSMediaType) { |
367 | makeRespError(ctx, w, "Expected "+lfsMIME+" (with UTF-8 charset) in list of acceptable response media types", http.StatusNotAcceptable) | 507 | makeRespError(ctx, w, "Expected "+lfsMIME+" (with UTF-8 charset) in list of acceptable response media types", http.StatusNotAcceptable) |
@@ -389,7 +529,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
389 | if body.Ref != nil { | 529 | if body.Ref != nil { |
390 | or.refspec = &body.Ref.Name | 530 | or.refspec = &body.Ref.Name |
391 | } | 531 | } |
392 | if !h.authorize(ctx, w, r, or) { | 532 | if !h.authorize(w, r.WithContext(ctx), or) { |
393 | return | 533 | return |
394 | } | 534 | } |
395 | 535 | ||
@@ -427,7 +567,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
427 | case operationDownload: | 567 | case operationDownload: |
428 | resp.Objects = append(resp.Objects, h.handleDownloadObject(ctx, repo, obj)) | 568 | resp.Objects = append(resp.Objects, h.handleDownloadObject(ctx, repo, obj)) |
429 | case operationUpload: | 569 | case operationUpload: |
430 | resp.Objects = append(resp.Objects, h.handleUploadObject(ctx, repo, obj)) | 570 | if respObj := h.handleUploadObject(ctx, repo, obj); respObj != nil { |
571 | resp.Objects = append(resp.Objects, *respObj) | ||
572 | } | ||
431 | } | 573 | } |
432 | } | 574 | } |
433 | 575 | ||
@@ -436,6 +578,53 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
436 | json.NewEncoder(w).Encode(resp) | 578 | json.NewEncoder(w).Encode(resp) |
437 | } | 579 | } |
438 | 580 | ||
581 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
582 | ctx := context.WithValue(r.Context(), requestIDKey, xid.New().String()) | ||
583 | |||
584 | reqPath := os.Getenv("PATH_INFO") | ||
585 | if reqPath == "" { | ||
586 | reqPath = r.URL.Path | ||
587 | } | ||
588 | reqPath = strings.TrimPrefix(path.Clean(reqPath), "/") | ||
589 | |||
590 | if submatches := reBatchAPI.FindStringSubmatch(reqPath); len(submatches) == 2 { | ||
591 | repo := strings.TrimPrefix(path.Clean(submatches[1]), "/") | ||
592 | reqlog(ctx, "Repository: %s", repo) | ||
593 | |||
594 | if r.Method != http.MethodPost { | ||
595 | makeRespError(ctx, w, "Method not allowed", http.StatusMethodNotAllowed) | ||
596 | return | ||
597 | } | ||
598 | |||
599 | h.handleBatchAPI(w, r.WithContext(ctx), repo) | ||
600 | return | ||
601 | } | ||
602 | |||
603 | if submatches := reObjUpload.FindStringSubmatch(reqPath); len(submatches) == 5 { | ||
604 | repo := strings.TrimPrefix(path.Clean(submatches[1]), "/") | ||
605 | oid0, oid1, oid := submatches[2], submatches[3], submatches[4] | ||
606 | |||
607 | if !isValidSHA256Hash(oid) { | ||
608 | panic("Regex should only allow valid SHA256 hashes") | ||
609 | } | ||
610 | if oid0 != oid[:2] || oid1 != oid[2:4] { | ||
611 | makeRespError(ctx, w, "Bad URL format: malformed OID pattern", http.StatusBadRequest) | ||
612 | return | ||
613 | } | ||
614 | reqlog(ctx, "Repository: %s; OID: %s", repo, oid) | ||
615 | |||
616 | if r.Method != http.MethodPost { | ||
617 | makeRespError(ctx, w, "Method not allowed", http.StatusMethodNotAllowed) | ||
618 | return | ||
619 | } | ||
620 | |||
621 | h.handleBatchAPI(w, r.WithContext(ctx), repo) | ||
622 | return | ||
623 | } | ||
624 | |||
625 | makeRespError(ctx, w, "Not found", http.StatusNotFound) | ||
626 | } | ||
627 | |||
439 | func reqlog(ctx context.Context, msg string, args ...any) { | 628 | func reqlog(ctx context.Context, msg string, args ...any) { |
440 | fmt.Fprint(os.Stderr, "[gitolfs3] ") | 629 | fmt.Fprint(os.Stderr, "[gitolfs3] ") |
441 | if val := ctx.Value(requestIDKey); val != nil { | 630 | if val := ctx.Value(requestIDKey); val != nil { |
@@ -460,31 +649,38 @@ func die(msg string, args ...any) { | |||
460 | os.Exit(1) | 649 | os.Exit(1) |
461 | } | 650 | } |
462 | 651 | ||
463 | func loadPublicKey(path string) ed25519.PublicKey { | 652 | func loadPrivateKey(path string) ed25519.PrivateKey { |
464 | raw, err := os.ReadFile(path) | 653 | raw, err := os.ReadFile(path) |
465 | if err != nil { | 654 | if err != nil { |
466 | die("Failed to open specified public key: %s", err) | 655 | die("Failed to open specified public key: %s", err) |
467 | } | 656 | } |
468 | raw = bytes.TrimSpace(raw) | 657 | raw = bytes.TrimSpace(raw) |
469 | 658 | ||
470 | if hex.DecodedLen(len(raw)) != ed25519.PublicKeySize { | 659 | if hex.DecodedLen(len(raw)) != ed25519.SeedSize { |
471 | die("Specified public key file does not contain key of appropriate length") | 660 | die("Specified public key file does not contain key (seed) of appropriate length") |
472 | } | 661 | } |
473 | decoded := make([]byte, hex.DecodedLen(len(raw))) | 662 | decoded := make([]byte, hex.DecodedLen(len(raw))) |
474 | if _, err = hex.Decode(decoded, raw); err != nil { | 663 | if _, err = hex.Decode(decoded, raw); err != nil { |
475 | die("Failed to decode specified public key: %s", err) | 664 | die("Failed to decode specified public key: %s", err) |
476 | } | 665 | } |
477 | return decoded | 666 | return ed25519.NewKeyFromSeed(decoded) |
667 | } | ||
668 | |||
669 | func wipe(b []byte) { | ||
670 | for i := range b { | ||
671 | b[i] = 0 | ||
672 | } | ||
478 | } | 673 | } |
479 | 674 | ||
480 | func main() { | 675 | func main() { |
481 | anonUser := os.Getenv("ANON_USER") | 676 | anonUser := os.Getenv("ANON_USER") |
482 | publicKeyPath := os.Getenv("GITOLFS3_PUBLIC_KEY_PATH") | 677 | privateKeyPath := os.Getenv("GITOLFS3_PRIVATE_KEY_PATH") |
483 | endpoint := os.Getenv("S3_ENDPOINT") | 678 | endpoint := os.Getenv("S3_ENDPOINT") |
484 | bucket := os.Getenv("S3_BUCKET") | 679 | bucket := os.Getenv("S3_BUCKET") |
485 | accessKeyIDFile := os.Getenv("S3_ACCESS_KEY_ID_FILE") | 680 | accessKeyIDFile := os.Getenv("S3_ACCESS_KEY_ID_FILE") |
486 | secretAccessKeyFile := os.Getenv("S3_SECRET_ACCESS_KEY_FILE") | 681 | secretAccessKeyFile := os.Getenv("S3_SECRET_ACCESS_KEY_FILE") |
487 | gitolitePath := os.Getenv("GITOLITE_PATH") | 682 | gitolitePath := os.Getenv("GITOLITE_PATH") |
683 | baseURLStr := os.Getenv("BASE_URL") | ||
488 | 684 | ||
489 | if gitolitePath == "" { | 685 | if gitolitePath == "" { |
490 | gitolitePath = "gitolite" | 686 | gitolitePath = "gitolite" |
@@ -493,8 +689,11 @@ func main() { | |||
493 | if anonUser == "" { | 689 | if anonUser == "" { |
494 | die("Fatal: expected environment variable ANON_USER to be set") | 690 | die("Fatal: expected environment variable ANON_USER to be set") |
495 | } | 691 | } |
496 | if publicKeyPath == "" { | 692 | if privateKeyPath == "" { |
497 | die("Fatal: expected environment variable GITOLFS3_PUBLIC_KEY_PATH to be set") | 693 | die("Fatal: expected environment variable GITOLFS3_PRIVATE_KEY_PATH to be set") |
694 | } | ||
695 | if baseURLStr == "" { | ||
696 | die("Fatal: expected environment variable BASE_URL to be set") | ||
498 | } | 697 | } |
499 | if endpoint == "" { | 698 | if endpoint == "" { |
500 | die("Fatal: expected environment variable S3_ENDPOINT to be set") | 699 | die("Fatal: expected environment variable S3_ENDPOINT to be set") |
@@ -519,7 +718,13 @@ func main() { | |||
519 | die("Fatal: failed to read secret access key from specified file: %s", err) | 718 | die("Fatal: failed to read secret access key from specified file: %s", err) |
520 | } | 719 | } |
521 | 720 | ||
522 | publicKey := loadPublicKey(publicKeyPath) | 721 | privateKey := loadPrivateKey(privateKeyPath) |
722 | defer wipe(privateKey) | ||
723 | |||
724 | baseURL, err := url.Parse(baseURLStr) | ||
725 | if err != nil { | ||
726 | die("Fatal: provided BASE_URL has bad format: %s", err) | ||
727 | } | ||
523 | 728 | ||
524 | mc, err := minio.New(endpoint, &minio.Options{ | 729 | mc, err := minio.New(endpoint, &minio.Options{ |
525 | Creds: credentials.NewStaticV4(string(accessKeyID), string(secretAccessKey), ""), | 730 | Creds: credentials.NewStaticV4(string(accessKeyID), string(secretAccessKey), ""), |
@@ -529,7 +734,7 @@ func main() { | |||
529 | die("Fatal: failed to create S3 client: %s", err) | 734 | die("Fatal: failed to create S3 client: %s", err) |
530 | } | 735 | } |
531 | 736 | ||
532 | if err = cgi.Serve(&handler{mc, bucket, anonUser, gitolitePath, publicKey}); err != nil { | 737 | if err = cgi.Serve(&handler{mc, bucket, anonUser, gitolitePath, privateKey, baseURL}); err != nil { |
533 | die("Fatal: failed to serve CGI: %s", err) | 738 | die("Fatal: failed to serve CGI: %s", err) |
534 | } | 739 | } |
535 | } | 740 | } |