go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cipd/appengine/impl/cas/signed_urls.go (about) 1 // Copyright 2017 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package cas 16 17 import ( 18 "bytes" 19 "context" 20 "encoding/base64" 21 "encoding/json" 22 "fmt" 23 "net/url" 24 "strings" 25 "time" 26 27 "go.chromium.org/luci/common/clock" 28 "go.chromium.org/luci/common/errors" 29 "go.chromium.org/luci/grpc/grpcutil" 30 "go.chromium.org/luci/server/caching/layered" 31 32 "go.chromium.org/luci/cipd/appengine/impl/gs" 33 ) 34 35 const ( 36 minSignedURLExpiration = 30 * time.Minute 37 maxSignedURLExpiration = 2 * time.Hour 38 absenceExpiration = time.Minute 39 ) 40 41 type gsObjInfo struct { 42 Size uint64 `json:"size,omitempty"` 43 URL string `json:"url,omitempty"` 44 } 45 46 // Exists returns whether this info refers to a file which exists. 47 func (i *gsObjInfo) Exists() bool { 48 if i == nil { 49 return false 50 } 51 return i.URL != "" 52 } 53 54 // GS path (string) => details about the file at that path (gsObjInfo). 55 var signedURLsCache = layered.RegisterCache(layered.Parameters[*gsObjInfo]{ 56 ProcessCacheCapacity: 4096, 57 GlobalNamespace: "signed_gs_urls_v2", 58 Marshal: func(item *gsObjInfo) ([]byte, error) { 59 return json.Marshal(item) 60 }, 61 Unmarshal: func(blob []byte) (*gsObjInfo, error) { 62 out := &gsObjInfo{} 63 err := json.Unmarshal(blob, out) 64 return out, err 65 }, 66 }) 67 68 // getSignedURL returns a signed URL that can be used to fetch the given file 69 // as well as the size of that file in bytes. 70 // 71 // 'gsPath' should have form '/bucket/path' or the call will panic. 'filename', 72 // if given, will be returned in Content-Disposition header when accessing the 73 // signed URL. It instructs user agents to save the file under the given name. 74 // 75 // 'signAs' is an email of a service account to impersonate when signing or "" 76 // to use the default service account. 77 // 78 // The returned URL is valid for at least 30 min (may be longer). It's expected 79 // that it will be used right away, not stored somewhere. 80 // 81 // On failures returns grpc-annotated errors. In particular, if the requested 82 // file is missing, returns NotFound grpc-annotated error. 83 func getSignedURL(ctx context.Context, gsPath, filename string, signer signerFactory, gs gs.GoogleStorage) (string, uint64, error) { 84 info, err := signedURLsCache.GetOrCreate(ctx, gsPath, func() (*gsObjInfo, time.Duration, error) { 85 info := &gsObjInfo{} 86 switch size, yes, err := gs.Size(ctx, gsPath); { 87 case err != nil: 88 return nil, 0, errors.Annotate(err, "failed to check GS file presence").Err() 89 case !yes: 90 return info, absenceExpiration, nil 91 default: 92 info.Size = size 93 } 94 95 sig, err := signer(ctx) 96 if err != nil { 97 return nil, 0, errors.Annotate(err, "can't create the signer").Err() 98 } 99 100 url, err := signURL(ctx, gsPath, sig, maxSignedURLExpiration) 101 if err != nil { 102 return nil, 0, err 103 } 104 105 // 'url' here is valid for maxSignedURLExpiration. By caching it for 106 // 'max-min' seconds, right before the cache expires the URL will have 107 // lifetime of max-(max-min) == min, which is what we want. 108 info.URL = url 109 return info, maxSignedURLExpiration - minSignedURLExpiration, nil 110 }) 111 112 if err != nil { 113 return "", 0, errors.Annotate(err, "failed to sign URL"). 114 Tag(grpcutil.InternalTag).Err() 115 } 116 117 if !info.Exists() { 118 return "", 0, errors.Reason("object %q doesn't exist", gsPath). 119 Tag(grpcutil.NotFoundTag).Err() 120 } 121 122 signedURL := info.URL 123 // Oddly, response-content-disposition is not signed and can be slapped onto 124 // existing signed URL. We don't complain though, makes live easier. 125 if filename != "" { 126 if strings.ContainsAny(filename, "\"\r\n") { 127 panic("bad filename for Content-Disposition header") 128 } 129 v := url.Values{ 130 "response-content-disposition": { 131 fmt.Sprintf(`attachment; filename="%s"`, filename), 132 }, 133 } 134 signedURL += "&" + v.Encode() 135 } 136 137 return signedURL, info.Size, nil 138 } 139 140 // signURL generates a signed GS URL using the signer. 141 func signURL(ctx context.Context, gsPath string, signer *signer, expiry time.Duration) (string, error) { 142 // See https://cloud.google.com/storage/docs/access-control/signed-urls. 143 // 144 // Basically, we sign a specially crafted multi-line string that encodes 145 // expected parameters of the request. During the actual request, Google 146 // Storage backend will construct the same string and verify that the provided 147 // signature matches it. 148 149 expires := fmt.Sprintf("%d", clock.Now(ctx).Add(expiry).Unix()) 150 151 buf := &bytes.Buffer{} 152 fmt.Fprintf(buf, "GET\n") 153 fmt.Fprintf(buf, "\n") // expected value of 'Content-MD5' header, not used 154 fmt.Fprintf(buf, "\n") // expected value of 'Content-Type' header, not used 155 fmt.Fprintf(buf, "%s\n", expires) 156 fmt.Fprintf(buf, "%s", gsPath) 157 158 _, sig, err := signer.SignBytes(ctx, buf.Bytes()) 159 if err != nil { 160 return "", errors.Annotate(err, "signBytes call failed").Err() 161 } 162 163 u := url.URL{ 164 Scheme: "https", 165 Host: "storage.googleapis.com", 166 Path: gsPath, 167 RawQuery: (url.Values{ 168 "GoogleAccessId": {signer.Email}, 169 "Expires": {expires}, 170 "Signature": {base64.StdEncoding.EncodeToString(sig)}, 171 }).Encode(), 172 } 173 return u.String(), nil 174 }