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  }