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:
parent
c478025513
commit
abfea6354c
13 changed files with 192 additions and 52 deletions
|
@ -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 {
|
||||
|
|
|
@ -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
31
internal/server/api.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
32
internal/server/static.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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, ", ")
|
||||
|
|
|
@ -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
25
pkg/header/cors_test.go
Normal 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"))
|
||||
})
|
||||
}
|
Loading…
Reference in a new issue