From 96088626d15cbc081cbeecae04f50eca057337a0 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Mon, 4 Mar 2024 12:33:34 +0000 Subject: [PATCH] Add a minimal image proxy To provide some safety when linking to user-supplied external images, we provide a simple image proxy handler. Images accessed through this proxy will only be served if they meet the following criteria: - Appear to be valid image files - Are in a permitted format: GIF, JPEG, PNG or WebP - Do not have an excessive width or height (5000 pixels max, by default) To serve an image through this proxy, its URL should be passed to the handler's path as a `src` query param. The path is supplied to the application in the `IMAGE_PROXY_PATH` environment variable. We also provide a helper method to make forming the proxy links easier: Thruster.image_proxy_path('https://example.com/image.jpg') --- README.md | 65 +++++++++++---- go.mod | 1 + go.sum | 2 + internal/config.go | 25 +++--- internal/fixtures/image.gif | Bin 0 -> 3662 bytes internal/fixtures/image.jpg | Bin 6287 -> 4322 bytes internal/fixtures/image.png | Bin 0 -> 8475 bytes internal/fixtures/image.svg | 4 + internal/fixtures/image.webp | Bin 0 -> 4044 bytes internal/handler.go | 11 ++- internal/image_proxy_handler.go | 117 +++++++++++++++++++++++++++ internal/image_proxy_handler_test.go | 61 ++++++++++++++ internal/service.go | 6 ++ internal/testing.go | 10 +++ lib/thruster.rb | 1 + lib/thruster/helpers.rb | 9 +++ 16 files changed, 285 insertions(+), 27 deletions(-) create mode 100644 internal/fixtures/image.gif create mode 100644 internal/fixtures/image.png create mode 100644 internal/fixtures/image.svg create mode 100644 internal/fixtures/image.webp create mode 100644 internal/image_proxy_handler.go create mode 100644 internal/image_proxy_handler_test.go create mode 100644 lib/thruster/helpers.rb diff --git a/README.md b/README.md index 3f23188..ee91a18 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ features to help your app run efficiently and safely on the open Internet: - Basic HTTP caching - X-Sendfile support for efficient file serving - Automatic GZIP compression +- Image proxy links to sanitize external image URLs Thruster tries to be as zero-config as possible, so most features are automatically enabled with sensible defaults. @@ -46,6 +47,36 @@ Or with automatic SSL: $ SSL_DOMAIN=myapp.example.com thrust bin/rails server ``` +## Image proxy links + +Applications that allow user-generated content often need a way to sanitize +external image URLs, to guard against the security risks of maliciously crafted +images. + +Thruster includes a minimal image proxy that inspects the content of external +images before serving them. Images will be served if they: + +- Appear to be valid image files +- Are in a permitted format: GIF, JPEG, PNG or WebP +- Do not have an excessive width or height (5000 pixels max, by default) + +External images that do not meet these criteria will be served with a `403 +Forbidden` status. + +To use the image proxy, your application should rewrite external image URLs in +user-generated content to use Thruster's image proxy path. This path is provided +to your application in the `IMAGE_PROXY_PATH` environment variable. Specify the +URL of the image to proxy as a query parameter named `src`. + +Thruster provides a helper method to form these paths for you: + +```ruby +Thruster.image_proxy_path('https://example.com/image.jpg') +``` + +When your application is running outside of Thruster, +`Thruster.image_proxy_path` will return the original URL unchanged. + ## Custom configuration Thruster provides a number of environment variables that can be used to @@ -57,19 +88,21 @@ For example, `SSL_DOMAIN` can also be set as `THRUSTER_SSL_DOMAIN`. Whenever a prefixed variable is set, Thruster will use it in preference to the unprefixed version. -| Variable Name | Description | Default Value | -|-----------------------|---------------------------------------------------------------------------------|---------------| -| `SSL_DOMAIN` | The domain name to use for SSL provisioning. If not set, SSL will be disabled. | None | -| `TARGET_PORT` | The port that your Puma server should run on. Thruster will set `PORT` to this when starting your server. | 3000 | -| `CACHE_SIZE` | The size of the HTTP cache in bytes. | 64MB | -| `MAX_CACHE_ITEM_SIZE` | The maximum size of a single item in the HTTP cache in bytes. | 1MB | -| `X_SENDFILE_ENABLED` | Whether to enable X-Sendfile support. Set to `0` or `false` to disable. | Enabled | -| `MAX_REQUEST_BODY` | The maximum size of a request body in bytes. Requests larger than this size will be refused; `0` means no maximum size. | `0` | -| `STORAGE_PATH` | The path to store Thruster's internal state. | `./storage/thruster` | -| `BAD_GATEWAY_PAGE` | Path to an HTML file to serve when the backend server returns a 502 Bad Gateway error. If there is no file at the specific path, Thruster will serve an empty 502 response instead. | `./public/502.html` | -| `HTTP_PORT` | The port to listen on for HTTP traffic. | 80 | -| `HTTPS_PORT` | The port to listen on for HTTPS traffic. | 443 | -| `HTTP_IDLE_TIMEOUT` | The maximum time in seconds that a client can be idle before the connection is closed. | 60 | -| `HTTP_READ_TIMEOUT` | The maximum time in seconds that a client can take to send the request headers. | 30 | -| `HTTP_WRITE_TIMEOUT` | The maximum time in seconds during which the client must read the response. | 30 | -| `DEBUG` | Set to `1` or `true` to enable debug logging. | Disabled | +| Variable Name | Description | Default Value | +|-----------------------------|---------------------------------------------------------------------------------|---------------| +| `SSL_DOMAIN` | The domain name to use for SSL provisioning. If not set, SSL will be disabled. | None | +| `TARGET_PORT` | The port that your Puma server should run on. Thruster will set `PORT` to this when starting your server. | 3000 | +| `CACHE_SIZE` | The size of the HTTP cache in bytes. | 64MB | +| `MAX_CACHE_ITEM_SIZE` | The maximum size of a single item in the HTTP cache in bytes. | 1MB | +| `X_SENDFILE_ENABLED` | Whether to enable X-Sendfile support. Set to `0` or `false` to disable. | Enabled | +| `IMAGE_PROXY_ENABLED` | Whether to enable the built in image proxy. Set to `0` or `false` to disable. | Enabled | +| `IMAGE_PROXY_MAX_DIMENSION` | When using the image proxy, only serve images with a width and height less than this, in pixels | 5000 | +| `MAX_REQUEST_BODY` | The maximum size of a request body in bytes. Requests larger than this size will be refused; `0` means no maximum size. | `0` | +| `STORAGE_PATH` | The path to store Thruster's internal state. | `./storage/thruster` | +| `BAD_GATEWAY_PAGE` | Path to an HTML file to serve when the backend server returns a 502 Bad Gateway error. If there is no file at the specific path, Thruster will serve an empty 502 response instead. | `./public/502.html` | +| `HTTP_PORT` | The port to listen on for HTTP traffic. | 80 | +| `HTTPS_PORT` | The port to listen on for HTTPS traffic. | 443 | +| `HTTP_IDLE_TIMEOUT` | The maximum time in seconds that a client can be idle before the connection is closed. | 60 | +| `HTTP_READ_TIMEOUT` | The maximum time in seconds that a client can take to send the request headers. | 30 | +| `HTTP_WRITE_TIMEOUT` | The maximum time in seconds during which the client must read the response. | 30 | +| `DEBUG` | Set to `1` or `true` to enable debug logging. | Disabled | diff --git a/go.mod b/go.mod index f96705c..0bd12ef 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/klauspost/compress v1.17.4 github.com/stretchr/testify v1.8.4 golang.org/x/crypto v0.17.0 + golang.org/x/image v0.15.0 ) require ( diff --git a/go.sum b/go.sum index aea6d8a..699f7df 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= +golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= diff --git a/internal/config.go b/internal/config.go index 36aece4..5ce4beb 100644 --- a/internal/config.go +++ b/internal/config.go @@ -20,8 +20,9 @@ const ( defaultMaxCacheItemSizeBytes = 1 * MB defaultMaxRequestBody = 0 - defaultStoragePath = "./storage/thruster" - defaultBadGatewayPage = "./public/502.html" + defaultStoragePath = "./storage/thruster" + defaultBadGatewayPage = "./public/502.html" + defaultImageProxyMaxDimension = 5000 defaultHttpPort = 80 defaultHttpsPort = 443 @@ -37,10 +38,12 @@ type Config struct { UpstreamCommand string UpstreamArgs []string - CacheSizeBytes int - MaxCacheItemSizeBytes int - XSendfileEnabled bool - MaxRequestBody int + CacheSizeBytes int + MaxCacheItemSizeBytes int + XSendfileEnabled bool + ImageProxyEnabled bool + ImageProxyMaxDimension int + MaxRequestBody int SSLDomain string StoragePath string @@ -70,10 +73,12 @@ func NewConfig() (*Config, error) { UpstreamCommand: os.Args[1], UpstreamArgs: os.Args[2:], - CacheSizeBytes: getEnvInt("CACHE_SIZE", defaultCacheSize), - MaxCacheItemSizeBytes: getEnvInt("MAX_CACHE_ITEM_SIZE", defaultMaxCacheItemSizeBytes), - XSendfileEnabled: getEnvBool("X_SENDFILE_ENABLED", true), - MaxRequestBody: getEnvInt("MAX_REQUEST_BODY", defaultMaxRequestBody), + CacheSizeBytes: getEnvInt("CACHE_SIZE", defaultCacheSize), + MaxCacheItemSizeBytes: getEnvInt("MAX_CACHE_ITEM_SIZE", defaultMaxCacheItemSizeBytes), + XSendfileEnabled: getEnvBool("X_SENDFILE_ENABLED", true), + ImageProxyEnabled: getEnvBool("IMAGE_PROXY_ENABLED", true), + ImageProxyMaxDimension: getEnvInt("IMAGE_PROXY_MAX_DIMENSION", defaultImageProxyMaxDimension), + MaxRequestBody: getEnvInt("MAX_REQUEST_BODY", defaultMaxRequestBody), SSLDomain: getEnvString("SSL_DOMAIN", ""), StoragePath: getEnvString("STORAGE_PATH", defaultStoragePath), diff --git a/internal/fixtures/image.gif b/internal/fixtures/image.gif new file mode 100644 index 0000000000000000000000000000000000000000..c9d04d739fa6579e89ea4571684feff592025bd8 GIT binary patch literal 3662 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 literal 0 HcmV?d00001 diff --git a/internal/fixtures/image.jpg b/internal/fixtures/image.jpg index 9b7cb606a6bb86858a54abb499c518852e8c8d10..6b593f48071c90ab808a86e4916bc150f92c85c3 100644 GIT binary patch literal 4322 zcmb_g2UJr@+n#%GIte5M5Rei;iXepEduX8u(!oYZ0tt{1lThs}iz~PmR8+7ax+?0T zt1BvNLBzGLioGxBvewVVb?vTva{~n3|NqbVzjMAfXYM?C-a3<+duB3tW3U%6W25*{ z06`G26n?;9AE=Kkm*xSGkN_M302Dw#OaT!h1h!ygtQJQh4gk~eC;$tjfG}(`5%##i zXd0iP9;>ebWXR~lR^E(itzDb3Q4Dn;EQQDC!#+i*6pQ65NwIqHK7+&O@unvwCr0t( zBVn-sFqi^`q6lRIAd{<=$UK4Y;$MLAVzNV=U zTz1A|jq?95#1e^BLO8%AXq_uk31txf2-_2(La7D-Ylgf{nOcG4Zio$(ut11!;5dH> zKf`gZ2IG?lZBmnY01yn|w9^FwWj+9O4&*td!h9Hq9tyFmTqKo4tbjOJCMy;}yc=Q% zXahMq9^W_jvpD&mVSyl58&e?2*W#hEKu?%BLaI^}2`V&S{+kb3Q3;$cjo85_U80Oh zhCN&l$c@6{I4zdvPD~u(rEqj3SW*&`HiCsJ9-PY|Uy&dpZUoDVCgQ)hCT5;A3SUMV zELHPUN3d8GnKFWv#mU-Sc}iZ`2rp1VH0)ban5N|=V!k$hr6e_D#HLi5j<0%6E>&TQ zww_7}Ps=MylC|}V<jb`~Xh07b025#V zY=8rB2JXNc1b|Qw31UGaNCjD78V~{rkO3tq1GB(Y24}%Va24DJy`UdF1q0wU_yAv;WQ2jR5fj7`u|=E^cf=P7Mk0}TBpJy_$YbPBWhY@DRboH!HHkzrBH5FCNHL@=QUPfuX%T51X&0%J z)I)kedPOFaO~_8JsW^Y6tZKwV(Qd#-iENf@rC<0@{4qYFZoZ1nmy(rH+n{ zwT_?8Bps>FJe}1#yLC?K^y$2#>(d?SJo*%R34JkrE4`C`lm3#yVAwH28QBaqV=<$Z zah!3F@m80u>!Qoo73t2^ZPIPmy`=kGkE&;@7p6B&uTrl;Z?E13y=P1+(~ikw3Yc@4 zP0WML>&#ayHp`8b$da)Zv9_~LvmWV__3iW{^+o#C`kVBR>px&)Y#TO@Eo6Vi-poG1 z?l&MA*c&4c-XMl2s5%X;u{qiEiu|-bj|4f81pe< zW5ii%mKkd1XqId?(`=L3IkQ*h7Ut3B3iCDQ$IYKv7+Hi`6j-dVIAZbG zl5H7mDYaZ-dDQX=$B+}oDdMc=baMu*%&cOpN~|_oowxd6ZEu}sU1hz?`mPPl#@8m# zW`#|c&A>RzaS7vQjoUHqmMzuR*H&V?%J!t~8#{Zu47&w(`|Tdt8{6~kEA4mK-*I3# zggDG_XmPkQo;2QfeBt!>pgFHvAq(!7I<}e{q61Jt@Lj5e(W>OC&y>4&vjn|-z48f zzNh?%e&K#|{SNuP^Y`{w`|t7pBfvSJFkpMYqd@yWap2~_2SL_Bf}o8-y}_K|oZ#l* z-Vm!0K}bu;{ZN}wQRtS?hhgKx3c_}VJr8#cSBCEkf5Y?V&E|DRU=h&~wGrnd^&`_F z8zb*USx4nZ?T8wP_KKbv-5EoSiHlhlbDeL_7xG*A1F=4_vtzsC=y54=jdAzm9pjbp z9SLYcY{K${I}>du$|vqm1d05_<%xHb?2~3B{WOU*DQQyEq~DX>l4m8KOkt-?PuZUG zIyF3XN$Tx1hqRKku5`WhDe0~0Z!#h>>N9#XxtX&w&t{osNwW@Qle5#aw`9MX965RA zKbC|@8YIu8JZYo!ML|r#hJrVRiG^DW2a7U_ zcF8ER9N9s+zPwQ0U2IWYUfiQ_Qq(AVXZX)pIb%Q>r`)1KR8v(4)CTZibgsmqRjs@>OL+KE;{qA+qbK~9bBBZ`1}&@CF_@xmdcl2TNbixTRp43 zvi`yHgyrokI4f#b{IzoW%F_*=4I5U`R+X&kT^+yrz?yMumah5OC~3Uf6yCIJt=Zaz zYu~IBt-G{7bbZ?f^9^+y-Zx8|Z)}X-*xq8-(y)oNsdUrto6|R+{Lc5g)-9%6>bCs7 zRlc=%Tk^K_B&v?0CF$%Fc`5M|^*v&ADykE~8y_yTR^~-A{hV`Qh50 zxIJBaefPHgX#3;Zee8X;?MQn?`@nw5{@w#w2YL?19_;D}=-Bs@%THSmSsiLRYh20niG2JJ{1kXu}$4zjk!6KQ3rPBFZTYBPN?He90_oHjOe4a8ahBx$+0 zC@VrLgZD?6BiPe7ko&(XMiYMKnE8J>ex~@RW132=7ps@FxZZp6PI*kVr%ll|rLYDO4(ru1BZo=;~0Z z3>HIIkI7^)X>@(IK9dbG6Ayymo*01w9ho{*9mxKNGS~@N6yOS6F@yzB7J{*m!EP`H z|Nj8rGNXc^WT-G^;)*XcEW}EHyZjFYK!_x$Fl2pc4@0sBZvi@nz8 lS+#T?(!8x1R5nv=rbfoV`!mM@pc1sop&74&1KZ%ue*>63-q8R6 delta 5770 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 IM6N<$g3ht&6#xJL diff --git a/internal/fixtures/image.png b/internal/fixtures/image.png new file mode 100644 index 0000000000000000000000000000000000000000..92ba3c4159f512f6a2e76e54c2f8b876f6df60db GIT binary patch literal 8475 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 t_7?va;(sL1#>??{F_HXD{_h + + + diff --git a/internal/fixtures/image.webp b/internal/fixtures/image.webp new file mode 100644 index 0000000000000000000000000000000000000000..5dfa3efe5a1ef6deec6335bb20ac257453439ce2 GIT binary patch literal 4044 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 imageProxyMaxDimension || cfg.Height > imageProxyMaxDimension { + slog.Debug("ImageProxy: image too large", "width", cfg.Width, "height", cfg.Height) + return nil + } + + slog.Debug("ImageProxy: image acceptable", "format", format, "width", cfg.Width, "height", cfg.Height) + return io.MultiReader(&buf, f) +} diff --git a/internal/image_proxy_handler_test.go b/internal/image_proxy_handler_test.go new file mode 100644 index 0000000..d067937 --- /dev/null +++ b/internal/image_proxy_handler_test.go @@ -0,0 +1,61 @@ +package internal + +import ( + "image" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestImageProxy_serving_valid_images(t *testing.T) { + tests := map[string]struct { + filename string + statusCode int + }{ + "valid gif": {"image.gif", http.StatusOK}, + "valid jpg": {"image.jpg", http.StatusOK}, + "valid png": {"image.png", http.StatusOK}, + "valid webp": {"image.webp", http.StatusOK}, + "valid svg": {"image.svg", http.StatusForbidden}, + "not an image": {"loremipsum.txt", http.StatusForbidden}, + "missing file": {"doesnotexist.txt", http.StatusNotFound}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + remoteServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !fixtureExists(tc.filename) { + w.WriteHeader(http.StatusNotFound) + return + } + + w.Write(fixtureContent(tc.filename)) + })) + defer remoteServer.Close() + + mux := http.NewServeMux() + RegisterNewImageProxyHandler(mux) + localServer := httptest.NewServer(mux) + defer localServer.Close() + + imageURL, _ := url.Parse(localServer.URL + imageProxyHandlerPath) + params := url.Values{} + params.Add("src", remoteServer.URL) + imageURL.RawQuery = params.Encode() + + resp, err := http.Get(imageURL.String()) + + require.NoError(t, err) + assert.Equal(t, tc.statusCode, resp.StatusCode) + + if tc.statusCode == http.StatusOK { + _, _, err = image.Decode(resp.Body) + require.NoError(t, err) + } + }) + } +} diff --git a/internal/service.go b/internal/service.go index 1abc18c..dcb331e 100644 --- a/internal/service.go +++ b/internal/service.go @@ -23,6 +23,7 @@ func (s *Service) Run() int { xSendfileEnabled: s.config.XSendfileEnabled, maxCacheableResponseBody: s.config.MaxCacheItemSizeBytes, badGatewayPage: s.config.BadGatewayPage, + imageProxyEnabled: s.config.ImageProxyEnabled, } handler := NewHandler(handlerOptions) @@ -56,4 +57,9 @@ func (s *Service) targetUrl() *url.URL { func (s *Service) setEnvironment() { // Set PORT to be inherited by the upstream process. os.Setenv("PORT", fmt.Sprintf("%d", s.config.TargetPort)) + + // Set IMAGE_PROXY_PATH, if enabled + if s.config.ImageProxyEnabled { + os.Setenv("IMAGE_PROXY_PATH", imageProxyHandlerPath) + } } diff --git a/internal/testing.go b/internal/testing.go index 121f2d6..64b3885 100644 --- a/internal/testing.go +++ b/internal/testing.go @@ -10,6 +10,16 @@ func fixturePath(name string) string { return path.Join("fixtures", name) } +func fixtureExists(name string) bool { + f, err := os.Open(fixturePath(name)) + if err != nil { + return false + } + defer f.Close() + + return true +} + func fixtureContent(name string) []byte { result, _ := os.ReadFile(fixturePath(name)) return result diff --git a/lib/thruster.rb b/lib/thruster.rb index c6ffb73..053882a 100644 --- a/lib/thruster.rb +++ b/lib/thruster.rb @@ -2,3 +2,4 @@ module Thruster end require_relative "thruster/version" +require_relative "thruster/helpers" diff --git a/lib/thruster/helpers.rb b/lib/thruster/helpers.rb new file mode 100644 index 0000000..6dddd62 --- /dev/null +++ b/lib/thruster/helpers.rb @@ -0,0 +1,9 @@ +module Thruster + def self.image_proxy_path(src) + proxy_path = ENV["IMAGE_PROXY_PATH"] + return src if proxy_path.nil? + + query = URI.encode_www_form({ src: src }) + "#{proxy_path}?#{query}" + end +end