From 2ea9858e1850cbec71d858c92c8ebbec90731bd6 Mon Sep 17 00:00:00 2001 From: Mario Gamas Date: Mon, 3 Aug 2026 12:27:08 +0100 Subject: [PATCH 1/2] Add optional image watermark transforms Allow overlaying a remote watermark image via wmurl/wmp/wmo/wms/wmx/wmy options so proxies can brand or mark images without a separate processing step. Co-authored-by: Cursor --- README.md | 30 ++++++++++-- data.go | 113 ++++++++++++++++++++++++++++++++++++++-------- data_test.go | 18 ++++++++ imageproxy.go | 50 +++++++++++++++++++- transform.go | 80 ++++++++++++++++++++++++++++---- transform_test.go | 41 ++++++++++++++++- 6 files changed, 298 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 6fd083b1d..640382ec3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ imageproxy is a caching image proxy server written in go. It features: -- basic image adjustments like resizing, cropping, and rotation +- basic image adjustments like resizing, cropping, rotation, and watermarks - access control using allowed hosts list or request signing (HMAC-SHA256) - support for jpeg, png, webp (decode only), tiff, and gif image formats (including animated gifs) @@ -35,14 +35,34 @@ imageproxy URLs are of the form `http://localhost/{options}/{remote_url}`. ### Options -Options are available for cropping, resizing, rotation, flipping, and digital -signatures among a few others. Options for are specified as a comma delimited -list of parameters, which can be supplied in any order. Duplicate parameters -overwrite previous values. +Options are available for cropping, resizing, rotation, flipping, watermarks, +and digital signatures among a few others. Options for are specified as a comma +delimited list of parameters, which can be supplied in any order. Duplicate +parameters overwrite previous values. See the full list of available options at . +Watermark options overlay a remote image after other transforms. The watermark +image URL must be URL-safe base64 encoded (no padding) in the `wmurl` option, +because unencoded URLs contain `/` and would break the +`/{options}/{remote_url}` path: + +| Option | Meaning | Default | +| ------ | ------- | ------- | +| `wmurl{base64url}` | Watermark image URL (URL-safe base64, no padding) | required | +| `wmp{pos}` | Position: `nw` `n` `ne` `w` `c` `e` `sw` `s` `se` | `se` | +| `wmo{0..1}` | Opacity | `1` | +| `wms{0..1}` | Scale as a fraction of output width | `0.2` | +| `wmx{n}` / `wmy{n}` | Edge padding in pixels | `0` | + +Example (watermark URL `https://example.com/logo.png` encoded as +`aHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw`): + +``` +/800x,wmurlaHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw,wmpse,wmo0.5,wms0.15/https://example.com/photo.jpg +``` + ### Remote URL The URL of the original image to load is specified as the remainder of the diff --git a/data.go b/data.go index 8b5765cc7..06f22547b 100644 --- a/data.go +++ b/data.go @@ -17,24 +17,30 @@ import ( ) const ( - optFit = "fit" - optFlipVertical = "fv" - optFlipHorizontal = "fh" - optFormatJPEG = "jpeg" - optFormatPNG = "png" - optFormatTIFF = "tiff" - optRotatePrefix = "r" - optQualityPrefix = "q" - optSignaturePrefix = "s" - optSizeDelimiter = "x" - optScaleUp = "scaleUp" - optCropX = "cx" - optCropY = "cy" - optCropWidth = "cw" - optCropHeight = "ch" - optSmartCrop = "sc" - optTrim = "trim" - optValidUntil = "vu" + optFit = "fit" + optFlipVertical = "fv" + optFlipHorizontal = "fh" + optFormatJPEG = "jpeg" + optFormatPNG = "png" + optFormatTIFF = "tiff" + optRotatePrefix = "r" + optQualityPrefix = "q" + optSignaturePrefix = "s" + optSizeDelimiter = "x" + optScaleUp = "scaleUp" + optCropX = "cx" + optCropY = "cy" + optCropWidth = "cw" + optCropHeight = "ch" + optSmartCrop = "sc" + optTrim = "trim" + optValidUntil = "vu" + optWatermarkURL = "wmurl" + optWatermarkPos = "wmp" + optWatermarkOpacity = "wmo" + optWatermarkScale = "wms" + optWatermarkPadX = "wmx" + optWatermarkPadY = "wmy" ) // URLError reports a malformed URL error. @@ -91,6 +97,24 @@ type Options struct { // If non-zero, the URL is valid until this time. ValidUntil time.Time + + // WatermarkURL is the remote URL of an image to overlay (set via wmurl option; + // value is URL-safe base64 with no padding). + WatermarkURL string + + // WatermarkPos is the overlay anchor: nw, n, ne, w, c, e, sw, s, se. + WatermarkPos string + + // WatermarkOpacity is overlay opacity in [0,1]. Zero means unset (default 1). + WatermarkOpacity float64 + + // WatermarkScale is watermark width as a fraction of the output image width. + // Zero means unset (default 0.2). + WatermarkScale float64 + + // WatermarkPadX and WatermarkPadY are edge padding in pixels. + WatermarkPadX int + WatermarkPadY int } func (o Options) String() string { @@ -140,6 +164,24 @@ func (o Options) String() string { if !o.ValidUntil.IsZero() { opts = append(opts, fmt.Sprintf("%s%d", optValidUntil, o.ValidUntil.Unix())) } + if o.WatermarkURL != "" { + opts = append(opts, optWatermarkURL+base64.RawURLEncoding.EncodeToString([]byte(o.WatermarkURL))) + } + if o.WatermarkPos != "" { + opts = append(opts, optWatermarkPos+o.WatermarkPos) + } + if o.WatermarkOpacity != 0 { + opts = append(opts, fmt.Sprintf("%s%v", optWatermarkOpacity, o.WatermarkOpacity)) + } + if o.WatermarkScale != 0 { + opts = append(opts, fmt.Sprintf("%s%v", optWatermarkScale, o.WatermarkScale)) + } + if o.WatermarkPadX != 0 { + opts = append(opts, fmt.Sprintf("%s%d", optWatermarkPadX, o.WatermarkPadX)) + } + if o.WatermarkPadY != 0 { + opts = append(opts, fmt.Sprintf("%s%d", optWatermarkPadY, o.WatermarkPadY)) + } sort.Strings(opts) @@ -151,7 +193,7 @@ func (o Options) String() string { // the presence of other fields (like Fit). A non-empty Format value is // assumed to involve a transformation. func (o Options) transform() bool { - return o.Width != 0 || o.Height != 0 || o.Rotate != 0 || o.FlipHorizontal || o.FlipVertical || o.Quality != 0 || o.Format != "" || o.CropX != 0 || o.CropY != 0 || o.CropWidth != 0 || o.CropHeight != 0 || o.Trim + return o.Width != 0 || o.Height != 0 || o.Rotate != 0 || o.FlipHorizontal || o.FlipVertical || o.Quality != 0 || o.Format != "" || o.CropX != 0 || o.CropY != 0 || o.CropWidth != 0 || o.CropHeight != 0 || o.Trim || o.WatermarkURL != "" } // ParseOptions parses str as a list of comma separated transformation options. @@ -250,6 +292,17 @@ func (o Options) transform() bool { // 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. // +// # Watermark +// +// The following options overlay a remote watermark image onto the output: +// +// wmurl{base64url} - watermark image URL (URL-safe base64, no padding) +// wmp{pos} - position: nw, n, ne, w, c, e, sw, s, se (default: se) +// wmo{opacity} - opacity from 0 to 1 (default: 1) +// wms{scale} - watermark width as a fraction of output width (default: 0.2) +// wmx{n} - horizontal padding in pixels (default: 0) +// wmy{n} - vertical padding in pixels (default: 0) +// // Examples // // 0x0 - no resizing @@ -264,6 +317,7 @@ func (o Options) transform() bool { // 200x,png - 200 pixels wide, converted to PNG format // cw100,ch100 - crop image to 100px square, starting at (0,0) // cx10,cy20,cw100,ch200 - crop image starting at (10,20) is 100px wide and 200px tall +// wmurl{aHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw},wmp{se},wmo{0.5} - watermark bottom-right at 50% opacity func ParseOptions(str string) Options { var options Options @@ -284,6 +338,27 @@ func ParseOptions(str string) Options { options.SmartCrop = true case opt == optTrim: options.Trim = true + case strings.HasPrefix(opt, optWatermarkURL): + value := strings.TrimPrefix(opt, optWatermarkURL) + if b, err := base64.RawURLEncoding.DecodeString(value); err == nil { + options.WatermarkURL = string(b) + } else if b, err := base64.URLEncoding.DecodeString(value); err == nil { + options.WatermarkURL = string(b) + } + case strings.HasPrefix(opt, optWatermarkPos): + options.WatermarkPos = strings.TrimPrefix(opt, optWatermarkPos) + case strings.HasPrefix(opt, optWatermarkOpacity): + value := strings.TrimPrefix(opt, optWatermarkOpacity) + options.WatermarkOpacity, _ = strconv.ParseFloat(value, 64) + case strings.HasPrefix(opt, optWatermarkScale): + value := strings.TrimPrefix(opt, optWatermarkScale) + options.WatermarkScale, _ = strconv.ParseFloat(value, 64) + case strings.HasPrefix(opt, optWatermarkPadX): + value := strings.TrimPrefix(opt, optWatermarkPadX) + options.WatermarkPadX, _ = strconv.Atoi(value) + case strings.HasPrefix(opt, optWatermarkPadY): + value := strings.TrimPrefix(opt, optWatermarkPadY) + options.WatermarkPadY, _ = strconv.Atoi(value) case strings.HasPrefix(opt, optRotatePrefix): value := strings.TrimPrefix(opt, optRotatePrefix) options.Rotate, _ = strconv.Atoi(value) diff --git a/data_test.go b/data_test.go index 0e854af62..b9469c684 100644 --- a/data_test.go +++ b/data_test.go @@ -37,6 +37,10 @@ func TestOptions_String(t *testing.T) { Options{ScaleUp: true, CropX: 100, CropY: 200, CropWidth: 300, CropHeight: 400, SmartCrop: true}, "0x0,ch400,cw300,cx100,cy200,sc,scaleUp", }, + { + Options{WatermarkURL: "https://example.com/logo.png", WatermarkPos: "se", WatermarkOpacity: 0.5, WatermarkScale: 0.15, WatermarkPadX: 16, WatermarkPadY: 8}, + "0x0,wmo0.5,wmpse,wms0.15,wmurlaHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw,wmx16,wmy8", + }, } for i, tt := range tests { @@ -88,6 +92,13 @@ func TestParseOptions(t *testing.T) { {"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,vu1234567890", Options{Width: 1, Height: 2, ScaleUp: true, CropX: 100, CropY: 200, CropWidth: 300, CropHeight: 400, SmartCrop: true, ValidUntil: time.Unix(1234567890, 0)}}, + + // watermark options (wmurl value is URL-safe base64 for https://example.com/logo.png) + {"wmurlaHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw", Options{WatermarkURL: "https://example.com/logo.png"}}, + {"wmurlaHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw,wmpse,wmo0.5,wms0.15,wmx16,wmy8", Options{ + WatermarkURL: "https://example.com/logo.png", WatermarkPos: "se", WatermarkOpacity: 0.5, WatermarkScale: 0.15, WatermarkPadX: 16, WatermarkPadY: 8, + }}, + {"wmpse,wmo0.4,wms0.2,wmx10,wmy10", Options{WatermarkPos: "se", WatermarkOpacity: 0.4, WatermarkScale: 0.2, WatermarkPadX: 10, WatermarkPadY: 10}}, } for _, tt := range tests { @@ -97,6 +108,13 @@ func TestParseOptions(t *testing.T) { } } +func TestOptions_transform_watermark(t *testing.T) { + opt := Options{WatermarkURL: "https://example.com/logo.png"} + if !opt.transform() { + t.Fatal("expected watermark-only options to require transform") + } +} + // Test that request URLs are properly parsed into Options and RemoteURL. This // test verifies that invalid remote URLs throw errors, and that valid // combinations of Options and URL are accept. This does not exhaustively test diff --git a/imageproxy.go b/imageproxy.go index 18df0b325..22d06668f 100644 --- a/imageproxy.go +++ b/imageproxy.go @@ -13,6 +13,10 @@ import ( "encoding/base64" "errors" "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" "io" "log" "mime" @@ -633,7 +637,19 @@ func (t *TransformingTransport) RoundTrip(req *http.Request) (*http.Response, er opt := ParseOptions(req.URL.Fragment) - img, err := Transform(b, opt) + var watermark image.Image + if opt.WatermarkURL != "" { + watermark, err = t.fetchWatermark(opt.WatermarkURL) + if err != nil { + if t.log != nil { + t.log("error fetching watermark %s: %v", opt.WatermarkURL, err) + } else { + log.Printf("error fetching watermark %s: %v", opt.WatermarkURL, err) + } + } + } + + img, err := transform(b, opt, watermark) if err != nil { log.Printf("error transforming image %s: %v", req.URL.String(), err) img = b @@ -654,3 +670,35 @@ func (t *TransformingTransport) RoundTrip(req *http.Request) (*http.Response, er return http.ReadResponse(bufio.NewReader(buf), req) } + +// fetchWatermark downloads and decodes a watermark image from watermarkURL. +func (t *TransformingTransport) fetchWatermark(watermarkURL string) (image.Image, error) { + u, err := url.Parse(watermarkURL) + if err != nil { + return nil, err + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("watermark URL must have http or https scheme") + } + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + resp, err := t.CachingClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("watermark fetch returned status %d", resp.StatusCode) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + m, _, err := image.Decode(bytes.NewReader(b)) + return m, err +} diff --git a/transform.go b/transform.go index a5e87d4b0..490ffaa58 100644 --- a/transform.go +++ b/transform.go @@ -14,6 +14,7 @@ import ( "io" "log" "math" + "strings" "github.com/disintegration/imaging" "github.com/muesli/smartcrop" @@ -39,6 +40,11 @@ var resampleFilter = imaging.Lanczos // encoded image in one of the supported formats (gif, jpeg, or png). The // bytes of a similarly encoded image is returned. func Transform(img []byte, opt Options) ([]byte, error) { + return transform(img, opt, nil) +} + +// transform is like Transform but applies watermark when non-nil. +func transform(img []byte, opt Options, watermark image.Image) ([]byte, error) { if !opt.transform() { // bail if no transformation was requested return img, nil @@ -68,7 +74,7 @@ func Transform(img []byte, opt Options) ([]byte, error) { if format == "jpeg" || format == "tiff" { r := io.LimitReader(bytes.NewReader(img), maxExifSize) if exifOpt := exifOrientation(r); exifOpt.transform() { - m = transformImage(m, exifOpt) + m = transformImage(m, exifOpt, nil) } } @@ -85,14 +91,14 @@ func Transform(img []byte, opt Options) ([]byte, error) { buf := new(bytes.Buffer) switch format { case "bmp": - m = transformImage(m, opt) + m = transformImage(m, opt, watermark) err = bmp.Encode(buf, m) if err != nil { return nil, err } case "gif": fn := func(img image.Image) image.Image { - return transformImage(img, opt) + return transformImage(img, opt, watermark) } err = gifresize.Process(buf, bytes.NewReader(img), fn) if err != nil { @@ -104,19 +110,19 @@ func Transform(img []byte, opt Options) ([]byte, error) { quality = defaultQuality } - m = transformImage(m, opt) + m = transformImage(m, opt, watermark) err = jpeg.Encode(buf, m, &jpeg.Options{Quality: quality}) if err != nil { return nil, err } case "png": - m = transformImage(m, opt) + m = transformImage(m, opt, watermark) err = png.Encode(buf, m) if err != nil { return nil, err } case "tiff": - m = transformImage(m, opt) + m = transformImage(m, opt, watermark) err = tiff.Encode(buf, m, &tiff.Options{Compression: tiff.Deflate, Predictor: true}) if err != nil { return nil, err @@ -270,8 +276,8 @@ func exifOrientation(r io.Reader) (opt Options) { } // transformImage modifies the image m based on the transformations specified -// in opt. -func transformImage(m image.Image, opt Options) image.Image { +// in opt. When watermark is non-nil, it is overlaid after other transforms. +func transformImage(m image.Image, opt Options, watermark image.Image) image.Image { timer := prometheus.NewTimer(metricTransformationDuration) defer timer.ObserveDuration() @@ -322,9 +328,67 @@ func transformImage(m image.Image, opt Options) image.Image { m = imaging.FlipH(m) } + if watermark != nil { + m = applyWatermark(m, watermark, opt) + } + return m } +func applyWatermark(m image.Image, watermark image.Image, opt Options) image.Image { + opacity := opt.WatermarkOpacity + if opacity <= 0 || opacity > 1 { + opacity = 1 + } + scale := opt.WatermarkScale + if scale <= 0 || scale > 1 { + scale = 0.2 + } + + bounds := m.Bounds() + targetW := int(float64(bounds.Dx()) * scale) + if targetW < 1 { + targetW = 1 + } + wm := imaging.Resize(watermark, targetW, 0, resampleFilter) + wmBounds := wm.Bounds() + pt := watermarkPoint(bounds.Dx(), bounds.Dy(), wmBounds.Dx(), wmBounds.Dy(), opt.WatermarkPos, opt.WatermarkPadX, opt.WatermarkPadY) + // imaging.Overlay positions relative to the image's Min point + pt = pt.Add(bounds.Min) + return imaging.Overlay(m, wm, pt, opacity) +} + +func watermarkPoint(imgW, imgH, wmW, wmH int, pos string, padX, padY int) image.Point { + if padX < 0 { + padX = 0 + } + if padY < 0 { + padY = 0 + } + switch strings.ToLower(pos) { + case "nw": + return image.Pt(padX, padY) + case "n": + return image.Pt((imgW-wmW)/2, padY) + case "ne": + return image.Pt(imgW-wmW-padX, padY) + case "w": + return image.Pt(padX, (imgH-wmH)/2) + case "c", "center": + return image.Pt((imgW-wmW)/2, (imgH-wmH)/2) + case "e": + return image.Pt(imgW-wmW-padX, (imgH-wmH)/2) + case "sw": + return image.Pt(padX, imgH-wmH-padY) + case "s": + return image.Pt((imgW-wmW)/2, imgH-wmH-padY) + case "se", "": + return image.Pt(imgW-wmW-padX, imgH-wmH-padY) + default: + return image.Pt(imgW-wmW-padX, imgH-wmH-padY) + } +} + // trimEdges returns a new image with solid color borders of the image removed. // The pixel at the top left corner is used to match the border color. func trimEdges(img image.Image) image.Image { diff --git a/transform_test.go b/transform_test.go index 6a0395e74..fdb364d98 100644 --- a/transform_test.go +++ b/transform_test.go @@ -384,7 +384,7 @@ func TestTransformImage(t *testing.T) { } for _, tt := range tests { - if got := transformImage(tt.src, tt.opt); !reflect.DeepEqual(got, tt.want) { + if got := transformImage(tt.src, tt.opt, nil); !reflect.DeepEqual(got, tt.want) { t.Errorf("transformImage(%v, %v) returned image %#v, want %#v", tt.src, tt.opt, got, tt.want) } } @@ -463,3 +463,42 @@ func TestTrimEdges(t *testing.T) { }) } } + +func TestWatermarkPoint(t *testing.T) { + tests := []struct { + pos string + padX, padY int + want image.Point + }{ + {"se", 0, 0, image.Pt(80, 80)}, + {"", 0, 0, image.Pt(80, 80)}, + {"nw", 5, 5, image.Pt(5, 5)}, + {"c", 0, 0, image.Pt(40, 40)}, + {"n", 0, 10, image.Pt(40, 10)}, + } + for _, tt := range tests { + got := watermarkPoint(100, 100, 20, 20, tt.pos, tt.padX, tt.padY) + if got != tt.want { + t.Errorf("watermarkPoint(pos=%q) = %v, want %v", tt.pos, got, tt.want) + } + } +} + +func TestApplyWatermark(t *testing.T) { + base := newImage(100, 100, red) + wm := newImage(10, 10, blue) + + got := transformImage(base, Options{WatermarkPos: "se", WatermarkOpacity: 1, WatermarkScale: 0.1}, wm) + if got.Bounds().Dx() != 100 || got.Bounds().Dy() != 100 { + t.Fatalf("expected output size 100x100, got %vx%v", got.Bounds().Dx(), got.Bounds().Dy()) + } + // bottom-right area should no longer be pure red after opaque blue overlay + if reflect.DeepEqual(got, base) { + t.Fatal("expected watermarked image to differ from base") + } + + center := transformImage(base, Options{WatermarkPos: "c", WatermarkOpacity: 1, WatermarkScale: 0.1}, wm) + if reflect.DeepEqual(center, got) { + t.Fatal("expected center and se watermark positions to produce different images") + } +} From 526044c56401d824c66fb780a2b4fcc502ce2ba4 Mon Sep 17 00:00:00 2001 From: Mario Gamas Date: Tue, 27 Oct 2026 19:18:09 +0000 Subject: [PATCH 2/2] Reject watermark URLs that bypass host policy Apply the proxy allow and deny lists to wmurl, and require an options signature on signature-only proxies so a URL-only signature cannot be rewritten to fetch another host. Let wmo0 and wms0 mean zero. Co-authored-by: Cursor --- README.md | 9 ++- data.go | 29 ++++---- data_test.go | 16 +++-- imageproxy.go | 147 ++++++++++++++++++++++++++++++++------ imageproxy_test.go | 173 +++++++++++++++++++++++++++++++++++++++++++++ transform.go | 21 ++++-- transform_test.go | 13 +++- 7 files changed, 361 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 640382ec3..3ae970c2a 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,15 @@ because unencoded URLs contain `/` and would break the | ------ | ------- | ------- | | `wmurl{base64url}` | Watermark image URL (URL-safe base64, no padding) | required | | `wmp{pos}` | Position: `nw` `n` `ne` `w` `c` `e` `sw` `s` `se` | `se` | -| `wmo{0..1}` | Opacity | `1` | -| `wms{0..1}` | Scale as a fraction of output width | `0.2` | +| `wmo{0..1}` | Opacity. `0` is fully transparent | `1` | +| `wms{0..1}` | Scale as a fraction of output width. `0` draws nothing | `0.2` | | `wmx{n}` / `wmy{n}` | Edge padding in pixels | `0` | +The watermark host is subject to the same allow and deny lists as the remote +image. When the proxy requires a signature and the watermark host is not +covered by an allow list, the signature must cover the options. A signature of +the remote image alone cannot be reused with a different `wmurl`. + Example (watermark URL `https://example.com/logo.png` encoded as `aHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw`): diff --git a/data.go b/data.go index 06f22547b..ed6a28835 100644 --- a/data.go +++ b/data.go @@ -105,12 +105,13 @@ type Options struct { // WatermarkPos is the overlay anchor: nw, n, ne, w, c, e, sw, s, se. WatermarkPos string - // WatermarkOpacity is overlay opacity in [0,1]. Zero means unset (default 1). - WatermarkOpacity float64 + // WatermarkOpacity is overlay opacity in [0,1]. Nil means unset (default 1). + // Zero is fully transparent. + WatermarkOpacity *float64 // WatermarkScale is watermark width as a fraction of the output image width. - // Zero means unset (default 0.2). - WatermarkScale float64 + // Nil means unset (default 0.2). Zero draws no watermark. + WatermarkScale *float64 // WatermarkPadX and WatermarkPadY are edge padding in pixels. WatermarkPadX int @@ -170,11 +171,11 @@ func (o Options) String() string { if o.WatermarkPos != "" { opts = append(opts, optWatermarkPos+o.WatermarkPos) } - if o.WatermarkOpacity != 0 { - opts = append(opts, fmt.Sprintf("%s%v", optWatermarkOpacity, o.WatermarkOpacity)) + if o.WatermarkOpacity != nil { + opts = append(opts, fmt.Sprintf("%s%v", optWatermarkOpacity, *o.WatermarkOpacity)) } - if o.WatermarkScale != 0 { - opts = append(opts, fmt.Sprintf("%s%v", optWatermarkScale, o.WatermarkScale)) + if o.WatermarkScale != nil { + opts = append(opts, fmt.Sprintf("%s%v", optWatermarkScale, *o.WatermarkScale)) } if o.WatermarkPadX != 0 { opts = append(opts, fmt.Sprintf("%s%d", optWatermarkPadX, o.WatermarkPadX)) @@ -298,8 +299,8 @@ func (o Options) transform() bool { // // wmurl{base64url} - watermark image URL (URL-safe base64, no padding) // wmp{pos} - position: nw, n, ne, w, c, e, sw, s, se (default: se) -// wmo{opacity} - opacity from 0 to 1 (default: 1) -// wms{scale} - watermark width as a fraction of output width (default: 0.2) +// wmo{opacity} - opacity from 0 to 1 (default: 1). 0 is fully transparent +// wms{scale} - watermark width as a fraction of output width, from 0 to 1 (default: 0.2). 0 draws nothing // wmx{n} - horizontal padding in pixels (default: 0) // wmy{n} - vertical padding in pixels (default: 0) // @@ -349,10 +350,14 @@ func ParseOptions(str string) Options { options.WatermarkPos = strings.TrimPrefix(opt, optWatermarkPos) case strings.HasPrefix(opt, optWatermarkOpacity): value := strings.TrimPrefix(opt, optWatermarkOpacity) - options.WatermarkOpacity, _ = strconv.ParseFloat(value, 64) + if v, err := strconv.ParseFloat(value, 64); err == nil { + options.WatermarkOpacity = &v + } case strings.HasPrefix(opt, optWatermarkScale): value := strings.TrimPrefix(opt, optWatermarkScale) - options.WatermarkScale, _ = strconv.ParseFloat(value, 64) + if v, err := strconv.ParseFloat(value, 64); err == nil { + options.WatermarkScale = &v + } case strings.HasPrefix(opt, optWatermarkPadX): value := strings.TrimPrefix(opt, optWatermarkPadX) options.WatermarkPadX, _ = strconv.Atoi(value) diff --git a/data_test.go b/data_test.go index b9469c684..b4fc93dd2 100644 --- a/data_test.go +++ b/data_test.go @@ -6,10 +6,13 @@ package imageproxy import ( "net/http" "net/url" + "reflect" "testing" "time" ) +func f64(v float64) *float64 { return &v } + var emptyOptions = Options{} func TestOptions_String(t *testing.T) { @@ -38,9 +41,13 @@ func TestOptions_String(t *testing.T) { "0x0,ch400,cw300,cx100,cy200,sc,scaleUp", }, { - Options{WatermarkURL: "https://example.com/logo.png", WatermarkPos: "se", WatermarkOpacity: 0.5, WatermarkScale: 0.15, WatermarkPadX: 16, WatermarkPadY: 8}, + Options{WatermarkURL: "https://example.com/logo.png", WatermarkPos: "se", WatermarkOpacity: f64(0.5), WatermarkScale: f64(0.15), WatermarkPadX: 16, WatermarkPadY: 8}, "0x0,wmo0.5,wmpse,wms0.15,wmurlaHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw,wmx16,wmy8", }, + { + Options{WatermarkOpacity: f64(0), WatermarkScale: f64(0)}, + "0x0,wmo0,wms0", + }, } for i, tt := range tests { @@ -96,13 +103,14 @@ func TestParseOptions(t *testing.T) { // watermark options (wmurl value is URL-safe base64 for https://example.com/logo.png) {"wmurlaHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw", Options{WatermarkURL: "https://example.com/logo.png"}}, {"wmurlaHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw,wmpse,wmo0.5,wms0.15,wmx16,wmy8", Options{ - WatermarkURL: "https://example.com/logo.png", WatermarkPos: "se", WatermarkOpacity: 0.5, WatermarkScale: 0.15, WatermarkPadX: 16, WatermarkPadY: 8, + WatermarkURL: "https://example.com/logo.png", WatermarkPos: "se", WatermarkOpacity: f64(0.5), WatermarkScale: f64(0.15), WatermarkPadX: 16, WatermarkPadY: 8, }}, - {"wmpse,wmo0.4,wms0.2,wmx10,wmy10", Options{WatermarkPos: "se", WatermarkOpacity: 0.4, WatermarkScale: 0.2, WatermarkPadX: 10, WatermarkPadY: 10}}, + {"wmpse,wmo0.4,wms0.2,wmx10,wmy10", Options{WatermarkPos: "se", WatermarkOpacity: f64(0.4), WatermarkScale: f64(0.2), WatermarkPadX: 10, WatermarkPadY: 10}}, + {"wmo0,wms0", Options{WatermarkOpacity: f64(0), WatermarkScale: f64(0)}}, } for _, tt := range tests { - if got, want := ParseOptions(tt.Input), tt.Options; got != want { + if got, want := ParseOptions(tt.Input), tt.Options; !reflect.DeepEqual(got, want) { t.Errorf("ParseOptions(%q) returned %#v, want %#v", tt.Input, got, want) } } diff --git a/imageproxy.go b/imageproxy.go index 22d06668f..a88aef9be 100644 --- a/imageproxy.go +++ b/imageproxy.go @@ -141,6 +141,7 @@ func NewProxy(transport http.RoundTripper, cache Cache) *Proxy { } }, updateCacheHeaders: proxy.updateCacheHeaders, + allowWatermark: proxy.watermarkHostAllowed, }, Cache: cache, MarkCachedResponses: true, @@ -255,6 +256,11 @@ func (p *Proxy) serveImage(w http.ResponseWriter, r *http.Request) { http.Error(w, msgNotAllowed, http.StatusForbidden) return } + if err := p.allowedWatermark(req); err != nil { + p.logf("watermark %s: %v", req.Options.WatermarkURL, err) + http.Error(w, msgNotAllowed, http.StatusForbidden) + return + } // assign static settings from proxy to req.Options req.Options.ScaleUp = p.ScaleUp @@ -440,6 +446,47 @@ func (p *Proxy) allowed(r *Request) error { return errNotAllowed } +// allowedWatermark applies the proxy host rules to the watermark URL. +// DenyHosts always rejects. When AllowHosts is set, the watermark host must +// match it; a signature does not add hosts. When the proxy is signature-only, +// the signature must cover the options so a URL-only signature cannot be +// rewritten with a different wmurl. +func (p *Proxy) allowedWatermark(r *Request) error { + if r.Options.WatermarkURL == "" { + return nil + } + u, err := url.Parse(r.Options.WatermarkURL) + if err != nil { + return errNotAllowed + } + if err := p.watermarkHostAllowed(u); err != nil { + return err + } + if len(p.AllowHosts) > 0 || len(p.SignatureKeys) == 0 { + return nil + } + for _, key := range p.SignatureKeys { + if len(key) > 0 && signatureCoversOptions(key, r) { + return nil + } + } + return errNotAllowed +} + +// watermarkHostAllowed reports whether u may be fetched as a watermark. +func (p *Proxy) watermarkHostAllowed(u *url.URL) error { + if u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" { + return errNotAllowed + } + if hostMatches(p.DenyHosts, u) { + return errDeniedHost + } + if len(p.AllowHosts) > 0 && !hostMatches(p.AllowHosts, u) { + return errNotAllowed + } + return nil +} + // contentTypeMatches returns whether contentType matches one of the allowed patterns. func contentTypeMatches(patterns []string, contentType string) bool { if len(patterns) == 0 { @@ -491,34 +538,50 @@ func referrerMatches(hosts []string, r *http.Request) bool { // 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, ok := decodeSignature(r.Options.Signature) + if !ok { + return false + } + if signatureMatches(key, got, []byte(r.URL.String())) { + return true + } + return optionsSignatureMatches(key, got, r) +} + +// signatureCoversOptions reports whether key signs the URL together with the +// options, rather than the remote URL alone. +func signatureCoversOptions(key []byte, r *Request) bool { + got, ok := decodeSignature(r.Options.Signature) + if !ok { + return false } + return optionsSignatureMatches(key, got, r) +} - got, err := base64.URLEncoding.DecodeString(sig) +func decodeSignature(sig string) ([]byte, bool) { + padded := sig + if m := len(padded) % 4; m != 0 { // add padding if missing + padded += strings.Repeat("=", 4-m) + } + got, err := base64.URLEncoding.DecodeString(padded) if err != nil { - log.Printf("error base64 decoding signature %q", r.Options.Signature) - return false + log.Printf("error base64 decoding signature %q", sig) + return nil, false } + return got, true +} - // check signature with URL only +func signatureMatches(key, got, message []byte) bool { mac := hmac.New(sha256.New, key) - _, _ = mac.Write([]byte(r.URL.String())) - want := mac.Sum(nil) - if hmac.Equal(got, want) { - return true - } + _, _ = mac.Write(message) + return hmac.Equal(got, mac.Sum(nil)) +} - // check signature with URL and options +func optionsSignatureMatches(key, got []byte, r *Request) bool { u, opt := *r.URL, r.Options // make copies opt.Signature = "" u.Fragment = opt.String() - - mac = hmac.New(sha256.New, key) - _, _ = mac.Write([]byte(u.String())) - want = mac.Sum(nil) - return hmac.Equal(got, want) + return signatureMatches(key, got, []byte(u.String())) } // should304 returns whether we should send a 304 Not Modified in response to @@ -583,6 +646,10 @@ type TransformingTransport struct { log func(format string, v ...any) updateCacheHeaders func(hdr http.Header) + + // allowWatermark, when set, reports whether a watermark URL may be fetched. + // It is called for the initial URL and for redirects. + allowWatermark func(*url.URL) error } // RoundTrip implements the http.RoundTripper interface. @@ -641,6 +708,9 @@ func (t *TransformingTransport) RoundTrip(req *http.Request) (*http.Response, er if opt.WatermarkURL != "" { watermark, err = t.fetchWatermark(opt.WatermarkURL) if err != nil { + if errors.Is(err, errNotAllowed) || errors.Is(err, errDeniedHost) { + return forbiddenResponse(req), nil + } if t.log != nil { t.log("error fetching watermark %s: %v", opt.WatermarkURL, err) } else { @@ -671,21 +741,46 @@ func (t *TransformingTransport) RoundTrip(req *http.Request) (*http.Response, er return http.ReadResponse(bufio.NewReader(buf), req) } +func forbiddenResponse(req *http.Request) *http.Response { + body := msgNotAllowed + "\n" + return &http.Response{ + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Status: fmt.Sprintf("%d %s", http.StatusForbidden, http.StatusText(http.StatusForbidden)), + StatusCode: http.StatusForbidden, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + ContentLength: int64(len(body)), + Request: req, + } +} + // fetchWatermark downloads and decodes a watermark image from watermarkURL. func (t *TransformingTransport) fetchWatermark(watermarkURL string) (image.Image, error) { u, err := url.Parse(watermarkURL) if err != nil { return nil, err } - if u.Scheme != "http" && u.Scheme != "https" { - return nil, fmt.Errorf("watermark URL must have http or https scheme") + if err := t.watermarkURLAllowed(u); err != nil { + return nil, err } req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { return nil, err } - resp, err := t.CachingClient.Do(req) + client := *t.CachingClient + client.CheckRedirect = func(redirect *http.Request, via []*http.Request) error { + if len(via) > maxRedirects { + return errTooManyRedirects + } + if err := t.watermarkURLAllowed(redirect.URL); err != nil { + return err + } + return nil + } + resp, err := client.Do(req) if err != nil { return nil, err } @@ -702,3 +797,13 @@ func (t *TransformingTransport) fetchWatermark(watermarkURL string) (image.Image m, _, err := image.Decode(bytes.NewReader(b)) return m, err } + +func (t *TransformingTransport) watermarkURLAllowed(u *url.URL) error { + if u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" { + return errNotAllowed + } + if t.allowWatermark != nil { + return t.allowWatermark(u) + } + return nil +} diff --git a/imageproxy_test.go b/imageproxy_test.go index 150d7c429..bac91ebbc 100644 --- a/imageproxy_test.go +++ b/imageproxy_test.go @@ -6,11 +6,14 @@ package imageproxy import ( "bufio" "bytes" + "crypto/hmac" + "crypto/sha256" "encoding/base64" "errors" "fmt" "image" "image/png" + "io" "log" "maps" "net/http" @@ -816,3 +819,173 @@ func TestContentTypeMatches(t *testing.T) { } } } + +func signOptions(key []byte, rawURL string, opt Options) Options { + u, err := url.Parse(rawURL) + if err != nil { + panic(err) + } + signed := opt + signed.Signature = "" + u.Fragment = signed.String() + mac := hmac.New(sha256.New, key) + _, _ = mac.Write([]byte(u.String())) + opt.Signature = base64.URLEncoding.EncodeToString(mac.Sum(nil)) + return opt +} + +func TestAllowedWatermark(t *testing.T) { + key := []byte("c0ffee") + imageURL := "http://good.test/image" + evil := "http://evil.test/logo.png" + logo := "https://good.test/logo.png" + + urlOnly := Options{WatermarkURL: evil} + mac := hmac.New(sha256.New, key) + _, _ = mac.Write([]byte(imageURL)) + urlOnly.Signature = base64.URLEncoding.EncodeToString(mac.Sum(nil)) + + tests := []struct { + name string + allow []string + deny []string + keys [][]byte + opt Options + rawURL string + want error + }{ + {name: "no watermark", allow: []string{"good.test"}, rawURL: imageURL}, + {name: "open proxy", opt: Options{WatermarkURL: evil}, rawURL: imageURL}, + {name: "allow list match", allow: []string{"good.test"}, opt: Options{WatermarkURL: logo}, rawURL: imageURL}, + {name: "allow list miss", allow: []string{"good.test"}, opt: Options{WatermarkURL: evil}, rawURL: imageURL, want: errNotAllowed}, + {name: "deny list", deny: []string{"evil.test"}, opt: Options{WatermarkURL: evil}, rawURL: imageURL, want: errDeniedHost}, + {name: "deny beats allow", allow: []string{"evil.test"}, deny: []string{"evil.test"}, opt: Options{WatermarkURL: evil}, rawURL: imageURL, want: errDeniedHost}, + {name: "file scheme", opt: Options{WatermarkURL: "file:///etc/passwd"}, rawURL: imageURL, want: errNotAllowed}, + {name: "url-only signature cannot retarget", keys: [][]byte{key}, opt: urlOnly, rawURL: imageURL, want: errNotAllowed}, + {name: "options signature on signature-only proxy", keys: [][]byte{key}, opt: signOptions(key, imageURL, Options{WatermarkURL: evil}), rawURL: imageURL}, + {name: "options signature does not bypass allow list", allow: []string{"good.test"}, keys: [][]byte{key}, opt: signOptions(key, imageURL, Options{WatermarkURL: evil}), rawURL: imageURL, want: errNotAllowed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := NewProxy(nil, nil) + p.AllowHosts = tt.allow + p.DenyHosts = tt.deny + p.SignatureKeys = tt.keys + u, err := url.Parse(tt.rawURL) + if err != nil { + t.Fatal(err) + } + got := p.allowedWatermark(&Request{URL: u, Options: tt.opt}) + if !errors.Is(got, tt.want) { + t.Fatalf("allowedWatermark() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFetchWatermarkRejectsDisallowedHost(t *testing.T) { + tr := &TransformingTransport{ + CachingClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + t.Errorf("watermark request was sent to %s", r.URL) + return nil, errors.New("unexpected request") + })}, + allowWatermark: func(u *url.URL) error { + if u.Hostname() == "evil.test" { + return errNotAllowed + } + return nil + }, + } + _, err := tr.fetchWatermark("http://evil.test/logo.png") + if !errors.Is(err, errNotAllowed) { + t.Fatalf("fetchWatermark() error = %v, want %v", err, errNotAllowed) + } + + _, err = tr.fetchWatermark("file:///etc/passwd") + if !errors.Is(err, errNotAllowed) { + t.Fatalf("fetchWatermark(file) error = %v, want %v", err, errNotAllowed) + } +} + +func TestFetchWatermarkRejectsRedirect(t *testing.T) { + internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("request reached denied host: %s", r.URL) + })) + defer internal.Close() + + allowed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, internal.URL+"/secret.png", http.StatusFound) + })) + defer allowed.Close() + + allowedURL, err := url.Parse(allowed.URL) + if err != nil { + t.Fatal(err) + } + tr := &TransformingTransport{ + CachingClient: &http.Client{Transport: http.DefaultTransport}, + allowWatermark: func(u *url.URL) error { + if u.Hostname() != allowedURL.Hostname() || u.Port() != allowedURL.Port() { + return errNotAllowed + } + return nil + }, + } + _, err = tr.fetchWatermark(allowed.URL + "/logo.png") + if !errors.Is(err, errNotAllowed) { + t.Fatalf("fetchWatermark(redirect) error = %v, want %v", err, errNotAllowed) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +func TestRoundTripForbiddenWatermark(t *testing.T) { + logo := image.NewNRGBA(image.Rect(0, 0, 1, 1)) + var img bytes.Buffer + if err := png.Encode(&img, logo); err != nil { + t.Fatal(err) + } + pngBytes := img.Bytes() + + tr := &TransformingTransport{ + allowWatermark: func(u *url.URL) error { + return errNotAllowed + }, + } + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Hostname() == "evil.test" { + t.Errorf("watermark host was requested") + return nil, errors.New("unexpected watermark request") + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(pngBytes)), + ContentLength: int64(len(pngBytes)), + Request: r, + }, nil + })} + tr.CachingClient = client + + req, err := http.NewRequest("GET", "http://good.test/photo.png#wmurlaHR0cDovL2V2aWwudGVzdC9sb2dvLnBuZw", nil) + if err != nil { + t.Fatal(err) + } + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want 403", resp.StatusCode) + } +} diff --git a/transform.go b/transform.go index 490ffaa58..abb96addf 100644 --- a/transform.go +++ b/transform.go @@ -336,13 +336,22 @@ func transformImage(m image.Image, opt Options, watermark image.Image) image.Ima } func applyWatermark(m image.Image, watermark image.Image, opt Options) image.Image { - opacity := opt.WatermarkOpacity - if opacity <= 0 || opacity > 1 { - opacity = 1 + opacity := 1.0 + if opt.WatermarkOpacity != nil { + opacity = *opt.WatermarkOpacity + if opacity < 0 || opacity > 1 { + opacity = 1 + } } - scale := opt.WatermarkScale - if scale <= 0 || scale > 1 { - scale = 0.2 + scale := 0.2 + if opt.WatermarkScale != nil { + scale = *opt.WatermarkScale + if scale == 0 { + return m + } + if scale < 0 || scale > 1 { + scale = 0.2 + } } bounds := m.Bounds() diff --git a/transform_test.go b/transform_test.go index fdb364d98..5212db412 100644 --- a/transform_test.go +++ b/transform_test.go @@ -488,7 +488,7 @@ func TestApplyWatermark(t *testing.T) { base := newImage(100, 100, red) wm := newImage(10, 10, blue) - got := transformImage(base, Options{WatermarkPos: "se", WatermarkOpacity: 1, WatermarkScale: 0.1}, wm) + got := transformImage(base, Options{WatermarkPos: "se", WatermarkOpacity: f64(1), WatermarkScale: f64(0.1)}, wm) if got.Bounds().Dx() != 100 || got.Bounds().Dy() != 100 { t.Fatalf("expected output size 100x100, got %vx%v", got.Bounds().Dx(), got.Bounds().Dy()) } @@ -497,8 +497,17 @@ func TestApplyWatermark(t *testing.T) { t.Fatal("expected watermarked image to differ from base") } - center := transformImage(base, Options{WatermarkPos: "c", WatermarkOpacity: 1, WatermarkScale: 0.1}, wm) + center := transformImage(base, Options{WatermarkPos: "c", WatermarkOpacity: f64(1), WatermarkScale: f64(0.1)}, wm) if reflect.DeepEqual(center, got) { t.Fatal("expected center and se watermark positions to produce different images") } + + transparent := transformImage(base, Options{WatermarkOpacity: f64(0), WatermarkScale: f64(0.1)}, wm) + if transparent.At(99, 99) != base.At(99, 99) { + t.Fatal("expected zero opacity to leave the base pixel unchanged") + } + absent := transformImage(base, Options{WatermarkScale: f64(0)}, wm) + if !reflect.DeepEqual(absent, base) { + t.Fatal("expected zero scale to leave the base image unchanged") + } }