Config: Allow CORS for fonts and CSS when using a CDN #3931

see https://www.w3.org/TR/css-fonts-3/#font-fetching-requirements

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2024-01-16 20:04:36 +01:00
parent c478025513
commit abfea6354c
13 changed files with 192 additions and 52 deletions

View file

@ -444,6 +444,20 @@ func (c *Config) CdnUrl(res string) string {
return strings.TrimRight(c.options.CdnUrl, "/") + res
}
// UseCdn checks if a Content Deliver Network (CDN) is used to serve static content.
func (c *Config) UseCdn() bool {
if c.options.CdnUrl == "" || c.options.CdnUrl == c.options.SiteUrl {
return false
}
return true
}
// NoCdn checks if there is no Content Deliver Network (CDN) configured to serve static content.
func (c *Config) NoCdn() bool {
return !c.UseCdn()
}
// CdnDomain returns the content delivery network domain name if specified.
func (c *Config) CdnDomain() string {
if c.options.CdnUrl == "" || c.options.CdnUrl == c.options.SiteUrl {

View file

@ -423,15 +423,23 @@ func TestConfig_CdnUrl(t *testing.T) {
assert.Equal(t, "", c.options.SiteUrl)
assert.Equal(t, "", c.CdnUrl(""))
assert.True(t, c.NoCdn())
assert.False(t, c.UseCdn())
c.options.SiteUrl = "http://superhost:2342/"
assert.Equal(t, "/", c.CdnUrl("/"))
c.options.CdnUrl = "http://foo:2342/foo/"
assert.Equal(t, "http://foo:2342/foo", c.CdnUrl(""))
assert.Equal(t, "http://foo:2342/foo/", c.CdnUrl("/"))
assert.False(t, c.NoCdn())
assert.True(t, c.UseCdn())
c.options.SiteUrl = c.options.CdnUrl
assert.Equal(t, "/", c.CdnUrl("/"))
assert.Equal(t, "", c.CdnUrl(""))
assert.True(t, c.NoCdn())
assert.False(t, c.UseCdn())
c.options.SiteUrl = ""
assert.False(t, c.NoCdn())
assert.True(t, c.UseCdn())
}
func TestConfig_CdnDomain(t *testing.T) {

31
internal/server/api.go Normal file
View file

@ -0,0 +1,31 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/header"
)
// Api is a middleware that sets additional response headers when serving REST API requests.
var Api = func(conf *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Set vary response header.
c.Header(header.Vary, header.DefaultVary)
// 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)
}
}
}
}

View file

