diff --git a/.gitignore b/.gitignore index bcd0007..87f6127 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ Thumbs.db # Folders _obj _test +.idea # Architecture specific extensions/prefixes *.[568vq] diff --git a/README.md b/README.md index b4b17d0..0feb903 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It's almost dependency-free and only uses [`net/http`](http://golang.org/pkg/net/http/) native package without additional abstractions for better [performance](#performance). Supports multiple [image operations](#supported-image-operations) exposed as a simple [HTTP API](#http-api), -with additional optional features such as **API token authorization**, **HTTP traffic throttle** strategy and **CORS support** for web clients. +with additional optional features such as **API token authorization**, **URL signature protection**, **HTTP traffic throttle** strategy and **CORS support** for web clients. `imaginary` **can read** images **from HTTP POST payloads**, **server local path** or **remote HTTP servers**, supporting **JPEG**, **PNG**, **WEBP**, and optionally **TIFF**, **PDF**, **GIF** and **SVG** formats if `libvips@8.3+` is compiled with proper library bindings. @@ -38,6 +38,7 @@ To get started, take a look the [installation](#installation) steps, [usage](#us - [Usage](#usage) - [HTTP API](#http-api) - [Authorization](#authorization) + - [URL signature](#url-signature) - [Errors](#errors) - [Form data](#form-data) - [Params](#params) @@ -293,6 +294,7 @@ Usage: imaginary -enable-url-source -authorization "Basic AwDJdL2DbwrD==" imaginary -enable-placeholder imaginary -enable-url-source -placeholder ./placeholder.jpg + imaginary -enable-url-signature -url-signature-key 4f46feebafc4b5e988f131c4ff8b5997 -url-signature-salt 88f131c4ff8b59974f46feebafc4b5e9 imaginary -h | -help imaginary -v | -version @@ -313,6 +315,9 @@ Options: -enable-url-source Restrict remote image source processing to certain origins (separated by commas) -enable-placeholder Enable image response placeholder to be used in case of error [default: false] -enable-auth-forwarding Forwards X-Forward-Authorization or Authorization header to the image source server. -enable-url-source flag must be defined. Tip: secure your server from public access to prevent attack vectors + -enable-url-signature Enable URL signature (URL-safe Base64-encoded HMAC digest) [default: false] + -url-signature-key The URL signature key (32 characters minimum) + -url-signature-salt The URL signature salt (32 characters minimum) -allowed-origins Restrict remote image source processing to certain origins (separated by commas) -max-allowed-size Restrict maximum size of http image source (in bytes) -certfile TLS certificate file path @@ -385,6 +390,18 @@ Supported custom placeholder image types are: `JPEG`, `PNG` and `WEBP`. imaginary -p 8080 -placeholder=placeholder.jpg -enable-url-source ``` +Enable URL signature (URL-safe Base64-encoded HMAC digest). + +This feature is particularly useful to protect against multiple image operations attacks and to verify the requester identity. +``` +imaginary -p 8080 -enable-url-signature -url-signature-key 4f46feebafc4b5e988f131c4ff8b5997 -url-signature-salt 88f131c4ff8b59974f46feebafc4b5e9 +``` + +It is recommanded to pass key and salt as environment variables: +``` +URL_SIGNATURE_KEY=4f46feebafc4b5e988f131c4ff8b5997 URL_SIGNATURE_SALT=88f131c4ff8b59974f46feebafc4b5e9 imaginary -p 8080 -enable-url-signature +``` + Increase libvips threads concurrency (experimental): ``` VIPS_CONCURRENCY=10 imaginary -p 8080 -concurrency 10 @@ -438,6 +455,28 @@ Host: localhost:8088 API-Key: secret ``` +### URL signature + +The URL signature is provided by the `sign` request parameter. + +The HMAC-SHA256 hash is created by taking the URL path (including the leading /), the request parameters (alphabetically-sorted, excluding the `sign` one and concatenated with & into a string) and the signature salt. The hash is then base64url-encoded. + +Here an example in Go: +``` +signKey := "4f46feebafc4b5e988f131c4ff8b5997" +signSalt := "88f131c4ff8b59974f46feebafc4b5e9" +urlPath := "/resize" +urlQuery := "file=image.jpg&height=200&type=jpeg&width=300" + +h := hmac.New(sha256.New, []byte(signKey)) +h.Write([]byte(urlPath)) +h.Write([]byte(urlQuery)) +h.Write([]byte(signSalt)) +buf := h.Sum(nil) + +fmt.Println("sign=" + base64.RawURLEncoding.EncodeToString(buf)) +``` + ### Errors `imaginary` will always reply with the proper HTTP status code and JSON body with error details. @@ -512,6 +551,7 @@ Image measures are always in pixels, unless otherwise indicated. - **sigma** `float` - Size of the gaussian mask to use when blurring an image. Example: `15.0` - **minampl** `float` - Minimum amplitude of the gaussian filter to use when blurring an image. Default: Example: `0.5` - **operations** `json` - Pipeline of image operation transformations defined as URL safe encoded JSON array. See [pipeline](#get--post-pipeline) endpoints for more details. +- **sign** `string` - URL signature (URL-safe Base64-encoded HMAC digest) #### GET / Content-Type: `application/json` diff --git a/error.go b/error.go index 885c1d7..cf96667 100644 --- a/error.go +++ b/error.go @@ -18,20 +18,23 @@ const ( InternalError NotFound NotImplemented + Forbidden ) var ( - ErrNotFound = NewError("Not found", NotFound) - ErrInvalidApiKey = NewError("Invalid or missing API key", Unauthorized) - ErrMethodNotAllowed = NewError("Method not allowed", NotAllowed) - ErrUnsupportedMedia = NewError("Unsupported media type", Unsupported) - ErrOutputFormat = NewError("Unsupported output image format", BadRequest) - ErrEmptyBody = NewError("Empty image", BadRequest) - ErrMissingParamFile = NewError("Missing required param: file", BadRequest) - ErrInvalidFilePath = NewError("Invalid file path", BadRequest) - ErrInvalidImageURL = NewError("Invalid image URL", BadRequest) - ErrMissingImageSource = NewError("Cannot process the image due to missing or invalid params", BadRequest) - ErrNotImplemented = NewError("Not implemented endpoint", NotImplemented) + ErrNotFound = NewError("Not found", NotFound) + ErrInvalidApiKey = NewError("Invalid or missing API key", Unauthorized) + ErrMethodNotAllowed = NewError("Method not allowed", NotAllowed) + ErrUnsupportedMedia = NewError("Unsupported media type", Unsupported) + ErrOutputFormat = NewError("Unsupported output image format", BadRequest) + ErrEmptyBody = NewError("Empty image", BadRequest) + ErrMissingParamFile = NewError("Missing required param: file", BadRequest) + ErrInvalidFilePath = NewError("Invalid file path", BadRequest) + ErrInvalidImageURL = NewError("Invalid image URL", BadRequest) + ErrMissingImageSource = NewError("Cannot process the image due to missing or invalid params", BadRequest) + ErrNotImplemented = NewError("Not implemented endpoint", NotImplemented) + ErrInvalidURLSignature = NewError("Invalid URL signature", BadRequest) + ErrURLSignatureMismatch = NewError("URL signature mismatch", Forbidden) ) type Error struct { @@ -70,6 +73,9 @@ func (e Error) HTTPCode() int { if e.Code == NotImplemented { return http.StatusNotImplemented } + if e.Code == Forbidden { + return http.StatusForbidden + } return http.StatusServiceUnavailable } diff --git a/imaginary.go b/imaginary.go index 1f354c1..fdec786 100644 --- a/imaginary.go +++ b/imaginary.go @@ -17,34 +17,37 @@ import ( ) var ( - aAddr = flag.String("a", "", "Bind address") - aPort = flag.Int("p", 8088, "Port to listen") - aVers = flag.Bool("v", false, "Show version") - aVersl = flag.Bool("version", false, "Show version") - aHelp = flag.Bool("h", false, "Show help") - aHelpl = flag.Bool("help", false, "Show help") - aPathPrefix = flag.String("path-prefix", "/", "Url path prefix to listen to") - aCors = flag.Bool("cors", false, "Enable CORS support") - aGzip = flag.Bool("gzip", false, "Enable gzip compression (deprecated)") - aAuthForwarding = flag.Bool("enable-auth-forwarding", false, "Forwards X-Forward-Authorization or Authorization header to the image source server. -enable-url-source flag must be defined. Tip: secure your server from public access to prevent attack vectors") - aEnableURLSource = flag.Bool("enable-url-source", false, "Enable remote HTTP URL image source processing") - aEnablePlaceholder = flag.Bool("enable-placeholder", false, "Enable image response placeholder to be used in case of error") - aAlloweOrigins = flag.String("allowed-origins", "", "Restrict remote image source processing to certain origins (separated by commas)") - aMaxAllowedSize = flag.Int("max-allowed-size", 0, "Restrict maximum size of http image source (in bytes)") - aKey = flag.String("key", "", "Define API key for authorization") - aMount = flag.String("mount", "", "Mount server local directory") - aCertFile = flag.String("certfile", "", "TLS certificate file path") - aKeyFile = flag.String("keyfile", "", "TLS private key file path") - aAuthorization = flag.String("authorization", "", "Defines a constant Authorization header value passed to all the image source servers. -enable-url-source flag must be defined. This overwrites authorization headers forwarding behavior via X-Forward-Authorization") - aPlaceholder = flag.String("placeholder", "", "Image path to image custom placeholder to be used in case of error. Recommended minimum image size is: 1200x1200") - aDisableEndpoints = flag.String("disable-endpoints", "", "Comma separated endpoints to disable. E.g: form,crop,rotate,health") - aHTTPCacheTTL = flag.Int("http-cache-ttl", -1, "The TTL in seconds") - aReadTimeout = flag.Int("http-read-timeout", 60, "HTTP read timeout in seconds") - aWriteTimeout = flag.Int("http-write-timeout", 60, "HTTP write timeout in seconds") - aConcurrency = flag.Int("concurrency", 0, "Throttle concurrency limit per second") - aBurst = flag.Int("burst", 100, "Throttle burst max cache size") - aMRelease = flag.Int("mrelease", 30, "OS memory release interval in seconds") - aCpus = flag.Int("cpus", runtime.GOMAXPROCS(-1), "Number of cpu cores to use") + aAddr = flag.String("a", "", "Bind address") + aPort = flag.Int("p", 8088, "Port to listen") + aVers = flag.Bool("v", false, "Show version") + aVersl = flag.Bool("version", false, "Show version") + aHelp = flag.Bool("h", false, "Show help") + aHelpl = flag.Bool("help", false, "Show help") + aPathPrefix = flag.String("path-prefix", "/", "Url path prefix to listen to") + aCors = flag.Bool("cors", false, "Enable CORS support") + aGzip = flag.Bool("gzip", false, "Enable gzip compression (deprecated)") + aAuthForwarding = flag.Bool("enable-auth-forwarding", false, "Forwards X-Forward-Authorization or Authorization header to the image source server. -enable-url-source flag must be defined. Tip: secure your server from public access to prevent attack vectors") + aEnableURLSource = flag.Bool("enable-url-source", false, "Enable remote HTTP URL image source processing") + aEnablePlaceholder = flag.Bool("enable-placeholder", false, "Enable image response placeholder to be used in case of error") + aEnableURLSignature = flag.Bool("enable-url-signature", false, "Enable URL signature (URL-safe Base64-encoded HMAC digest)") + aURLSignatureKey = flag.String("url-signature-key", "", "The URL signature key (32 characters minimum)") + aURLSignatureSalt = flag.String("url-signature-salt", "", "The URL signature salt (32 characters minimum)") + aAllowedOrigins = flag.String("allowed-origins", "", "Restrict remote image source processing to certain origins (separated by commas)") + aMaxAllowedSize = flag.Int("max-allowed-size", 0, "Restrict maximum size of http image source (in bytes)") + aKey = flag.String("key", "", "Define API key for authorization") + aMount = flag.String("mount", "", "Mount server local directory") + aCertFile = flag.String("certfile", "", "TLS certificate file path") + aKeyFile = flag.String("keyfile", "", "TLS private key file path") + aAuthorization = flag.String("authorization", "", "Defines a constant Authorization header value passed to all the image source servers. -enable-url-source flag must be defined. This overwrites authorization headers forwarding behavior via X-Forward-Authorization") + aPlaceholder = flag.String("placeholder", "", "Image path to image custom placeholder to be used in case of error. Recommended minimum image size is: 1200x1200") + aDisableEndpoints = flag.String("disable-endpoints", "", "Comma separated endpoints to disable. E.g: form,crop,rotate,health") + aHTTPCacheTTL = flag.Int("http-cache-ttl", -1, "The TTL in seconds") + aReadTimeout = flag.Int("http-read-timeout", 60, "HTTP read timeout in seconds") + aWriteTimeout = flag.Int("http-write-timeout", 60, "HTTP write timeout in seconds") + aConcurrency = flag.Int("concurrency", 0, "Throttle concurrency limit per second") + aBurst = flag.Int("burst", 100, "Throttle burst max cache size") + aMRelease = flag.Int("mrelease", 30, "OS memory release interval in seconds") + aCpus = flag.Int("cpus", runtime.GOMAXPROCS(-1), "Number of cpu cores to use") ) const usage = `imaginary %s @@ -61,6 +64,7 @@ Usage: imaginary -enable-url-source -authorization "Basic AwDJdL2DbwrD==" imaginary -enable-placeholder imaginary -enable-url-source -placeholder ./placeholder.jpg + imaginary -enable-url-signature -url-signature-key 4f46feebafc4b5e988f131c4ff8b5997 -url-signature-salt 88f131c4ff8b59974f46feebafc4b5e9 imaginary -h | -help imaginary -v | -version @@ -81,6 +85,9 @@ Options: -enable-url-source Restrict remote image source processing to certain origins (separated by commas) -enable-placeholder Enable image response placeholder to be used in case of error [default: false] -enable-auth-forwarding Forwards X-Forward-Authorization or Authorization header to the image source server. -enable-url-source flag must be defined. Tip: secure your server from public access to prevent attack vectors + -enable-url-signature Enable URL signature (URL-safe Base64-encoded HMAC digest) [default: false] + -url-signature-key The URL signature key (32 characters minimum) + -url-signature-salt The URL signature salt (32 characters minimum) -allowed-origins Restrict remote image source processing to certain origins (separated by commas) -max-allowed-size Restrict maximum size of http image source (in bytes) -certfile TLS certificate file path @@ -94,6 +101,11 @@ Options: (default for current machine is %d cores) ` +type URLSignature struct { + Key string + Salt string +} + func main() { flag.Usage = func() { fmt.Fprint(os.Stderr, fmt.Sprintf(usage, Version, runtime.NumCPU())) @@ -111,27 +123,32 @@ func main() { runtime.GOMAXPROCS(*aCpus) port := getPort(*aPort) + urlSignature := getURLSignature(*aURLSignatureKey, *aURLSignatureSalt) + opts := ServerOptions{ - Port: port, - Address: *aAddr, - CORS: *aCors, - AuthForwarding: *aAuthForwarding, - EnableURLSource: *aEnableURLSource, - EnablePlaceholder: *aEnablePlaceholder, - PathPrefix: *aPathPrefix, - APIKey: *aKey, - Concurrency: *aConcurrency, - Burst: *aBurst, - Mount: *aMount, - CertFile: *aCertFile, - KeyFile: *aKeyFile, - Placeholder: *aPlaceholder, - HTTPCacheTTL: *aHTTPCacheTTL, - HTTPReadTimeout: *aReadTimeout, - HTTPWriteTimeout: *aWriteTimeout, - Authorization: *aAuthorization, - AlloweOrigins: parseOrigins(*aAlloweOrigins), - MaxAllowedSize: *aMaxAllowedSize, + Port: port, + Address: *aAddr, + CORS: *aCors, + AuthForwarding: *aAuthForwarding, + EnableURLSource: *aEnableURLSource, + EnablePlaceholder: *aEnablePlaceholder, + EnableURLSignature: *aEnableURLSignature, + URLSignatureKey: urlSignature.Key, + URLSignatureSalt: urlSignature.Salt, + PathPrefix: *aPathPrefix, + APIKey: *aKey, + Concurrency: *aConcurrency, + Burst: *aBurst, + Mount: *aMount, + CertFile: *aCertFile, + KeyFile: *aKeyFile, + Placeholder: *aPlaceholder, + HTTPCacheTTL: *aHTTPCacheTTL, + HTTPReadTimeout: *aReadTimeout, + HTTPWriteTimeout: *aWriteTimeout, + Authorization: *aAuthorization, + AllowedOrigins: parseOrigins(*aAllowedOrigins), + MaxAllowedSize: *aMaxAllowedSize, } // Show warning if gzip flag is passed @@ -177,6 +194,21 @@ func main() { opts.PlaceholderImage = placeholder } + // Check URL signature key and salt, if required + if *aEnableURLSignature == true { + if urlSignature.Key == "" || urlSignature.Salt == "" { + exitWithError("URL signature key and salt are required") + } + + if len(urlSignature.Key) < 32 { + exitWithError("URL signature key must be a minimum of 32 characters") + } + + if len(urlSignature.Salt) < 32 { + exitWithError("URL signature salt must be a minimum of 32 characters") + } + } + debug("imaginary server listening on port :%d/%s", opts.Port, strings.TrimPrefix(opts.PathPrefix, "/")) // Load image source providers @@ -199,6 +231,18 @@ func getPort(port int) int { return port } +func getURLSignature(key string, salt string) URLSignature { + if keyEnv := os.Getenv("URL_SIGNATURE_KEY"); keyEnv != "" { + key = keyEnv + } + + if saltEnv := os.Getenv("URL_SIGNATURE_SALT"); saltEnv != "" { + salt = saltEnv + } + + return URLSignature{key, salt} +} + func showUsage() { flag.Usage() os.Exit(1) diff --git a/middleware.go b/middleware.go index 6e4194d..9435c91 100644 --- a/middleware.go +++ b/middleware.go @@ -5,6 +5,9 @@ import ( "net/http" "strings" "time" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" "github.com/rs/cors" "gopkg.in/h2non/bimg.v1" @@ -36,7 +39,13 @@ func Middleware(fn func(http.ResponseWriter, *http.Request), o ServerOptions) ht func ImageMiddleware(o ServerOptions) func(Operation) http.Handler { return func(fn Operation) http.Handler { - return validateImage(Middleware(imageController(o, Operation(fn)), o), o) + handler := validateImage(Middleware(imageController(o, Operation(fn)), o), o) + + if o.EnableURLSignature == true { + return validateURLSignature(handler, o) + } + + return handler } } @@ -153,3 +162,32 @@ func getCacheControl(ttl int) string { func isPublicPath(path string) bool { return path == "/" || path == "/health" || path == "/form" } + +func validateURLSignature(next http.Handler, o ServerOptions) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Retrieve and remove URL signature from request parameters + query := r.URL.Query() + sign := query.Get("sign") + query.Del("sign") + + // Compute expected URL signature + h := hmac.New(sha256.New, []byte(o.URLSignatureKey)) + h.Write([]byte(r.URL.Path)) + h.Write([]byte(query.Encode())) + h.Write([]byte(o.URLSignatureSalt)) + expectedSign := h.Sum(nil) + + urlSign, err := base64.RawURLEncoding.DecodeString(sign) + if err != nil { + ErrorReply(r, w, ErrInvalidURLSignature, o) + return + } + + if hmac.Equal(urlSign, expectedSign) == false { + ErrorReply(r, w, ErrURLSignatureMismatch, o) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/server.go b/server.go index a54f12c..c26a992 100644 --- a/server.go +++ b/server.go @@ -11,29 +11,32 @@ import ( ) type ServerOptions struct { - Port int - Burst int - Concurrency int - HTTPCacheTTL int - HTTPReadTimeout int - HTTPWriteTimeout int - MaxAllowedSize int - CORS bool - Gzip bool // deprecated - AuthForwarding bool - EnableURLSource bool - EnablePlaceholder bool - Address string - PathPrefix string - APIKey string - Mount string - CertFile string - KeyFile string - Authorization string - Placeholder string - PlaceholderImage []byte - Endpoints Endpoints - AlloweOrigins []*url.URL + Port int + Burst int + Concurrency int + HTTPCacheTTL int + HTTPReadTimeout int + HTTPWriteTimeout int + MaxAllowedSize int + CORS bool + Gzip bool // deprecated + AuthForwarding bool + EnableURLSource bool + EnablePlaceholder bool + EnableURLSignature bool + URLSignatureKey string + URLSignatureSalt string + Address string + PathPrefix string + APIKey string + Mount string + CertFile string + KeyFile string + Authorization string + Placeholder string + PlaceholderImage []byte + Endpoints Endpoints + AllowedOrigins []*url.URL } // Endpoints represents a list of endpoint names to disable. diff --git a/source.go b/source.go index 882edd0..2c70ad1 100644 --- a/source.go +++ b/source.go @@ -36,7 +36,7 @@ func LoadSources(o ServerOptions) { MountPath: o.Mount, AuthForwarding: o.AuthForwarding, Authorization: o.Authorization, - AllowedOrigings: o.AlloweOrigins, + AllowedOrigings: o.AllowedOrigins, MaxAllowedSize: o.MaxAllowedSize, }) }