diff --git a/README.md b/README.md index 67ebdda..6fd083b 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,14 @@ If both a whiltelist and signatureKey are specified, requests can match either. In other words, requests that match one of the allowed hosts don't necessarily need to be signed, though they can be. +To limit how long a URL is valid (particularly useful for signed URLs), +you can specify a "valid until" time using the `vu` option with a Unix timestamp. +For example, the following signed URL would only be valid until 2020-01-01: + +``` +http://localhost:8080/vu1577836800,sjNcVf6LxzKEvR6Owgg3zhEMN7xbWxlpf-eyYbRfFK4A=/https://example.com/image +``` + ### Default Base URL Typically, remote images to be proxied are specified as absolute URLs. diff --git a/data.go b/data.go index 22118fb..8b5765c 100644 --- a/data.go +++ b/data.go @@ -12,6 +12,7 @@ import ( "sort" "strconv" "strings" + "time" "unicode" ) @@ -33,6 +34,7 @@ const ( optCropHeight = "ch" optSmartCrop = "sc" optTrim = "trim" + optValidUntil = "vu" ) // URLError reports a malformed URL error. @@ -86,6 +88,9 @@ type Options struct { // If true, automatically trim pixels of the same color around the edges Trim bool + + // If non-zero, the URL is valid until this time. + ValidUntil time.Time } func (o Options) String() string { @@ -132,7 +137,12 @@ func (o Options) String() string { if o.Trim { opts = append(opts, optTrim) } + if !o.ValidUntil.IsZero() { + opts = append(opts, fmt.Sprintf("%s%d", optValidUntil, o.ValidUntil.Unix())) + } + sort.Strings(opts) + return strings.Join(opts, ",") } @@ -235,6 +245,11 @@ func (o Options) transform() bool { // that have been resized or cropped. The trim option is applied before other // options such as cropping or resizing. // +// # Valid Until +// +// The "vu{unixtime}" option specifies a Unix timestamp at which the request URL is no longer valid. +// For example, "vu1800000000" would mean the URL is valid until 2027-01-15T08:00:00Z. +// // Examples // // 0x0 - no resizing @@ -289,6 +304,11 @@ func ParseOptions(str string) Options { case strings.HasPrefix(opt, optCropHeight): value := strings.TrimPrefix(opt, optCropHeight) options.CropHeight, _ = strconv.ParseFloat(value, 64) + case strings.HasPrefix(opt, optValidUntil): + value := strings.TrimPrefix(opt, optValidUntil) + if v, _ := strconv.ParseInt(value, 10, 64); v > 0 { + options.ValidUntil = time.Unix(v, 0) + } case strings.Contains(opt, optSizeDelimiter): size := strings.SplitN(opt, optSizeDelimiter, 2) if w := size[0]; w != "" { diff --git a/data_test.go b/data_test.go index af047a3..0e854af 100644 --- a/data_test.go +++ b/data_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "testing" + "time" ) var emptyOptions = Options{} @@ -25,8 +26,8 @@ func TestOptions_String(t *testing.T) { "1x2,fh,fit,fv,q80,r90", }, { - Options{Width: 0.15, Height: 1.3, Rotate: 45, Quality: 95, Signature: "c0ffee", Format: "png"}, - "0.15x1.3,png,q95,r45,sc0ffee", + Options{Width: 0.15, Height: 1.3, Rotate: 45, Quality: 95, Signature: "c0ffee", Format: "png", ValidUntil: time.Unix(123, 0)}, + "0.15x1.3,png,q95,r45,sc0ffee,vu123", }, { Options{Width: 0.15, Height: 1.3, CropX: 100, CropY: 200}, @@ -86,7 +87,7 @@ func TestParseOptions(t *testing.T) { // flags, in different orders {"q70,1x2,fit,r90,fv,fh,sc0ffee,png", Options{Width: 1, Height: 2, Fit: true, Rotate: 90, FlipVertical: true, FlipHorizontal: true, Quality: 70, Signature: "c0ffee", Format: "png"}}, {"r90,fh,sc0ffee,png,q90,1x2,fv,fit", Options{Width: 1, Height: 2, Fit: true, Rotate: 90, FlipVertical: true, FlipHorizontal: true, Quality: 90, Signature: "c0ffee", Format: "png"}}, - {"cx100,cw300,1x2,cy200,ch400,sc,scaleUp", Options{Width: 1, Height: 2, ScaleUp: true, CropX: 100, CropY: 200, CropWidth: 300, CropHeight: 400, SmartCrop: true}}, + {"cx100,cw300,1x2,cy200,ch400,sc,scaleUp,vu1234567890", Options{Width: 1, Height: 2, ScaleUp: true, CropX: 100, CropY: 200, CropWidth: 300, CropHeight: 400, SmartCrop: true, ValidUntil: time.Unix(1234567890, 0)}}, } for _, tt := range tests { diff --git a/imageproxy.go b/imageproxy.go index d51ffc0..9290890 100644 --- a/imageproxy.go +++ b/imageproxy.go @@ -101,6 +101,8 @@ type Proxy struct { // remote server specifies 'private' or 'no-store' in the cache-control // header. ForceCache bool + + timeNow time.Time // current time, used for testing } // NewProxy constructs a new proxy. The provided http RoundTripper will be @@ -278,7 +280,6 @@ func (p *Proxy) serveImage(w http.ResponseWriter, r *http.Request) { } } resp, err := p.Client.Do(actualReq) - if err != nil { msg := fmt.Sprintf("error fetching remote image: %v", err) p.log(msg) @@ -371,15 +372,29 @@ var ( errDeniedHost = errors.New("request contains a denied host") errNotAllowed = errors.New("request does not contain an allowed host or valid signature") errTooManyRedirects = errors.New("too many redirects") + errNotValid = errors.New("request is no longer valid") msgNotAllowed = "requested URL is not allowed" msgNotAllowedInRedirect = "requested URL in redirect is not allowed" ) +func (p *Proxy) now() time.Time { + if !p.timeNow.IsZero() { + return p.timeNow + } + return time.Now() +} + // allowed determines whether the specified request contains an allowed // referrer, host, and signature. It returns an error if the request is not -// allowed. +// allowed or not valid any longer. func (p *Proxy) allowed(r *Request) error { + if !r.Options.ValidUntil.IsZero() { + if !p.now().Before(r.Options.ValidUntil) { + return errNotValid + } + } + if len(p.Referrers) > 0 && !referrerMatches(p.Referrers, r.Original) { return errReferrer } diff --git a/imageproxy_test.go b/imageproxy_test.go index 850924c..57670d1 100644 --- a/imageproxy_test.go +++ b/imageproxy_test.go @@ -110,8 +110,11 @@ func TestAllowed(t *testing.T) { return req } + now := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + tests := []struct { url string + now time.Time options Options allowHosts []string denyHosts []string @@ -153,6 +156,11 @@ func TestAllowed(t *testing.T) { {url: "http://test/image", options: Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ="}, denyHosts: []string{"test"}, keys: key, allowed: false}, {url: "http://127.0.0.1/image", denyHosts: []string{"127.0.0.0/8"}, allowed: false}, {url: "http://127.0.0.1:3000/image", denyHosts: []string{"127.0.0.0/8"}, allowed: false}, + + // valid until options + {url: "http://test/image", now: now, options: Options{ValidUntil: now.Add(time.Second)}, allowed: true}, + {url: "http://test/image", now: now, options: Options{ValidUntil: now.Add(-time.Second)}, allowed: false}, + {url: "http://test/image", now: now, options: Options{ValidUntil: now}, allowed: false}, } for _, tt := range tests { @@ -161,6 +169,7 @@ func TestAllowed(t *testing.T) { p.DenyHosts = tt.denyHosts p.SignatureKeys = tt.keys p.Referrers = tt.referrers + p.timeNow = tt.now u, err := url.Parse(tt.url) if err != nil {