Sfoglia il codice sorgente

Support wildcards with * in AllowedSources (excluding slashes) (#677)

* Support wildcards with * in AllowedSources (excluding slashes)

* Compile allowed sources patterns when parsing config

* Fix linter errors

Co-authored-by: Christopher Hlubek <christopher.hlubek@networkteam.com>
Christopher Hlubek 3 anni fa
parent
commit
dbd19649a6
4 ha cambiato i file con 94 aggiunte e 36 eliminazioni
  1. 35 18
      config.go
  2. 1 1
      docs/configuration.md
  3. 2 2
      processing_options.go
  4. 56 15
      processing_options_test.go

+ 35 - 18
config.go

@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"math"
 	"os"
+	"regexp"
 	"runtime"
 	"strconv"
 	"strings"
@@ -36,22 +37,6 @@ func strEnvConfig(s *string, name string) {
 	}
 }
 
-func strSliceEnvConfig(s *[]string, name string) {
-	if env := os.Getenv(name); len(env) > 0 {
-		parts := strings.Split(env, ",")
-
-		for i, p := range parts {
-			parts[i] = strings.TrimSpace(p)
-		}
-
-		*s = parts
-
-		return
-	}
-
-	*s = []string{}
-}
-
 func boolEnvConfig(b *bool, name string) {
 	if env, err := strconv.ParseBool(os.Getenv(name)); err == nil {
 		*b = env
@@ -197,6 +182,38 @@ func presetFileConfig(p presets, filepath string) error {
 	return nil
 }
 
+func patternsEnvConfig(s *[]*regexp.Regexp, name string) {
+	if env := os.Getenv(name); len(env) > 0 {
+		parts := strings.Split(env, ",")
+		result := make([]*regexp.Regexp, len(parts))
+
+		for i, p := range parts {
+			result[i] = regexpFromPattern(strings.TrimSpace(p))
+		}
+
+		*s = result
+	} else {
+		*s = []*regexp.Regexp{}
+	}
+}
+
+func regexpFromPattern(pattern string) *regexp.Regexp {
+	var result strings.Builder
+	// Perform prefix matching
+	result.WriteString("^")
+	for i, part := range strings.Split(pattern, "*") {
+		// Add a regexp match all without slashes for each wildcard character
+		if i > 0 {
+			result.WriteString("[^/]*")
+		}
+
+		// Quote other parts of the pattern
+		result.WriteString(regexp.QuoteMeta(part))
+	}
+	// It is safe to use regexp.MustCompile since the expression is always valid
+	return regexp.MustCompile(result.String())
+}
+
 type config struct {
 	Network          string
 	Bind             string
@@ -258,7 +275,7 @@ type config struct {
 	IgnoreSslVerification bool
 	DevelopmentErrorsMode bool
 
-	AllowedSources      []string
+	AllowedSources      []*regexp.Regexp
 	LocalFileSystemRoot string
 	S3Enabled           bool
 	S3Region            string
@@ -384,7 +401,7 @@ func configure() error {
 	}
 	intEnvConfig(&conf.MaxAnimationFrames, "IMGPROXY_MAX_ANIMATION_FRAMES")
 
-	strSliceEnvConfig(&conf.AllowedSources, "IMGPROXY_ALLOWED_SOURCES")
+	patternsEnvConfig(&conf.AllowedSources, "IMGPROXY_ALLOWED_SOURCES")
 
 	intEnvConfig(&conf.AvifSpeed, "IMGPROXY_AVIF_SPEED")
 	boolEnvConfig(&conf.JpegProgressive, "IMGPROXY_JPEG_PROGRESSIVE")

+ 1 - 1
docs/configuration.md

@@ -73,7 +73,7 @@ imgproxy does not send CORS headers by default. Specify allowed origin to enable
 
 You can limit allowed source URLs:
 
-* `IMGPROXY_ALLOWED_SOURCES`: whitelist of source image URLs prefixes divided by comma. When blank, imgproxy allows all source image URLs. Example: `s3://,https://example.com/,local://`. Default: blank.
+* `IMGPROXY_ALLOWED_SOURCES`: whitelist of source image URLs prefixes divided by comma. Wildcards can be included with `*` to match all characters except `/`. When blank, imgproxy allows all source image URLs. Example: `s3://,https://*.example.com/,local://`. Default: blank.
 
 **⚠️Warning:** Be careful when using this config to limit source URL hosts, and always add a trailing slash after the host. Bad: `http://example.com`, good: `http://example.com/`. If you don't add a trailing slash, `http://example.com@baddomain.com` will be an allowed URL but the request will be made to `baddomain.com`.
 

+ 2 - 2
processing_options.go

@@ -993,8 +993,8 @@ func isAllowedSource(imageURL string) bool {
 	if len(conf.AllowedSources) == 0 {
 		return true
 	}
-	for _, val := range conf.AllowedSources {
-		if strings.HasPrefix(imageURL, string(val)) {
+	for _, allowedSource := range conf.AllowedSources {
+		if allowedSource.MatchString(imageURL) {
 			return true
 		}
 	}

+ 56 - 15
processing_options_test.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"net/http"
 	"net/url"
+	"regexp"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -105,22 +106,62 @@ func (s *ProcessingOptionsTestSuite) TestParsePlainURLEscapedWithBase() {
 	assert.Equal(s.T(), imageTypePNG, getProcessingOptions(ctx).Format)
 }
 
-func (s *ProcessingOptionsTestSuite) TestParseURLAllowedSource() {
-	conf.AllowedSources = []string{"local://", "http://images.dev/"}
-
-	req := s.getRequest("/unsafe/plain/http://images.dev/lorem/ipsum.jpg")
-	_, err := parsePath(context.Background(), req)
-
-	require.Nil(s.T(), err)
-}
-
-func (s *ProcessingOptionsTestSuite) TestParseURLNotAllowedSource() {
-	conf.AllowedSources = []string{"local://", "http://images.dev/"}
-
-	req := s.getRequest("/unsafe/plain/s3://images/lorem/ipsum.jpg")
-	_, err := parsePath(context.Background(), req)
+func (s *ProcessingOptionsTestSuite) TestParseURLAllowedSources() {
+	tt := []struct {
+		name           string
+		allowedSources []string
+		requestPath    string
+		expectedError  bool
+	}{
+		{
+			name:           "match http URL without wildcard",
+			allowedSources: []string{"local://", "http://images.dev/"},
+			requestPath:    "/unsafe/plain/http://images.dev/lorem/ipsum.jpg",
+			expectedError:  false,
+		},
+		{
+			name:           "match http URL with wildcard in hostname single level",
+			allowedSources: []string{"local://", "http://*.mycdn.dev/"},
+			requestPath:    "/unsafe/plain/http://a-1.mycdn.dev/lorem/ipsum.jpg",
+			expectedError:  false,
+		},
+		{
+			name:           "match http URL with wildcard in hostname multiple levels",
+			allowedSources: []string{"local://", "http://*.mycdn.dev/"},
+			requestPath:    "/unsafe/plain/http://a-1.b-2.mycdn.dev/lorem/ipsum.jpg",
+			expectedError:  false,
+		},
+		{
+			name:           "no match s3 URL with allowed local and http URLs",
+			allowedSources: []string{"local://", "http://images.dev/"},
+			requestPath:    "/unsafe/plain/s3://images/lorem/ipsum.jpg",
+			expectedError:  true,
+		},
+		{
+			name:           "no match http URL with wildcard in hostname including slash",
+			allowedSources: []string{"local://", "http://*.mycdn.dev/"},
+			requestPath:    "/unsafe/plain/http://other.dev/.mycdn.dev/lorem/ipsum.jpg",
+			expectedError:  true,
+		},
+	}
 
-	require.Error(s.T(), err)
+	for _, tc := range tt {
+		s.T().Run(tc.name, func(t *testing.T) {
+			exps := make([]*regexp.Regexp, len(tc.allowedSources))
+			for i, pattern := range tc.allowedSources {
+				exps[i] = regexpFromPattern(pattern)
+			}
+			conf.AllowedSources = exps
+
+			req := s.getRequest(tc.requestPath)
+			_, err := parsePath(context.Background(), req)
+			if tc.expectedError {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathBasic() {