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

File tree

16 files changed

+285
-27
lines changed

16 files changed

+285
-27
lines changed

README.md

Lines changed: 49 additions & 16 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 2 additions & 0 deletions
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

Lines changed: 15 additions & 10 deletions
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

Lines changed: 4 additions & 0 deletions
Loading

internal/fixtures/image.webp

3.95 KB
Loading

internal/handler.go

Lines changed: 10 additions & 1 deletion
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
}

0 commit comments

Comments
 (0)