mirror of
https://github.com/willnorris/imageproxy.git
synced 2026-04-24 20:36:24 +02:00
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:
parent
b98b3455a1
commit
fe35d19c3e
5 changed files with 58 additions and 5 deletions
|
|
@ -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
|
In other words, requests that match one of the allowed hosts don't necessarily
|
||||||
need to be signed, though they can be.
|
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
|
### Default Base URL
|
||||||
|
|
||||||
Typically, remote images to be proxied are specified as absolute URLs.
|
Typically, remote images to be proxied are specified as absolute URLs.
|
||||||
|
|
|
||||||
20
data.go
20
data.go
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ const (
|
||||||
optCropHeight = "ch"
|
optCropHeight = "ch"
|
||||||
optSmartCrop = "sc"
|
optSmartCrop = "sc"
|
||||||
optTrim = "trim"
|
optTrim = "trim"
|
||||||
|
optValidUntil = "vu"
|
||||||
)
|
)
|
||||||
|
|
||||||
// URLError reports a malformed URL error.
|
// 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
|
// If true, automatically trim pixels of the same color around the edges
|
||||||
Trim bool
|
Trim bool
|
||||||
|
|
||||||
|
// If non-zero, the URL is valid until this time.
|
||||||
|
ValidUntil time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o Options) String() string {
|
func (o Options) String() string {
|
||||||
|
|
@ -132,7 +137,12 @@ func (o Options) String() string {
|
||||||
if o.Trim {
|
if o.Trim {
|
||||||
opts = append(opts, optTrim)
|
opts = append(opts, optTrim)
|
||||||
}
|
}
|
||||||
|
if !o.ValidUntil.IsZero() {
|
||||||
|
opts = append(opts, fmt.Sprintf("%s%d", optValidUntil, o.ValidUntil.Unix()))
|
||||||
|
}
|
||||||
|
|
||||||
sort.Strings(opts)
|
sort.Strings(opts)
|
||||||
|
|
||||||
return strings.Join(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
|
// that have been resized or cropped. The trim option is applied before other
|
||||||
// options such as cropping or resizing.
|
// 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
|
// Examples
|
||||||
//
|
//
|
||||||
// 0x0 - no resizing
|
// 0x0 - no resizing
|
||||||
|
|
@ -289,6 +304,11 @@ func ParseOptions(str string) Options {
|
||||||
case strings.HasPrefix(opt, optCropHeight):
|
case strings.HasPrefix(opt, optCropHeight):
|
||||||
value := strings.TrimPrefix(opt, optCropHeight)
|
value := strings.TrimPrefix(opt, optCropHeight)
|
||||||
options.CropHeight, _ = strconv.ParseFloat(value, 64)
|
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):
|
case strings.Contains(opt, optSizeDelimiter):
|
||||||
size := strings.SplitN(opt, optSizeDelimiter, 2)
|
size := strings.SplitN(opt, optSizeDelimiter, 2)
|
||||||
if w := size[0]; w != "" {
|
if w := size[0]; w != "" {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var emptyOptions = Options{}
|
var emptyOptions = Options{}
|
||||||
|
|
@ -25,8 +26,8 @@ func TestOptions_String(t *testing.T) {
|
||||||
"1x2,fh,fit,fv,q80,r90",
|
"1x2,fh,fit,fv,q80,r90",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Options{Width: 0.15, Height: 1.3, Rotate: 45, Quality: 95, Signature: "c0ffee", Format: "png"},
|
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",
|
"0.15x1.3,png,q95,r45,sc0ffee,vu123",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Options{Width: 0.15, Height: 1.3, CropX: 100, CropY: 200},
|
Options{Width: 0.15, Height: 1.3, CropX: 100, CropY: 200},
|
||||||
|
|
@ -86,7 +87,7 @@ func TestParseOptions(t *testing.T) {
|
||||||
// flags, in different orders
|
// 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"}},
|
{"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"}},
|
{"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 {
|
for _, tt := range tests {
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,8 @@ type Proxy struct {
|
||||||
// remote server specifies 'private' or 'no-store' in the cache-control
|
// remote server specifies 'private' or 'no-store' in the cache-control
|
||||||
// header.
|
// header.
|
||||||
ForceCache bool
|
ForceCache bool
|
||||||
|
|
||||||
|
timeNow time.Time // current time, used for testing
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProxy constructs a new proxy. The provided http RoundTripper will be
|
// 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)
|
resp, err := p.Client.Do(actualReq)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := fmt.Sprintf("error fetching remote image: %v", err)
|
msg := fmt.Sprintf("error fetching remote image: %v", err)
|
||||||
p.log(msg)
|
p.log(msg)
|
||||||
|
|
@ -371,15 +372,29 @@ var (
|
||||||
errDeniedHost = errors.New("request contains a denied host")
|
errDeniedHost = errors.New("request contains a denied host")
|
||||||
errNotAllowed = errors.New("request does not contain an allowed host or valid signature")
|
errNotAllowed = errors.New("request does not contain an allowed host or valid signature")
|
||||||
errTooManyRedirects = errors.New("too many redirects")
|
errTooManyRedirects = errors.New("too many redirects")
|
||||||
|
errNotValid = errors.New("request is no longer valid")
|
||||||
|
|
||||||
msgNotAllowed = "requested URL is not allowed"
|
msgNotAllowed = "requested URL is not allowed"
|
||||||
msgNotAllowedInRedirect = "requested URL in redirect 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
|
// allowed determines whether the specified request contains an allowed
|
||||||
// referrer, host, and signature. It returns an error if the request is not
|
// 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 {
|
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) {
|
if len(p.Referrers) > 0 && !referrerMatches(p.Referrers, r.Original) {
|
||||||
return errReferrer
|
return errReferrer
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,8 +110,11 @@ func TestAllowed(t *testing.T) {
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
url string
|
url string
|
||||||
|
now time.Time
|
||||||
options Options
|
options Options
|
||||||
allowHosts []string
|
allowHosts []string
|
||||||
denyHosts []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://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/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},
|
{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 {
|
for _, tt := range tests {
|
||||||
|
|
@ -161,6 +169,7 @@ func TestAllowed(t *testing.T) {
|
||||||
p.DenyHosts = tt.denyHosts
|
p.DenyHosts = tt.denyHosts
|
||||||
p.SignatureKeys = tt.keys
|
p.SignatureKeys = tt.keys
|
||||||
p.Referrers = tt.referrers
|
p.Referrers = tt.referrers
|
||||||
|
p.timeNow = tt.now
|
||||||
|
|
||||||
u, err := url.Parse(tt.url)
|
u, err := url.Parse(tt.url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue