Skip to content

Commit 9608862

Browse files
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')
1 parent 8b3b83f commit 9608862

16 files changed

+285
-27
lines changed

README.md

+49-16
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ features to help your app run efficiently and safely on the open Internet:
99
- Basic HTTP caching
1010
- X-Sendfile support for efficient file serving
1111
- Automatic GZIP compression
12+
- Image proxy links to sanitize external image URLs
1213

1314
Thruster tries to be as zero-config as possible, so most features are
1415
automatically enabled with sensible defaults.
@@ -46,6 +47,36 @@ Or with automatic SSL:
4647
$ SSL_DOMAIN=myapp.example.com thrust bin/rails server
4748
```
4849

50+
## Image proxy links
51+
52+
Applications that allow user-generated content often need a way to sanitize
53+
external image URLs, to guard against the security risks of maliciously crafted
54+
images.
55+
56+
Thruster includes a minimal image proxy that inspects the content of external
57+
images before serving them. Images will be served if they:
58+
59+
- Appear to be valid image files
60+
- Are in a permitted format: GIF, JPEG, PNG or WebP
61+
- Do not have an excessive width or height (5000 pixels max, by default)
62+
63+
External images that do not meet these criteria will be served with a `403
64+
Forbidden` status.
65+
66+
To use the image proxy, your application should rewrite external image URLs in
67+
user-generated content to use Thruster's image proxy path. This path is provided
68+
to your application in the `IMAGE_PROXY_PATH` environment variable. Specify the
69+
URL of the image to proxy as a query parameter named `src`.
70+
71+
Thruster provides a helper method to form these paths for you:
72+
73+
```ruby
74+
Thruster.image_proxy_path('https://example.com/image.jpg')
75+
```
76+
77+
When your application is running outside of Thruster,
78+
`Thruster.image_proxy_path` will return the original URL unchanged.
79+
4980
## Custom configuration
5081

5182
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
5788
prefixed variable is set, Thruster will use it in preference to the unprefixed
5889
version.
5990

60-
| Variable Name | Description | Default Value |
61-
|-----------------------|---------------------------------------------------------------------------------|---------------|
62-
| `SSL_DOMAIN` | The domain name to use for SSL provisioning. If not set, SSL will be disabled. | None |
63-
| `TARGET_PORT` | The port that your Puma server should run on. Thruster will set `PORT` to this when starting your server. | 3000 |
64-
| `CACHE_SIZE` | The size of the HTTP cache in bytes. | 64MB |
65-
| `MAX_CACHE_ITEM_SIZE` | The maximum size of a single item in the HTTP cache in bytes. | 1MB |
66-
| `X_SENDFILE_ENABLED` | Whether to enable X-Sendfile support. Set to `0` or `false` to disable. | Enabled |
67-
| `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` |
68-
| `STORAGE_PATH` | The path to store Thruster's internal state. | `./storage/thruster` |
69-
| `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` |
70-
| `HTTP_PORT` | The port to listen on for HTTP traffic. | 80 |
71-
| `HTTPS_PORT` | The port to listen on for HTTPS traffic. | 443 |
72-
| `HTTP_IDLE_TIMEOUT` | The maximum time in seconds that a client can be idle before the connection is closed. | 60 |
73-
| `HTTP_READ_TIMEOUT` | The maximum time in seconds that a client can take to send the request headers. | 30 |
74-
| `HTTP_WRITE_TIMEOUT` | The maximum time in seconds during which the client must read the response. | 30 |
75-
| `DEBUG` | Set to `1` or `true` to enable debug logging. | Disabled |
91+
| Variable Name | Description | Default Value |
92+
|-----------------------------|---------------------------------------------------------------------------------|---------------|
93+
| `SSL_DOMAIN` | The domain name to use for SSL provisioning. If not set, SSL will be disabled. | None |
94+
| `TARGET_PORT` | The port that your Puma server should run on. Thruster will set `PORT` to this when starting your server. | 3000 |
95+
| `CACHE_SIZE` | The size of the HTTP cache in bytes. | 64MB |
96+
| `MAX_CACHE_ITEM_SIZE` | The maximum size of a single item in the HTTP cache in bytes. | 1MB |
97+
| `X_SENDFILE_ENABLED` | Whether to enable X-Sendfile support. Set to `0` or `false` to disable. | Enabled |
98+
| `IMAGE_PROXY_ENABLED` | Whether to enable the built in image proxy. Set to `0` or `false` to disable. | Enabled |
99+
| `IMAGE_PROXY_MAX_DIMENSION` | When using the image proxy, only serve images with a width and height less than this, in pixels | 5000 |
100+
| `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` |
101+
| `STORAGE_PATH` | The path to store Thruster's internal state. | `./storage/thruster` |
102+
| `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` |
103+
| `HTTP_PORT` | The port to listen on for HTTP traffic. | 80 |
104+
| `HTTPS_PORT` | The port to listen on for HTTPS traffic. | 443 |
105+
| `HTTP_IDLE_TIMEOUT` | The maximum time in seconds that a client can be idle before the connection is closed. | 60 |
106+
| `HTTP_READ_TIMEOUT` | The maximum time in seconds that a client can take to send the request headers. | 30 |
107+
| `HTTP_WRITE_TIMEOUT` | The maximum time in seconds during which the client must read the response. | 30 |
108+
| `DEBUG` | Set to `1` or `true` to enable debug logging. | Disabled |

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/klauspost/compress v1.17.4
77
github.com/stretchr/testify v1.8.4
88
golang.org/x/crypto v0.17.0
9+
golang.org/x/image v0.15.0
910
)
1011

1112
require (

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
1515
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
1616
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
1717
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
18+
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
19+
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
1820
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
1921
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
2022
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=

internal/config.go

+15-10
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ const (
2020
defaultMaxCacheItemSizeBytes = 1 * MB
2121
defaultMaxRequestBody = 0
2222

23-
defaultStoragePath = "./storage/thruster"
24-
defaultBadGatewayPage = "./public/502.html"
23+
defaultStoragePath = "./storage/thruster"
24+
defaultBadGatewayPage = "./public/502.html"
25+
defaultImageProxyMaxDimension = 5000
2526

2627
defaultHttpPort = 80
2728
defaultHttpsPort = 443
@@ -37,10 +38,12 @@ type Config struct {
3738
UpstreamCommand string
3839
UpstreamArgs []string
3940

40-
CacheSizeBytes int
41-
MaxCacheItemSizeBytes int
42-
XSendfileEnabled bool
43-
MaxRequestBody int
41+
CacheSizeBytes int
42+
MaxCacheItemSizeBytes int
43+
XSendfileEnabled bool
44+
ImageProxyEnabled bool
45+
ImageProxyMaxDimension int
46+
MaxRequestBody int
4447

4548
SSLDomain string
4649
StoragePath string
@@ -70,10 +73,12 @@ func NewConfig() (*Config, error) {
7073
UpstreamCommand: os.Args[1],
7174
UpstreamArgs: os.Args[2:],
7275

73-
CacheSizeBytes: getEnvInt("CACHE_SIZE", defaultCacheSize),
74-
MaxCacheItemSizeBytes: getEnvInt("MAX_CACHE_ITEM_SIZE", defaultMaxCacheItemSizeBytes),
75-
XSendfileEnabled: getEnvBool("X_SENDFILE_ENABLED", true),
76-
MaxRequestBody: getEnvInt("MAX_REQUEST_BODY", defaultMaxRequestBody),
76+
CacheSizeBytes: getEnvInt("CACHE_SIZE", defaultCacheSize),
77+
MaxCacheItemSizeBytes: getEnvInt("MAX_CACHE_ITEM_SIZE", defaultMaxCacheItemSizeBytes),
78+
XSendfileEnabled: getEnvBool("X_SENDFILE_ENABLED", true),
79+
ImageProxyEnabled: getEnvBool("IMAGE_PROXY_ENABLED", true),
80+
ImageProxyMaxDimension: getEnvInt("IMAGE_PROXY_MAX_DIMENSION", defaultImageProxyMaxDimension),
81+
MaxRequestBody: getEnvInt("MAX_REQUEST_BODY", defaultMaxRequestBody),
7782

7883
SSLDomain: getEnvString("SSL_DOMAIN", ""),
7984
StoragePath: getEnvString("STORAGE_PATH", defaultStoragePath),

internal/fixtures/image.gif

3.58 KB
Loading

internal/fixtures/image.jpg

-1.92 KB
Loading

internal/fixtures/image.png

8.28 KB
Loading

internal/fixtures/image.svg

+4
Loading

internal/fixtures/image.webp

3.95 KB
Binary file not shown.

internal/handler.go

+10-1
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,24 @@ type HandlerOptions struct {
1515
maxRequestBody int
1616
targetUrl *url.URL
1717
xSendfileEnabled bool
18+
imageProxyEnabled bool
1819
}
1920

2021
func NewHandler(options HandlerOptions) http.Handler {
22+
mux := http.NewServeMux()
23+
2124
handler := NewProxyHandler(options.targetUrl, options.badGatewayPage)
2225
handler = NewCacheHandler(options.cache, options.maxCacheableResponseBody, handler)
2326
handler = NewSendfileHandler(options.xSendfileEnabled, handler)
2427
handler = gzhttp.GzipHandler(handler)
2528
handler = NewMaxRequestBodyHandler(options.maxRequestBody, handler)
2629
handler = NewLoggingMiddleware(slog.Default(), handler)
2730

28-
return handler
31+
if options.imageProxyEnabled {
32+
RegisterNewImageProxyHandler(mux)
33+
}
34+
35+
mux.Handle("/", handler)
36+
37+
return mux
2938
}

internal/image_proxy_handler.go

+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package internal
2+
3+
import (
4+
"bytes"
5+
"image"
6+
"io"
7+
"log/slog"
8+
"net/http"
9+
"net/url"
10+
"slices"
11+
"time"
12+
13+
_ "image/gif"
14+
_ "image/jpeg"
15+
_ "image/png"
16+
17+
_ "golang.org/x/image/webp"
18+
)
19+
20+
var allowedFormats = []string{"gif", "jpeg", "png", "webp"}
21+
22+
const (
23+
imageProxyHandlerPath = "/_t/image"
24+
imageProxyMaxDimension = 5000
25+
)
26+
27+
type ImageProxyHandler struct {
28+
httpClient *http.Client
29+
}
30+
31+
func RegisterNewImageProxyHandler(mux *http.ServeMux) {
32+
handler := &ImageProxyHandler{
33+
httpClient: &http.Client{
34+
Timeout: 10 * time.Second,
35+
},
36+
}
37+
38+
mux.Handle("GET "+imageProxyHandlerPath, handler)
39+
}
40+
41+
func (h *ImageProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
42+
remoteURL := h.extractRemoteURL(r)
43+
if remoteURL == nil {
44+
http.Error(w, "invalid url", http.StatusNotFound)
45+
return
46+
}
47+
48+
resp, err := h.httpClient.Get(remoteURL.String())
49+
if err != nil {
50+
http.Error(w, "error fetching remote image", http.StatusBadGateway)
51+
return
52+
}
53+
54+
if resp.StatusCode != http.StatusOK {
55+
h.copyHeaders(w, resp)
56+
w.WriteHeader(resp.StatusCode)
57+
return
58+
}
59+
60+
imageReader := h.sanitizeImage(resp.Body)
61+
if imageReader == nil {
62+
http.Error(w, "invalid image", http.StatusForbidden)
63+
return
64+
}
65+
66+
slog.Info("Proxying remote image", "url", remoteURL)
67+
68+
h.copyHeaders(w, resp)
69+
w.WriteHeader(http.StatusOK)
70+
io.Copy(w, imageReader)
71+
}
72+
73+
// Private
74+
75+
func (h *ImageProxyHandler) extractRemoteURL(r *http.Request) *url.URL {
76+
urlString := r.URL.Query().Get("src")
77+
if urlString == "" {
78+
return nil
79+
}
80+
81+
remoteURL, err := url.Parse(urlString)
82+
if err != nil || (remoteURL.Scheme != "http" && remoteURL.Scheme != "https") {
83+
return nil
84+
}
85+
86+
return remoteURL
87+
}
88+
89+
func (h *ImageProxyHandler) copyHeaders(w http.ResponseWriter, resp *http.Response) {
90+
for k, v := range resp.Header {
91+
w.Header()[k] = v
92+
}
93+
}
94+
95+
func (h *ImageProxyHandler) sanitizeImage(f io.Reader) io.Reader {
96+
var buf bytes.Buffer
97+
reader := io.TeeReader(f, &buf)
98+
99+
cfg, format, err := image.DecodeConfig(reader)
100+
if err != nil {
101+
slog.Debug("ImageProxy: image format not valid", "err", err)
102+
return nil
103+
}
104+
105+
if !slices.Contains(allowedFormats, format) {
106+
slog.Debug("ImageProxy: image format not allowed", "format", format)
107+
return nil
108+
}
109+
110+
if cfg.Width > imageProxyMaxDimension || cfg.Height > imageProxyMaxDimension {
111+
slog.Debug("ImageProxy: image too large", "width", cfg.Width, "height", cfg.Height)
112+
return nil
113+
}
114+
115+
slog.Debug("ImageProxy: image acceptable", "format", format, "width", cfg.Width, "height", cfg.Height)
116+
return io.MultiReader(&buf, f)
117+
}

internal/image_proxy_handler_test.go

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package internal
2+
3+
import (
4+
"image"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestImageProxy_serving_valid_images(t *testing.T) {
15+
tests := map[string]struct {
16+
filename string
17+
statusCode int
18+
}{
19+
"valid gif": {"image.gif", http.StatusOK},
20+
"valid jpg": {"image.jpg", http.StatusOK},
21+
"valid png": {"image.png", http.StatusOK},
22+
"valid webp": {"image.webp", http.StatusOK},
23+
"valid svg": {"image.svg", http.StatusForbidden},
24+
"not an image": {"loremipsum.txt", http.StatusForbidden},
25+
"missing file": {"doesnotexist.txt", http.StatusNotFound},
26+
}
27+
28+
for name, tc := range tests {
29+
t.Run(name, func(t *testing.T) {
30+
remoteServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31+
if !fixtureExists(tc.filename) {
32+
w.WriteHeader(http.StatusNotFound)
33+
return
34+
}
35+
36+
w.Write(fixtureContent(tc.filename))
37+
}))
38+
defer remoteServer.Close()
39+
40+
mux := http.NewServeMux()
41+
RegisterNewImageProxyHandler(mux)
42+
localServer := httptest.NewServer(mux)
43+
defer localServer.Close()
44+
45+
imageURL, _ := url.Parse(localServer.URL + imageProxyHandlerPath)
46+
params := url.Values{}
47+
params.Add("src", remoteServer.URL)
48+
imageURL.RawQuery = params.Encode()
49+
50+
resp, err := http.Get(imageURL.String())
51+
52+
require.NoError(t, err)
53+
assert.Equal(t, tc.statusCode, resp.StatusCode)
54+
55+
if tc.statusCode == http.StatusOK {
56+
_, _, err = image.Decode(resp.Body)
57+
require.NoError(t, err)
58+
}
59+
})
60+
}
61+
}

internal/service.go

+6
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func (s *Service) Run() int {
2323
xSendfileEnabled: s.config.XSendfileEnabled,
2424
maxCacheableResponseBody: s.config.MaxCacheItemSizeBytes,
2525
badGatewayPage: s.config.BadGatewayPage,
26+
imageProxyEnabled: s.config.ImageProxyEnabled,
2627
}
2728

2829
handler := NewHandler(handlerOptions)
@@ -56,4 +57,9 @@ func (s *Service) targetUrl() *url.URL {
5657
func (s *Service) setEnvironment() {
5758
// Set PORT to be inherited by the upstream process.
5859
os.Setenv("PORT", fmt.Sprintf("%d", s.config.TargetPort))
60+
61+
// Set IMAGE_PROXY_PATH, if enabled
62+
if s.config.ImageProxyEnabled {
63+
os.Setenv("IMAGE_PROXY_PATH", imageProxyHandlerPath)
64+
}
5965
}

internal/testing.go

+10
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ func fixturePath(name string) string {
1010
return path.Join("fixtures", name)
1111
}
1212

13+
func fixtureExists(name string) bool {
14+
f, err := os.Open(fixturePath(name))
15+
if err != nil {
16+
return false
17+
}
18+
defer f.Close()
19+
20+
return true
21+
}
22+
1323
func fixtureContent(name string) []byte {
1424
result, _ := os.ReadFile(fixturePath(name))
1525
return result

lib/thruster.rb

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ module Thruster
22
end
33

44
require_relative "thruster/version"
5+
require_relative "thruster/helpers"

0 commit comments

Comments
 (0)