Skip to content

Commit 6319063

Browse files
authored
Add host whitelist and unsafe requests flag to serve command (#59)
1 parent 27a88b4 commit 6319063

File tree

5 files changed

+83
-6
lines changed

5 files changed

+83
-6
lines changed

CHANGELOG.MD

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
44

55
**Warning:** Features marked as *alpha* may change or be removed in a future release without notice. Use with caution.
66

7+
## [0.4.1] - 2025-08-17
8+
9+
### Added
10+
11+
- The `--http-host-whitelist` flag has been added to the serve command, to which a list of hosts can be passed. If at least one host is passed, access to streamed HTTP/HTTPS publications will be restricted to the provided hosts. A host like example.com can be further restricted to a "folder", such as example.com/the/path/
12+
- The `--http-unsafe-requests` flag has been added. It disabled restrictions that are enabled by default to prevent access to private IP addresses (such as internal infrastructure or localhost), and should be used with caution
13+
714
## [0.4.0] - 2025-07-30
815

916
### Changed

internal/cli/serve.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import (
1313

1414
"log/slog"
1515

16+
nurl "net/url"
17+
1618
"cloud.google.com/go/storage"
1719
"github.com/aws/aws-sdk-go-v2/aws"
1820
"github.com/aws/aws-sdk-go-v2/config"
@@ -44,6 +46,8 @@ var s3AccessKeyFlag string
4446
var s3SecretKeyFlag string
4547
var s3UsePathStyleFlag bool
4648

49+
var httpHostWhitelistFlag []string
50+
var httpUnsafeRequestsFlag bool
4751
var httpAuthorizationFlag string
4852

4953
var remoteArchiveTimeoutFlag uint32
@@ -182,7 +186,21 @@ implement any authentication, and may have more access to files than expected.`,
182186
}
183187

184188
// HTTP/HTTPS
185-
remote.HTTP, err = client.NewHTTPClient(httpAuthorizationFlag)
189+
urlWhitelist := make([]*nurl.URL, len(httpHostWhitelistFlag))
190+
for i, rawURL := range httpHostWhitelistFlag {
191+
parsedURL, err := nurl.Parse(rawURL)
192+
if err != nil {
193+
return fmt.Errorf("invalid URL in whitelist: %s: %w", rawURL, err)
194+
}
195+
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
196+
return fmt.Errorf("whitelisted URL %s must have http or https scheme", rawURL)
197+
}
198+
if parsedURL.Host == "" {
199+
return fmt.Errorf("whitelisted URL %s must have a host", rawURL)
200+
}
201+
urlWhitelist[i] = parsedURL
202+
}
203+
remote.HTTP, err = client.NewHTTPClient(httpAuthorizationFlag, urlWhitelist, httpUnsafeRequestsFlag)
186204
if err != nil {
187205
slog.Warn("HTTP client creation failed, HTTP support will be disabled", "error", err)
188206
}
@@ -239,6 +257,8 @@ func init() {
239257
serveCmd.Flags().StringVar(&s3SecretKeyFlag, "s3-secret-key", "", "S3 secret key")
240258
serveCmd.Flags().BoolVar(&s3UsePathStyleFlag, "s3-use-path-style", false, "Use S3 path style buckets (default is to use virtual hosts)")
241259

260+
serveCmd.Flags().StringSliceVar(&httpHostWhitelistFlag, "http-host-whitelist", []string{}, "Whitelist of HTTP hosts/paths to allow for remote HTTP requests (e.g. 'http://1.1.1.1', 'https://na1.storage.example.com/the/path'). If omitted, anything that resolves to a public IP is allowed.")
261+
serveCmd.Flags().BoolVar(&httpUnsafeRequestsFlag, "http-unsafe-requests", false, "Allow potentially unsafe HTTP requests to private IP addresses (e.g. localhost). Enable only if you completely control the requests made to the server, otherwise this can be dangerous")
242262
serveCmd.Flags().StringVar(&httpAuthorizationFlag, "http-authorization", "", "HTTP authorization header value (e.g. 'Bearer <token>' or 'Basic <base64-credentials>')")
243263

244264
serveCmd.Flags().Uint32Var(&remoteArchiveTimeoutFlag, "remote-archive-timeout", 60, "Timeout for remote archive requests (in seconds)")

pkg/serve/client/host_whitelist.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package client
2+
3+
import (
4+
"net/url"
5+
"strings"
6+
)
7+
8+
// Check if a URL has a valid match in the whitelist.
9+
// A valid match is when the host (hostname:port) is equal,
10+
// and the URL starts with the (optional) path in the whitelist entry
11+
func validateAgainstWhitelist(url *url.URL, whitelist []*url.URL) bool {
12+
if len(whitelist) == 0 {
13+
return true
14+
}
15+
16+
for _, u := range whitelist {
17+
if u.Host != url.Host {
18+
continue
19+
}
20+
if u.Path == "" || strings.HasPrefix(url.Path, u.Path) {
21+
return true
22+
}
23+
}
24+
25+
return false
26+
}

pkg/serve/client/http_auth.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
package client
22

33
import (
4+
"fmt"
45
"net/http"
6+
"net/url"
57
)
68

79
type authTransport struct {
810
Authorization string
11+
Whitelist []*url.URL
912
Transport http.RoundTripper
1013
}
1114

1215
func (a *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
16+
if !validateAgainstWhitelist(req.URL, a.Whitelist) {
17+
return nil, fmt.Errorf("request to %s is not allowed by the whitelist", req.URL)
18+
}
19+
1320
if a.Authorization == "" {
1421
return a.transport().RoundTrip(req)
1522
}
@@ -25,9 +32,10 @@ func (a *authTransport) transport() http.RoundTripper {
2532
return http.DefaultTransport
2633
}
2734

28-
func newAuthenticatedRoundTripper(auth string, transport *http.Transport) http.RoundTripper {
35+
func newAuthenticatedRoundTripper(auth string, whitelist []*url.URL, transport *http.Transport) http.RoundTripper {
2936
return &authTransport{
3037
Authorization: auth,
38+
Whitelist: whitelist,
3139
Transport: transport,
3240
}
3341
}

pkg/serve/client/http_client.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package client
22

33
import (
4+
"errors"
45
"fmt"
56
"net"
67
"net/http"
8+
"net/url"
79
"runtime"
810
"syscall"
911
"time"
@@ -39,16 +41,19 @@ func safeSocketControl(network string, address string, conn syscall.RawConn) err
3941

4042
// Some of the below conf values from https://github.com/imgproxy/imgproxy/blob/master/transport/transport.go
4143

42-
const ClientKeepAliveTimeout = 90 // Imgproxy default
43-
var Workers = runtime.GOMAXPROCS(0) * 2 // Imgproxy default
44+
const ClientKeepAliveTimeout = 90 // Imgproxy default
45+
var Workers = runtime.NumCPU() * 2 // Imgproxy default
4446

45-
func NewHTTPClient(auth string) (*http.Client, error) {
47+
func NewHTTPClient(auth string, whitelist []*url.URL, bypassSafeSocketControl bool) (*http.Client, error) {
4648
safeDialer := &net.Dialer{
4749
Timeout: 30 * time.Second,
4850
KeepAlive: 30 * time.Second,
4951
DualStack: true,
5052
Control: safeSocketControl,
5153
}
54+
if bypassSafeSocketControl {
55+
safeDialer.Control = nil
56+
}
5257

5358
safeTransport := &http.Transport{
5459
Proxy: http.ProxyFromEnvironment,
@@ -62,6 +67,17 @@ func NewHTTPClient(auth string) (*http.Client, error) {
6267
}
6368

6469
return &http.Client{
65-
Transport: newAuthenticatedRoundTripper(auth, safeTransport),
70+
Transport: newAuthenticatedRoundTripper(auth, whitelist, safeTransport),
71+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
72+
if len(via) >= 10 {
73+
// Default Go behavior
74+
return errors.New("stopped after 10 redirects")
75+
}
76+
77+
if !validateAgainstWhitelist(req.URL, whitelist) {
78+
return fmt.Errorf("redirect to %s is not allowed by the whitelist", req.URL)
79+
}
80+
return nil
81+
},
6682
}, nil
6783
}

0 commit comments

Comments
 (0)