diff --git a/data.go b/data.go index 36aaa61..406742e 100644 --- a/data.go +++ b/data.go @@ -24,12 +24,13 @@ import ( ) const ( - optFit = "fit" - optFlipVertical = "fv" - optFlipHorizontal = "fh" - optRotatePrefix = "r" - optQualityPrefix = "q" - optSizeDelimiter = "x" + optFit = "fit" + optFlipVertical = "fv" + optFlipHorizontal = "fh" + optRotatePrefix = "r" + optQualityPrefix = "q" + optSignaturePrefix = "s" + optSizeDelimiter = "x" ) // URLError reports a malformed URL error. @@ -61,6 +62,9 @@ type Options struct { // Quality of output image Quality int + + // HMAC Signature for signed requests. + Signature string } var emptyOptions = Options{} @@ -83,6 +87,9 @@ func (o Options) String() string { if o.Quality != 0 { fmt.Fprintf(buf, ",%s%d", string(optQualityPrefix), o.Quality) } + if o.Signature != "" { + fmt.Fprintf(buf, ",%s%s", string(optSignaturePrefix), o.Signature) + } return buf.String() } @@ -162,6 +169,8 @@ func ParseOptions(str string) Options { case strings.HasPrefix(opt, optQualityPrefix): value := strings.TrimPrefix(opt, optQualityPrefix) options.Quality, _ = strconv.Atoi(value) + case strings.HasPrefix(opt, optSignaturePrefix): + options.Signature = strings.TrimPrefix(opt, optSignaturePrefix) 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 63513b8..e799854 100644 --- a/data_test.go +++ b/data_test.go @@ -29,12 +29,12 @@ func TestOptions_String(t *testing.T) { "0x0", }, { - Options{1, 2, true, 90, true, true, 80}, + Options{1, 2, true, 90, true, true, 80, ""}, "1x2,fit,r90,fv,fh,q80", }, { - Options{0.15, 1.3, false, 45, false, false, 95}, - "0.15x1.3,r45,q95", + Options{0.15, 1.3, false, 45, false, false, 95, "c0ffee"}, + "0.15x1.3,r45,q95,sc0ffee", }, } @@ -82,8 +82,8 @@ func TestParseOptions(t *testing.T) { {"FOO,1,BAR,r90,BAZ", Options{Width: 1, Height: 1, Rotate: 90}}, // all flags, in different orders - {"q70,1x2,fit,r90,fv,fh", Options{1, 2, true, 90, true, true, 70}}, - {"r90,fh,q90,1x2,fv,fit", Options{1, 2, true, 90, true, true, 90}}, + {"q70,1x2,fit,r90,fv,fh,sc0ffee", Options{1, 2, true, 90, true, true, 70, "c0ffee"}}, + {"r90,fh,sc0ffee,q90,1x2,fv,fit", Options{1, 2, true, 90, true, true, 90, "c0ffee"}}, } for _, tt := range tests { diff --git a/imageproxy.go b/imageproxy.go index 9a603c4..eda0ec2 100644 --- a/imageproxy.go +++ b/imageproxy.go @@ -19,6 +19,9 @@ package imageproxy // import "willnorris.com/go/imageproxy" import ( "bufio" "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" "fmt" "io" "io/ioutil" @@ -48,6 +51,9 @@ type Proxy struct { // reference to. If nil, all remote URLs specified in requests must be // absolute. DefaultBaseURL *url.URL + + // SignatureKey is the HMAC key used to verify signed requests. + SignatureKey []byte } // NewProxy constructs a new proxy. The provided http RoundTripper will be @@ -89,7 +95,7 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { } if !p.allowed(req) { - msg := fmt.Sprintf("request does not contain an allowed host") + msg := fmt.Sprintf("request does not contain an allowed host or valid signature") glog.Error(msg) http.Error(w, msg, http.StatusForbidden) return @@ -136,17 +142,24 @@ func copyHeader(w http.ResponseWriter, r *http.Response, header string) { } // allowed returns whether the specified request is allowed because it matches -// a host in the proxy whitelist. +// a host in the proxy whitelist or it has a valid signature. func (p *Proxy) allowed(r *Request) bool { - if len(p.Whitelist) == 0 { - return true // no whitelist, all requests accepted + if len(p.Whitelist) == 0 && len(p.SignatureKey) == 0 { + return true // no whitelist or signature key, all requests accepted } if len(p.Whitelist) > 0 { if validHost(p.Whitelist, r.URL) { return true } - glog.Infof("remote URL is not for an allowed host: %v", r.URL) + glog.Infof("request is not for an allowed host: %v", r) + } + + if len(p.SignatureKey) > 0 { + if validSignature(p.SignatureKey, r) { + return true + } + glog.Infof("request contains invalid signature: %v", r) } return false @@ -166,6 +179,26 @@ func validHost(hosts []string, u *url.URL) bool { return false } +// validSignature returns whether the request signature is valid. +func validSignature(key []byte, r *Request) bool { + sig := r.Options.Signature + if m := len(sig) % 4; m != 0 { // add padding if missing + sig += strings.Repeat("=", 4-m) + } + + got, err := base64.URLEncoding.DecodeString(sig) + if err != nil { + glog.Errorf("error base64 decoding signature %q", r.Options.Signature) + return false + } + + mac := hmac.New(sha256.New, key) + mac.Write([]byte(r.URL.String())) + want := mac.Sum(nil) + + return hmac.Equal(got, want) +} + // check304 checks whether we should send a 304 Not Modified in response to // req, based on the response resp. This is determined using the last modified // time and the entity tag of resp. diff --git a/imageproxy_test.go b/imageproxy_test.go index ece73a1..7e27917 100644 --- a/imageproxy_test.go +++ b/imageproxy_test.go @@ -15,31 +15,46 @@ import ( ) func TestAllowed(t *testing.T) { - whitelist := []string{"good.test"} + whitelist := []string{"good"} + key := []byte("c0ffee") tests := []struct { url string + options Options whitelist []string + key []byte allowed bool }{ - {"http://foo/image", nil, true}, - {"http://foo/image", []string{}, true}, + // no whitelist or signature key + {"http://test/image", emptyOptions, nil, nil, true}, - {"http://good.test/image", whitelist, true}, - {"http://bad.test/image", whitelist, false}, + // whitelist + {"http://good/image", emptyOptions, whitelist, nil, true}, + {"http://bad/image", emptyOptions, whitelist, nil, false}, + + // signature key + {"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ="}, nil, key, true}, + {"http://test/image", Options{Signature: "deadbeef"}, nil, key, false}, + {"http://test/image", emptyOptions, nil, key, false}, + + // whitelist and signature + {"http://good/image", emptyOptions, whitelist, key, true}, + {"http://bad/image", Options{Signature: "gWivrPhXBbsYEwpmWAKjbJEiAEgZwbXbltg95O2tgNI="}, nil, key, true}, + {"http://bad/image", emptyOptions, whitelist, key, false}, } for _, tt := range tests { p := NewProxy(nil, nil) p.Whitelist = tt.whitelist + p.SignatureKey = tt.key u, err := url.Parse(tt.url) if err != nil { t.Errorf("error parsing url %q: %v", tt.url, err) } - req := &Request{u, emptyOptions} + req := &Request{u, tt.options} if got, want := p.allowed(req), tt.allowed; got != want { - t.Errorf("allowed(%q) returned %v, want %v", u, got, want) + t.Errorf("allowed(%q) returned %v, want %v", req, got, want) } } } @@ -74,6 +89,31 @@ func TestValidHost(t *testing.T) { } } +func TestValidSignature(t *testing.T) { + key := []byte("c0ffee") + + tests := []struct { + url string + options Options + valid bool + }{ + {"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ="}, true}, + {"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ"}, true}, + {"http://test/image", emptyOptions, false}, + } + + for _, tt := range tests { + u, err := url.Parse(tt.url) + if err != nil { + t.Errorf("error parsing url %q: %v", tt.url, err) + } + req := &Request{u, tt.options} + if got, want := validSignature(key, req), tt.valid; got != want { + t.Errorf("validSignature(%v, %q) returned %v, want %v", key, u, got, want) + } + } +} + func TestCheck304(t *testing.T) { tests := []struct { req, resp string