In addition, the Access-Control-Allow-Origin header is set to the same URL if an Origin header is found in the request (experimental). Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
e44262d4ea
commit
c5f6a28448
18 changed files with 212 additions and 38 deletions
|
@ -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.
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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())},
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
34
pkg/clean/ascii_test.go
Normal file
34
pkg/clean/ascii_test.go
Normal file
|
@ -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("")
|
||||
}
|
||||
}
|
18
pkg/clean/header.go
Normal file
18
pkg/clean/header.go
Normal file
|
@ -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)
|
||||
}
|
35
pkg/clean/header_test.go
Normal file
35
pkg/clean/header_test.go
Normal file
|
@ -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("")
|
||||
}
|
||||
}
|
|
@ -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 ""
|
||||
}
|
||||
|
||||
|
|
|
@ -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("")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
8
pkg/header/cors.go
Normal file
8
pkg/header/cors.go
Normal file
|
@ -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
|
||||
)
|
|
@ -2,5 +2,5 @@ package header
|
|||
|
||||
var (
|
||||
DefaultContentSecurityPolicy = "frame-ancestors 'none';"
|
||||
DefaultFrameOptions = "DENY"
|
||||
DefaultFrameOptions = Deny
|
||||
)
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
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
|
||||
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
|
||||
|
|
6
pkg/header/values.go
Normal file
6
pkg/header/values.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package header
|
||||
|
||||
const (
|
||||
Any = "*"
|
||||
Deny = "DENY"
|
||||
)
|
6
pkg/header/vary.go
Normal file
6
pkg/header/vary.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package header
|
||||
|
||||
const (
|
||||
Vary = "Vary"
|
||||
Origin = "Origin"
|
||||
)
|
Loading…
Reference in a new issue