@ -16,6 +16,7 @@ func registerStaticRoutes(router *gin.Engine, conf *config.Config) {
login := func(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
}
router.Any(conf.BaseUri("/"), login)
// Shows "Page Not found" error if no other handler is registered.
@ -38,11 +39,19 @@ func registerStaticRoutes(router *gin.Engine, conf *config.Config) {
router.StaticFile(conf.BaseUri("/favicon.ico"), filepath.Join(conf.ImgPath(), "favicon.ico"))
// Serves static assets like js, css and font files.
router.Static(conf.BaseUri(config.StaticUri), conf.StaticPath())
if dir := conf.StaticPath(); dir != "" {
group := router.Group(conf.BaseUri(config.StaticUri), Static(conf))
{
group.Static("", dir)
}
}
// Serves custom static assets if folder exists.
if dir := conf.CustomStaticPath(); dir != "" {
router.Static(conf.BaseUri(config.CustomStaticUri), dir)
group := router.Group(conf.BaseUri(config.CustomStaticUri), Static(conf))
{
group.Static("", dir)
}
}
// Rainbow Page.

View file

@ -9,31 +9,15 @@ import (
"github.com/photoprism/photoprism/pkg/header"
)
// Security adds common HTTP security headers to the response.
// Security is a middleware that adds security-related headers to the server's response.
var Security = func(conf *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Only allow CDNs to cache responses of GET, HEAD, and OPTIONS requests and block the request otherwise.
if header.BlockCdn(c.Request) {
// Abort if the request should not be served through a CDN.
if header.AbortCdnRequest(c.Request) {
c.AbortWithStatus(http.StatusNotFound)
return
}
// Set vary header.
c.Header(header.Vary, header.DefaultVary)
// 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.
// See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
c.Header(header.ContentSecurityPolicy, header.DefaultContentSecurityPolicy)

View file

@ -45,7 +45,7 @@ func Start(ctx context.Context, conf *config.Config) {
router.Use(Recovery(), Security(conf), Logger())
// Create REST API router group.
APIv1 = router.Group(conf.BaseUri(config.ApiUri))
APIv1 = router.Group(conf.BaseUri(config.ApiUri), Api(conf))
// Initialize package extensions.
Ext().Init(router, conf)

32
internal/server/static.go Normal file
View file

@ -0,0 +1,32 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/header"
)
// Static is a middleware that adds static content-related headers to the server's response.
var Static = func(conf *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Allow CORS based on the configuration and automatically for eot, ttf, woff, woff2 and css files with a CDN.
// See: https://www.w3.org/TR/css-fonts-3/#font-fetching-requirements
if origin := conf.CORSOrigin(); origin != "" || header.AllowCORS(c.Request.URL.Path) && conf.UseCdn() {
if origin == "" {
c.Header(header.AccessControlAllowOrigin, header.Any)
} else {
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)
}
}
}
}

View file

@ -64,11 +64,8 @@ func WebDAVAuth(conf *config.Config) gin.HandlerFunc {
return
}
// Block CDNs from caching this endpoint.
if header.IsCdn(c.Request) {
c.AbortWithStatus(http.StatusNotFound)
return
}
// Set vary response header.
c.Header(header.Vary, header.DefaultVary)
// Get basic authentication credentials, if any.
username, password, cacheKey, authorized := basicAuth(c)

View file

@ -15,6 +15,10 @@ const (
CdnConnectionID = "Cdn-Connectionid"
)
var (
CdnMethods = []string{http.MethodGet, http.MethodHead, http.MethodOptions}
)
// IsCdn checks whether the request seems to come from a CDN.
func IsCdn(req *http.Request) bool {
if req == nil {
@ -30,15 +34,15 @@ func IsCdn(req *http.Request) bool {
return false
}
// BlockCdn checks whether the request should be blocked for CDNs.
func BlockCdn(req *http.Request) bool {
// AbortCdnRequest checks if the request should not be served through a CDN.
func AbortCdnRequest(req *http.Request) bool {
if !IsCdn(req) {
return false
}
if req.URL.Path == "/" {
} else if req.Header.Get(XAuthToken) != "" {
return true
} else if req.URL.Path == "/" {
return true
}
return list.Excludes(SafeMethods, req.Method)
return list.Excludes(CdnMethods, req.Method)
}

View file

@ -44,7 +44,7 @@ func TestIsCdn(t *testing.T) {
})
}
func TestBlockCdn(t *testing.T) {
func TestAbortCdnRequest(t *testing.T) {
t.Run("Allow", func(t *testing.T) {
u, _ := url.Parse("/foo")
@ -54,7 +54,7 @@ func TestBlockCdn(t *testing.T) {
Method: http.MethodGet,
}
assert.False(t, BlockCdn(r))
assert.False(t, AbortCdnRequest(r))
})
t.Run("UnsafeMethod", func(t *testing.T) {
u, _ := url.Parse("/foo")
@ -65,7 +65,7 @@ func TestBlockCdn(t *testing.T) {
Method: http.MethodPost,
}
assert.True(t, BlockCdn(r))
assert.True(t, AbortCdnRequest(r))
})
t.Run("Root", func(t *testing.T) {
u, _ := url.Parse("/")
@ -76,7 +76,7 @@ func TestBlockCdn(t *testing.T) {
Method: http.MethodGet,
}
assert.True(t, BlockCdn(r))
assert.True(t, AbortCdnRequest(r))
})
t.Run("NoCdn", func(t *testing.T) {
u, _ := url.Parse("/foo")
@ -87,6 +87,6 @@ func TestBlockCdn(t *testing.T) {
Method: http.MethodPost,
}
assert.False(t, BlockCdn(r))
assert.False(t, AbortCdnRequest(r))
})
}

View file

@ -2,7 +2,7 @@ package header
import "strings"
// Content header names.
// Standard content request and response header names.
const (
Accept = "Accept"
AcceptEncoding = "Accept-Encoding"
@ -18,7 +18,10 @@ const (
Vary = "Vary"
)
// Content header defaults.
// Vary response header defaults.
//
// Requests that include a standard authorization header should be automatically excluded
// from public caches: https://datatracker.ietf.org/doc/html/rfc7234#section-3
var (
DefaultVaryHeaders = []string{XAuthToken, AcceptEncoding}
DefaultVary = strings.Join(DefaultVaryHeaders, ", ")

View file

@ -7,20 +7,53 @@ import (
// 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
AccessControlAllowOrigin = "Access-Control-Allow-Origin" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
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 = ""
SafeHeaders = []string{Accept, AcceptRanges, ContentDisposition, ContentEncoding, ContentRange, Location, Vary}
DefaultAccessControlAllowHeaders = strings.Join(SafeHeaders, ", ")
SafeMethods = []string{http.MethodGet, http.MethodHead, http.MethodOptions}
DefaultAccessControlAllowMethods = strings.Join(SafeMethods, ", ")
DefaultAccessControlMaxAge = "3600"
DefaultAccessControlAllowOrigin = ""
CorsHeaders = []string{Accept, AcceptRanges, ContentDisposition, ContentEncoding, ContentRange, Location}
DefaultAccessControlAllowHeaders = strings.Join(CorsHeaders, ", ")
CorsMethods = []string{http.MethodGet, http.MethodHead, http.MethodOptions}
DefaultAccessControlAllowMethods = strings.Join(CorsMethods, ", ")
DefaultAccessControlMaxAge = "3600"
CorsExt = map[string]bool{".eot": true, ".ttf": true, ".woff": true, ".woff2": true, ".css": true}
)
// AllowCORS checks if CORS headers can be safely used based on a request's file path.
// See: https://www.w3.org/TR/css-fonts-3/#font-fetching-requirements
func AllowCORS(path string) bool {
// Return false if path is empty.
if path == "" {
return false
}
// Extract extension from path.
var ext string
l := len(path) - 1
for i := l; i >= 0 && path[i] != '/'; i-- {
if path[i] == '.' {
ext = path[i:]
if l-len(ext) < 0 {
// Return false if there is no filename.
return false
} else if r := path[i-1]; (r < '0' || r > '9') && (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') {
// Return false if the filename is invalid.
return false
}
break
}
}
// Return false if path does not include an extension.
if ext == "" {
return false
}
// Check list of allowed extensions.
return CorsExt[ext]
}

25
pkg/header/cors_test.go Normal file
View file

@ -0,0 +1,25 @@
package header
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestAllowCORS(t *testing.T) {
t.Run("CSS", func(t *testing.T) {
assert.False(t, AllowCORS(""))
assert.False(t, AllowCORS("."))
assert.False(t, AllowCORS(" "))
assert.False(t, AllowCORS(".css"))
assert.False(t, AllowCORS(" .css"))
assert.True(t, AllowCORS("a.css"))
assert.True(t, AllowCORS("static/files/styles.css"))
assert.True(t, AllowCORS("/static/files/styles.css"))
assert.True(t, AllowCORS("/static/files/a.css"))
assert.False(t, AllowCORS("/static/files/styles/.css"))
assert.False(t, AllowCORS("/.css"))
assert.False(t, AllowCORS(".css"))
assert.False(t, AllowCORS("css"))
})
}