Add URL signature feature (#194)

* Add URL signature feature

* Fix signature salt empty & key/salt check
This commit is contained in:
Benoît LELEVÉ 2018-04-15 12:27:50 +02:00 committed by Tomás
parent 3a3bd3a462
commit 2a374823ff
7 changed files with 217 additions and 85 deletions

1
.gitignore vendored
View File

@ -8,6 +8,7 @@ Thumbs.db
# Folders
_obj
_test
.idea
# Architecture specific extensions/prefixes
*.[568vq]

View File

@ -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 <urls> Restrict remote image source processing to certain origins (separated by commas)
-max-allowed-size <bytes> Restrict maximum size of http image source (in bytes)
-certfile <path> 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`

View File

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

View File

@ -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 <urls> Restrict remote image source processing to certain origins (separated by commas)
-max-allowed-size <bytes> Restrict maximum size of http image source (in bytes)
-certfile <path> 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)

View File

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

View File

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

View File

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