Config: Add options to configure CORS origin, headers and methods #3931

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2024-01-16 12:14:06 +01:00
parent 4e7a61ffe5
commit 239708f00f
13 changed files with 122 additions and 61 deletions

View file

@ -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())

View file

@ -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 {

View file

@ -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())

View file

@ -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())

View file

@ -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",

View file

@ -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"`

View file

@ -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())},

View file

@ -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.

View file

@ -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)
}
}

View file

@ -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)
})
}

View file

@ -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"
)

View file

@ -1,6 +0,0 @@
package header
var (
DefaultContentSecurityPolicy = "frame-ancestors 'none';"
DefaultFrameOptions = Deny
)

View file

@ -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
)