add "valid until" option to limit lifetime of signed requests

Closes #222

Co-authored-by: Will Norris <will@willnorris.com>
This commit is contained in:
sl 2020-03-04 18:18:47 +01:00 committed by Will Norris
parent b98b3455a1
commit fe35d19c3e
5 changed files with 58 additions and 5 deletions

View file

@ -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.

20
data.go
View file

@ -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 != "" {

View file

@ -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 {

View file

@ -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
}

View file

@ -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 {