From 239708f00fc82f376e804edd27316046b6164e11 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Tue, 16 Jan 2024 12:14:06 +0100 Subject: [PATCH] Config: Add options to configure CORS origin, headers and methods #3931 Signed-off-by: Michael Mayer --- internal/config/config.go | 15 ++++++++++ internal/config/config_server.go | 6 ---- internal/config/config_server_test.go | 16 ---------- internal/config/config_test.go | 34 +++++++++++++++++++++ internal/config/flags.go | 43 +++++++++++++++++---------- internal/config/options.go | 8 +++-- internal/config/report.go | 10 +++++-- internal/server/security.go | 23 +++++++------- pkg/clean/header.go | 2 +- pkg/clean/header_test.go | 2 +- pkg/header/cors.go | 11 +++++++ pkg/header/default.go | 6 ---- pkg/header/security.go | 7 +++++ 13 files changed, 122 insertions(+), 61 deletions(-) delete mode 100644 pkg/header/default.go diff --git a/internal/config/config.go b/internal/config/config.go index 428c7866c..7e1d839c7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -464,6 +464,21 @@ func (c *Config) CdnVideo() bool { return c.options.CdnVideo } +// CORSOrigin returns the value for the Access-Control-Allow-Origin header, if any. +func (c *Config) CORSOrigin() string { + return clean.Header(c.options.CORSOrigin) +} + +// CORSHeaders returns the value for the Access-Control-Allow-Headers header, if any. +func (c *Config) CORSHeaders() string { + return clean.Header(c.options.CORSHeaders) +} + +// CORSMethods returns the value for the Access-Control-Allow-Methods header, if any. +func (c *Config) CORSMethods() string { + return clean.Header(c.options.CORSMethods) +} + // ContentUri returns the content delivery URI. func (c *Config) ContentUri() string { return c.CdnUrl(c.ApiUri()) diff --git a/internal/config/config_server.go b/internal/config/config_server.go index 9e028144d..257dab3a1 100644 --- a/internal/config/config_server.go +++ b/internal/config/config_server.go @@ -83,12 +83,6 @@ func (c *Config) HttpCompression() string { return strings.ToLower(strings.TrimSpace(c.options.HttpCompression)) } -// HttpCORS checks of Cross-Origin Resource Sharing (CORS) should be allowed, -// so the "Access-Control-Allow-Origin" response header should be set to "*". -func (c *Config) HttpCORS() bool { - return c.options.HttpCORS -} - // HttpCachePublic checks whether static content may be cached by a CDN or caching proxy. func (c *Config) HttpCachePublic() bool { if c.options.HttpCachePublic { diff --git a/internal/config/config_server_test.go b/internal/config/config_server_test.go index fd87ad706..1ec273d70 100644 --- a/internal/config/config_server_test.go +++ b/internal/config/config_server_test.go @@ -62,22 +62,6 @@ func TestConfig_HttpCompression(t *testing.T) { assert.Equal(t, "", c.HttpCompression()) } -func TestConfig_HttpCORS(t *testing.T) { - c := NewConfig(CliTestContext()) - - c.Options().CdnUrl = "" - c.Options().HttpCORS = false - assert.False(t, c.HttpCORS()) - c.Options().CdnUrl = "https://cdn.com/" - assert.False(t, c.HttpCORS()) - c.Options().CdnUrl = "" - assert.False(t, c.HttpCORS()) - c.Options().HttpCORS = true - assert.True(t, c.HttpCORS()) - c.Options().HttpCORS = false - assert.False(t, c.HttpCORS()) -} - func TestConfig_HttpCachePublic(t *testing.T) { c := NewConfig(CliTestContext()) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e69be87ad..1b038ac80 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/header" ) func TestMain(m *testing.M) { @@ -471,6 +472,39 @@ func TestConfig_CdnVideo(t *testing.T) { assert.False(t, c.CdnVideo()) } +func TestConfig_CORSOrigin(t *testing.T) { + c := NewConfig(CliTestContext()) + + c.Options().CORSOrigin = "" + assert.Equal(t, "", c.CORSOrigin()) + c.Options().CORSOrigin = "*" + assert.Equal(t, "*", c.CORSOrigin()) + c.Options().CORSOrigin = "https://developer.mozilla.org" + assert.Equal(t, "https://developer.mozilla.org", c.CORSOrigin()) + c.Options().CORSOrigin = "" + assert.Equal(t, "", c.CORSOrigin()) +} + +func TestConfig_CORSHeaders(t *testing.T) { + c := NewConfig(CliTestContext()) + + assert.Equal(t, "", c.CORSHeaders()) + c.Options().CORSHeaders = header.DefaultAccessControlAllowHeaders + assert.Equal(t, header.DefaultAccessControlAllowHeaders, c.CORSHeaders()) + c.Options().CORSHeaders = "" + assert.Equal(t, "", c.CORSHeaders()) +} + +func TestConfig_CORSMethods(t *testing.T) { + c := NewConfig(CliTestContext()) + + assert.Equal(t, "", c.CORSMethods()) + c.Options().CORSMethods = header.DefaultAccessControlAllowMethods + assert.Equal(t, header.DefaultAccessControlAllowMethods, c.CORSMethods()) + c.Options().CORSMethods = "" + assert.Equal(t, "", c.CORSMethods()) +} + func TestConfig_ContentUri(t *testing.T) { c := NewConfig(CliTestContext()) diff --git a/internal/config/flags.go b/internal/config/flags.go index 6795922c2..88a4972b5 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -384,16 +384,6 @@ var Flags = CliFlags{ Value: "", EnvVar: EnvVar("WALLPAPER_URI"), }}, { - Flag: cli.StringFlag{ - Name: "cdn-url", - Usage: "content delivery network `URL`", - EnvVar: EnvVar("CDN_URL"), - }}, { - Flag: cli.BoolFlag{ - Name: "cdn-video", - Usage: "stream videos over the specified CDN", - EnvVar: EnvVar("CDN_VIDEO"), - }}, { Flag: cli.StringFlag{ Name: "site-url, url", Usage: "public site `URL`", @@ -427,6 +417,34 @@ var Flags = CliFlags{ Usage: "sharing preview image `URL`", EnvVar: EnvVar("SITE_PREVIEW"), }}, { + Flag: cli.StringFlag{ + Name: "cdn-url", + Usage: "content delivery network `URL`", + EnvVar: EnvVar("CDN_URL"), + }}, { + Flag: cli.BoolFlag{ + Name: "cdn-video", + Usage: "stream videos over the specified CDN", + EnvVar: EnvVar("CDN_VIDEO"), + }}, { + Flag: cli.StringFlag{ + Name: "cors-origin", + Usage: "origin `URL` from which browsers are allowed to perform cross-origin requests (leave empty to disable or use * to allow all)", + EnvVar: EnvVar("CORS_ORIGIN"), + Value: header.DefaultAccessControlAllowOrigin, + }}, { + Flag: cli.StringFlag{ + Name: "cors-headers", + Usage: "one or more `HEADERS` that browsers should see when performing a cross-origin request", + EnvVar: EnvVar("CORS_HEADERS"), + Value: header.DefaultAccessControlAllowHeaders, + }}, { + Flag: cli.StringFlag{ + Name: "cors-methods", + Usage: "one or more `METHODS` that may be used when performing a cross-origin request", + EnvVar: EnvVar("CORS_METHODS"), + Value: header.DefaultAccessControlAllowMethods, + }}, { Flag: cli.StringFlag{ Name: "https-proxy", Usage: "proxy server `URL` to be used for outgoing connections *optional*", @@ -491,11 +509,6 @@ var Flags = CliFlags{ Usage: "Web server compression `METHOD` (gzip, none)", EnvVar: EnvVar("HTTP_COMPRESSION"), }}, { - Flag: cli.BoolFlag{ - Name: "http-cors", - Usage: "allow Cross-Origin Resource Sharing (CORS)", - EnvVar: EnvVar("HTTP_CORS"), - }}, { Flag: cli.BoolFlag{ Name: "http-cache-public", Usage: "allow static content to be cached by a CDN or caching proxy", diff --git a/internal/config/options.go b/internal/config/options.go index 95a1f2481..3bec5fb85 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -93,14 +93,17 @@ type Options struct { LegalInfo string `yaml:"LegalInfo" json:"LegalInfo" flag:"legal-info"` LegalUrl string `yaml:"LegalUrl" json:"LegalUrl" flag:"legal-url"` WallpaperUri string `yaml:"WallpaperUri" json:"WallpaperUri" flag:"wallpaper-uri"` - CdnUrl string `yaml:"CdnUrl" json:"CdnUrl" flag:"cdn-url"` - CdnVideo bool `yaml:"CdnVideo" json:"CdnVideo" flag:"cdn-video"` SiteUrl string `yaml:"SiteUrl" json:"SiteUrl" flag:"site-url"` SiteAuthor string `yaml:"SiteAuthor" json:"SiteAuthor" flag:"site-author"` SiteTitle string `yaml:"SiteTitle" json:"SiteTitle" flag:"site-title"` SiteCaption string `yaml:"SiteCaption" json:"SiteCaption" flag:"site-caption"` SiteDescription string `yaml:"SiteDescription" json:"SiteDescription" flag:"site-description"` SitePreview string `yaml:"SitePreview" json:"SitePreview" flag:"site-preview"` + CdnUrl string `yaml:"CdnUrl" json:"CdnUrl" flag:"cdn-url"` + CdnVideo bool `yaml:"CdnVideo" json:"CdnVideo" flag:"cdn-video"` + CORSOrigin string `yaml:"CORSOrigin" json:"-" flag:"cors-origin"` + CORSHeaders string `yaml:"CORSHeaders" json:"-" flag:"cors-headers"` + CORSMethods string `yaml:"CORSMethods" json:"-" flag:"cors-methods"` HttpsProxy string `yaml:"HttpsProxy" json:"HttpsProxy" flag:"https-proxy"` HttpsProxyInsecure bool `yaml:"HttpsProxyInsecure" json:"HttpsProxyInsecure" flag:"https-proxy-insecure"` TrustedProxies []string `yaml:"TrustedProxies" json:"-" flag:"trusted-proxy"` @@ -113,7 +116,6 @@ type Options struct { TLSKey string `yaml:"TLSKey" json:"TLSKey" flag:"tls-key"` HttpMode string `yaml:"HttpMode" json:"-" flag:"http-mode"` HttpCompression string `yaml:"HttpCompression" json:"-" flag:"http-compression"` - HttpCORS bool `yaml:"HttpCORS" json:"-" flag:"http-cors"` HttpCachePublic bool `yaml:"HttpCachePublic" json:"HttpCachePublic" flag:"http-cache-public"` HttpCacheMaxAge int `yaml:"HttpCacheMaxAge" json:"HttpCacheMaxAge" flag:"http-cache-maxage"` HttpVideoMaxAge int `yaml:"HttpVideoMaxAge" json:"HttpVideoMaxAge" flag:"http-video-maxage"` diff --git a/internal/config/report.go b/internal/config/report.go index f4470b35a..0ff9564ff 100644 --- a/internal/config/report.go +++ b/internal/config/report.go @@ -127,8 +127,6 @@ func (c *Config) Report() (rows [][]string, cols []string) { {"wallpaper-uri", c.WallpaperUri()}, // Site Infos. - {"cdn-url", c.CdnUrl("/")}, - {"cdn-video", fmt.Sprintf("%t", c.CdnVideo())}, {"site-url", c.SiteUrl()}, {"site-https", fmt.Sprintf("%t", c.SiteHttps())}, {"site-domain", c.SiteDomain()}, @@ -138,6 +136,13 @@ func (c *Config) Report() (rows [][]string, cols []string) { {"site-description", c.SiteDescription()}, {"site-preview", c.SitePreview()}, + // CDN and Cross-Origin Resource Sharing (CORS). + {"cdn-url", c.CdnUrl("/")}, + {"cdn-video", fmt.Sprintf("%t", c.CdnVideo())}, + {"cors-origin", c.CORSOrigin()}, + {"cors-headers", c.CORSHeaders()}, + {"cors-methods", c.CORSMethods()}, + // URIs. {"base-uri", c.BaseUri("/")}, {"api-uri", c.ApiUri()}, @@ -160,7 +165,6 @@ func (c *Config) Report() (rows [][]string, cols []string) { {"tls-key", c.TLSKey()}, {"http-mode", c.HttpMode()}, {"http-compression", c.HttpCompression()}, - {"http-cors", fmt.Sprintf("%t", c.HttpCORS())}, {"http-cache-public", fmt.Sprintf("%t", c.HttpCachePublic())}, {"http-cache-maxage", fmt.Sprintf("%d", c.HttpCacheMaxAge())}, {"http-video-maxage", fmt.Sprintf("%d", c.HttpVideoMaxAge())}, diff --git a/internal/server/security.go b/internal/server/security.go index bc07e2c96..bd65a22bf 100644 --- a/internal/server/security.go +++ b/internal/server/security.go @@ -1,25 +1,28 @@ package server import ( + "net/http" + "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/config" - "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/header" ) // Security adds common HTTP security headers to the response. var Security = func(conf *config.Config) gin.HandlerFunc { return func(c *gin.Context) { - // Allow Cross-Origin Resource Sharing (CORS)? - // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin#cors_and_caching - if conf.HttpCORS() { - c.Header(header.AccessControlAllowOrigin, header.Any) - } else if origin := c.GetHeader(header.Origin); origin != "" { - // Automatically set the "Access-Control-Allow-Origin" response header - // based on the "Origin" value of the request. - c.Header(header.AccessControlAllowOrigin, clean.Header(origin)) - c.Header(header.Vary, header.Origin) + // If permitted, set CORS headers (Cross-Origin Resource Sharing). + // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin + if origin := conf.CORSOrigin(); origin != "" { + c.Header(header.AccessControlAllowOrigin, origin) + + // Add additional information to preflight OPTION requests. + if c.Request.Method == http.MethodOptions { + c.Header(header.AccessControlAllowHeaders, conf.CORSHeaders()) + c.Header(header.AccessControlAllowMethods, conf.CORSMethods()) + c.Header(header.AccessControlMaxAge, header.DefaultAccessControlMaxAge) + } } // Set Content Security Policy. diff --git a/pkg/clean/header.go b/pkg/clean/header.go index 867e1809a..cfbd11494 100644 --- a/pkg/clean/header.go +++ b/pkg/clean/header.go @@ -9,7 +9,7 @@ func Header(s string) string { result := make([]rune, 0, len(s)) for _, r := range s { - if r > 32 && r < 127 { + if r > 31 && r < 127 { result = append(result, r) } } diff --git a/pkg/clean/header_test.go b/pkg/clean/header_test.go index 6cf73b9ce..5ae68d2ea 100644 --- a/pkg/clean/header_test.go +++ b/pkg/clean/header_test.go @@ -18,7 +18,7 @@ func TestHeader(t *testing.T) { }) t.Run("Emoji", func(t *testing.T) { result := Header("Hello 👍") - assert.Equal(t, "Hello", result) + assert.Equal(t, "Hello ", result) }) } diff --git a/pkg/header/cors.go b/pkg/header/cors.go index b5ba0eca4..3c2ab6098 100644 --- a/pkg/header/cors.go +++ b/pkg/header/cors.go @@ -1,8 +1,19 @@ package header +// Cross-Origin Resource Sharing (CORS) headers. const ( AccessControlAllowOrigin = "Access-Control-Allow-Origin" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin AccessControlAllowCredentials = "Access-Control-Allow-Credentials" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials AccessControlAllowHeaders = "Access-Control-Allow-Headers" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers AccessControlAllowMethods = "Access-Control-Allow-Methods" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods + AccessControlMaxAge = "Access-Control-Max-Age" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age +) + +// CORS header defaults. +var ( + DefaultAccessControlAllowOrigin = "" + DefaultAccessControlAllowCredentials = "" + DefaultAccessControlAllowHeaders = "Origin, Accept, Accept-Ranges, Content-Range" + DefaultAccessControlAllowMethods = "GET, HEAD, OPTIONS" + DefaultAccessControlMaxAge = "3600" ) diff --git a/pkg/header/default.go b/pkg/header/default.go deleted file mode 100644 index 3d751c8d4..000000000 --- a/pkg/header/default.go +++ /dev/null @@ -1,6 +0,0 @@ -package header - -var ( - DefaultContentSecurityPolicy = "frame-ancestors 'none';" - DefaultFrameOptions = Deny -) diff --git a/pkg/header/security.go b/pkg/header/security.go index 977de9b0d..f2acb0da3 100644 --- a/pkg/header/security.go +++ b/pkg/header/security.go @@ -1,5 +1,6 @@ package header +// HTTP/HTTPS security headers. const ( StrictTransportSecurity = "Strict-Transport-Security" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security ContentSecurityPolicy = "Content-Security-Policy" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy @@ -10,3 +11,9 @@ const ( FrameOptions = "X-Frame-Options" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options ForwardedProto = "X-Forwarded-Proto" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto ) + +// Security header defaults. +var ( + DefaultContentSecurityPolicy = "frame-ancestors 'none';" + DefaultFrameOptions = Deny +)