diff --git a/internal/config/config_server.go b/internal/config/config_server.go index 4d1e30408..9e028144d 100644 --- a/internal/config/config_server.go +++ b/internal/config/config_server.go @@ -83,6 +83,21 @@ 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 { + return true + } + + return c.options.CdnUrl != "" +} + // HttpCacheMaxAge returns the time in seconds until cached content expires. func (c *Config) HttpCacheMaxAge() ttl.Duration { // Return default cache maxage? @@ -109,15 +124,6 @@ func (c *Config) HttpVideoMaxAge() ttl.Duration { return ttl.Duration(c.options.HttpVideoMaxAge) } -// HttpCachePublic checks whether static content may be cached by a CDN or caching proxy. -func (c *Config) HttpCachePublic() bool { - if c.options.HttpCachePublic { - return true - } - - return c.options.CdnUrl != "" -} - // HttpHost returns the built-in HTTP server host name or IP address (empty for all interfaces). func (c *Config) HttpHost() string { // when unix socket used as host, make host as default value. or http client will act weirdly. diff --git a/internal/config/config_server_test.go b/internal/config/config_server_test.go index fc8363142..fd87ad706 100644 --- a/internal/config/config_server_test.go +++ b/internal/config/config_server_test.go @@ -62,6 +62,36 @@ 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()) + + assert.False(t, c.HttpCachePublic()) + c.Options().CdnUrl = "https://cdn.com/" + assert.True(t, c.HttpCachePublic()) + c.Options().CdnUrl = "" + assert.False(t, c.HttpCachePublic()) + c.Options().HttpCachePublic = true + assert.True(t, c.HttpCachePublic()) + c.Options().HttpCachePublic = false + assert.False(t, c.HttpCachePublic()) +} + func TestConfig_HttpCacheMaxAge(t *testing.T) { c := NewConfig(CliTestContext()) @@ -85,17 +115,3 @@ func TestConfig_HttpVideoMaxAge(t *testing.T) { c.Options().HttpVideoMaxAge = 0 assert.Equal(t, ttl.CacheVideo, c.HttpVideoMaxAge()) } - -func TestConfig_HttpCachePublic(t *testing.T) { - c := NewConfig(CliTestContext()) - - assert.False(t, c.HttpCachePublic()) - c.Options().CdnUrl = "https://cdn.com/" - assert.True(t, c.HttpCachePublic()) - c.Options().CdnUrl = "" - assert.False(t, c.HttpCachePublic()) - c.Options().HttpCachePublic = true - assert.True(t, c.HttpCachePublic()) - c.Options().HttpCachePublic = false - assert.False(t, c.HttpCachePublic()) -} diff --git a/internal/config/flags.go b/internal/config/flags.go index 44589bf2e..6795922c2 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -491,6 +491,11 @@ 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 62cc3b5b1..95a1f2481 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -113,6 +113,7 @@ 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 544c5df78..f4470b35a 100644 --- a/internal/config/report.go +++ b/internal/config/report.go @@ -160,6 +160,7 @@ 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 eda77cd68..bc07e2c96 100644 --- a/internal/server/security.go +++ b/internal/server/security.go @@ -4,13 +4,30 @@ import ( "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) { - c.Writer.Header().Set(header.ContentSecurityPolicy, header.DefaultContentSecurityPolicy) - c.Writer.Header().Set(header.FrameOptions, header.DefaultFrameOptions) + // 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) + } + + // Set Content Security Policy. + // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + c.Header(header.ContentSecurityPolicy, header.DefaultContentSecurityPolicy) + + // Set Frame Options. + // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options + c.Header(header.FrameOptions, header.DefaultFrameOptions) } } diff --git a/pkg/clean/ascii.go b/pkg/clean/ascii.go index f0550a9da..e7fe44841 100644 --- a/pkg/clean/ascii.go +++ b/pkg/clean/ascii.go @@ -2,6 +2,10 @@ package clean // ASCII removes all non-ascii characters from a string and returns it. func ASCII(s string) string { + if s == "" { + return "" + } + result := make([]rune, 0, len(s)) for _, r := range s { diff --git a/pkg/clean/ascii_test.go b/pkg/clean/ascii_test.go new file mode 100644 index 000000000..be8502faf --- /dev/null +++ b/pkg/clean/ascii_test.go @@ -0,0 +1,34 @@ +package clean + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestASCII(t *testing.T) { + t.Run("URL", func(t *testing.T) { + result := ASCII("https://docs.photoprism.app/getting-started/config-options/#file-converters") + assert.Equal(t, "https://docs.photoprism.app/getting-started/config-options/#file-converters", result) + }) + t.Run("Emoji", func(t *testing.T) { + result := ASCII("Hello 👍") + assert.Equal(t, "Hello ", result) + }) + t.Run("EmojiURL", func(t *testing.T) { + result := ASCII("https://docs.photoprism.app/getting-started 👍/config-options/#file-converters") + assert.Equal(t, "https://docs.photoprism.app/getting-started /config-options/#file-converters", result) + }) +} + +func BenchmarkASCII(b *testing.B) { + for n := 0; n < b.N; n++ { + ASCII("https://docs.photoprism.app/getting-started 👍/config-options/#file-converters") + } +} + +func BenchmarkASCIIEmpty(b *testing.B) { + for n := 0; n < b.N; n++ { + ASCII("") + } +} diff --git a/pkg/clean/header.go b/pkg/clean/header.go new file mode 100644 index 000000000..867e1809a --- /dev/null +++ b/pkg/clean/header.go @@ -0,0 +1,18 @@ +package clean + +// Header sanitizes a string for use in request or response headers. +func Header(s string) string { + if s == "" || len(s) > MaxLength { + return "" + } + + result := make([]rune, 0, len(s)) + + for _, r := range s { + if r > 32 && r < 127 { + result = append(result, r) + } + } + + return string(result) +} diff --git a/pkg/clean/header_test.go b/pkg/clean/header_test.go new file mode 100644 index 000000000..6cf73b9ce --- /dev/null +++ b/pkg/clean/header_test.go @@ -0,0 +1,35 @@ +package clean + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHeader(t *testing.T) { + t.Run("Ok", func(t *testing.T) { + expected := "https://docs.photoprism.app/getting-started/config-options/#file-converters" + result := Header(expected) + assert.Equal(t, expected, result) + }) + t.Run("Tabs", func(t *testing.T) { + result := Header("https://..docs.photoprism.app/gettin\\g-started/config-options/\tfile-converters") + assert.Equal(t, "https://..docs.photoprism.app/gettin\\g-started/config-options/file-converters", result) + }) + t.Run("Emoji", func(t *testing.T) { + result := Header("Hello 👍") + assert.Equal(t, "Hello", result) + }) +} + +func BenchmarkHeader(b *testing.B) { + for n := 0; n < b.N; n++ { + Header("https://..docs.photoprism.app/gettin\\g-started/config-options/\tfile-converters") + } +} + +func BenchmarkHeaderEmpty(b *testing.B) { + for n := 0; n < b.N; n++ { + Header("") + } +} diff --git a/pkg/clean/uri.go b/pkg/clean/uri.go index baeecdd9a..5db00e9e5 100644 --- a/pkg/clean/uri.go +++ b/pkg/clean/uri.go @@ -7,7 +7,9 @@ import ( // Uri removes invalid character from an uri string. func Uri(s string) string { - if s == "" || reject(s, 512) || strings.Contains(s, "..") { + if s == "" || len(s) > MaxLength { + return "" + } else if strings.Contains(s, "..") { return "" } diff --git a/pkg/clean/uri_test.go b/pkg/clean/uri_test.go index 1f5ef039a..1d1306b7b 100644 --- a/pkg/clean/uri_test.go +++ b/pkg/clean/uri_test.go @@ -15,4 +15,20 @@ func TestUri(t *testing.T) { result := Uri("https://..docs.photoprism.app/gettin\\g-started/config-options/\tfile-converters") assert.Equal(t, "", result) }) + t.Run("Emoji", func(t *testing.T) { + result := Uri("Hello 👍") + assert.Equal(t, "Hello%20%F0%9F%91%8D", result) + }) +} + +func BenchmarkUri(b *testing.B) { + for n := 0; n < b.N; n++ { + Uri("https://docs.photoprism.app/getting-started/config-options/#file-converters") + } +} + +func BenchmarkUriEmpty(b *testing.B) { + for n := 0; n < b.N; n++ { + Uri("") + } } diff --git a/pkg/header/auth.go b/pkg/header/auth.go index cf47defbc..311b1f230 100644 --- a/pkg/header/auth.go +++ b/pkg/header/auth.go @@ -12,11 +12,11 @@ import ( ) const ( - XAuthToken = "X-Auth-Token" - XSessionID = "X-Session-ID" Auth = "Authorization" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization AuthBasic = "Basic" AuthBearer = "Bearer" + XAuthToken = "X-Auth-Token" + XSessionID = "X-Session-ID" ) // AuthToken returns the client authentication token from the request context, diff --git a/pkg/header/cors.go b/pkg/header/cors.go new file mode 100644 index 000000000..b5ba0eca4 --- /dev/null +++ b/pkg/header/cors.go @@ -0,0 +1,8 @@ +package header + +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 +) diff --git a/pkg/header/default.go b/pkg/header/default.go index f9182d9de..3d751c8d4 100644 --- a/pkg/header/default.go +++ b/pkg/header/default.go @@ -2,5 +2,5 @@ package header var ( DefaultContentSecurityPolicy = "frame-ancestors 'none';" - DefaultFrameOptions = "DENY" + DefaultFrameOptions = Deny ) diff --git a/pkg/header/security.go b/pkg/header/security.go index bcc0b1d34..977de9b0d 100644 --- a/pkg/header/security.go +++ b/pkg/header/security.go @@ -1,13 +1,12 @@ package header const ( - AccessControlAllowOrigin = "Access-Control-Allow-Origin" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin - ContentSecurityPolicy = "Content-Security-Policy" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy - StrictTransportSecurity = "Strict-Transport-Security" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security - CrossOriginOpenerPolicy = "Cross-Origin-Opener-Policy" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy - ReferrerPolicy = "Referrer-Policy" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy - ContentTypeOptions = "X-Content-Type-Options" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options - XSSProtection = "X-XSS-Protection" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection - 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 + 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 + CrossOriginOpenerPolicy = "Cross-Origin-Opener-Policy" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy + ReferrerPolicy = "Referrer-Policy" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy + ContentTypeOptions = "X-Content-Type-Options" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options + XSSProtection = "X-XSS-Protection" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection + 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 ) diff --git a/pkg/header/values.go b/pkg/header/values.go new file mode 100644 index 000000000..6916da7f6 --- /dev/null +++ b/pkg/header/values.go @@ -0,0 +1,6 @@ +package header + +const ( + Any = "*" + Deny = "DENY" +) diff --git a/pkg/header/vary.go b/pkg/header/vary.go new file mode 100644 index 000000000..d53495c1c --- /dev/null +++ b/pkg/header/vary.go @@ -0,0 +1,6 @@ +package header + +const ( + Vary = "Vary" + Origin = "Origin" +)