diff --git a/internal/api/api_auth.go b/internal/api/api_auth.go index cbfbd9ad8..8b4448469 100644 --- a/internal/api/api_auth.go +++ b/internal/api/api_auth.go @@ -18,6 +18,11 @@ func Auth(c *gin.Context, resource acl.Resource, grant acl.Permission) *entity.S // AuthAny checks if the user is authorized to access a resource with any of the specified permissions // and returns the session or nil otherwise. func AuthAny(c *gin.Context, resource acl.Resource, grants acl.Permissions) (s *entity.Session) { + // Prevent CDNs from caching responses that require authentication. + if header.IsCdn(c.Request) { + return entity.SessionStatusForbidden() + } + // Get client IP and auth token from the request headers. clientIp := ClientIP(c) authToken := AuthToken(c) diff --git a/internal/api/session_create.go b/internal/api/session_create.go index 0eb15d5da..d33e58439 100644 --- a/internal/api/session_create.go +++ b/internal/api/session_create.go @@ -23,10 +23,17 @@ func CreateSession(router *gin.RouterGroup) { // Disable caching of responses. c.Header(header.CacheControl, header.CacheControlNoStore) + // Prevent CDNs from caching this endpoint. + if header.IsCdn(c.Request) { + c.AbortWithStatus(http.StatusNotFound) + return + } + var f form.Login clientIp := ClientIP(c) + // Validate request data. if err := c.BindJSON(&f); err != nil { event.AuditWarn([]string{clientIp, "create session", "invalid request", "%s"}, err) AbortBadRequest(c) diff --git a/internal/api/session_delete.go b/internal/api/session_delete.go index 94a9f6112..159b00ac7 100644 --- a/internal/api/session_delete.go +++ b/internal/api/session_delete.go @@ -10,7 +10,6 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/get" "github.com/photoprism/photoprism/internal/i18n" - "github.com/photoprism/photoprism/internal/server/limiter" "github.com/photoprism/photoprism/internal/session" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/header" @@ -27,67 +26,59 @@ func DeleteSession(router *gin.RouterGroup) { // Disable caching of responses. c.Header(header.CacheControl, header.CacheControlNoStore) + // Prevent CDNs from caching this endpoint. + if header.IsCdn(c.Request) { + c.AbortWithStatus(http.StatusNotFound) + return + } + // Abort if running in public mode. if get.Config().Public() { - // Return JSON response for confirmation. c.JSON(http.StatusOK, DeleteSessionResponse(session.PublicID)) return } + // Check if the session user is allowed to manage all accounts or update his/her own account. + s := AuthAny(c, acl.ResourceSessions, acl.Permissions{acl.ActionManage, acl.ActionDelete}) + + if s.Abort(c) { + return + } + id := clean.ID(c.Param("id")) // Get client IP and auth token from request headers. clientIp := ClientIP(c) - authToken := AuthToken(c) - - // Fail if authentication error rate limit is exceeded. - if clientIp != "" && limiter.Auth.Reject(clientIp) { - limiter.AbortJSON(c) - return - } - - // Find session based on auth token. - sess, err := entity.FindSession(rnd.SessionID(authToken)) - - if err != nil || sess == nil { - if clientIp != "" { - limiter.Auth.Reserve(clientIp) - } - Abort(c, http.StatusUnauthorized, i18n.ErrUnauthorized) - return - } else if sess.Abort(c) { - return - } // Only admins may delete other sessions by ref id. if rnd.IsRefID(id) { - if !acl.Resources.AllowAll(acl.ResourceSessions, sess.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) { - event.AuditErr([]string{clientIp, "session %s", "delete %s as %s", "denied"}, sess.RefID, acl.ResourceSessions.String(), sess.User().AclRole()) + if !acl.Resources.AllowAll(acl.ResourceSessions, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) { + event.AuditErr([]string{clientIp, "session %s", "delete %s as %s", "denied"}, s.RefID, acl.ResourceSessions.String(), s.User().AclRole()) Abort(c, http.StatusForbidden, i18n.ErrForbidden) return } - event.AuditInfo([]string{clientIp, "session %s", "delete %s as %s", "granted"}, sess.RefID, acl.ResourceSessions.String(), sess.User().AclRole()) + event.AuditInfo([]string{clientIp, "session %s", "delete %s as %s", "granted"}, s.RefID, acl.ResourceSessions.String(), s.User().AclRole()) - if sess = entity.FindSessionByRefID(id); sess == nil { + if s = entity.FindSessionByRefID(id); s == nil { Abort(c, http.StatusNotFound, i18n.ErrNotFound) return } - } else if id != "" && sess.ID != id { - event.AuditWarn([]string{clientIp, "session %s", "delete %s as %s", "ids do not match"}, sess.RefID, acl.ResourceSessions.String(), sess.User().AclRole()) + } else if id != "" && s.ID != id { + event.AuditWarn([]string{clientIp, "session %s", "delete %s as %s", "ids do not match"}, s.RefID, acl.ResourceSessions.String(), s.User().AclRole()) Abort(c, http.StatusForbidden, i18n.ErrForbidden) return } // Delete session cache and database record. - if err = sess.Delete(); err != nil { - event.AuditErr([]string{clientIp, "session %s", "delete session as %s", "%s"}, sess.RefID, sess.User().AclRole(), err) + if err := s.Delete(); err != nil { + event.AuditErr([]string{clientIp, "session %s", "delete session as %s", "%s"}, s.RefID, s.User().AclRole(), err) } else { - event.AuditDebug([]string{clientIp, "session %s", "deleted"}, sess.RefID) + event.AuditDebug([]string{clientIp, "session %s", "deleted"}, s.RefID) } // Return JSON response for confirmation. - c.JSON(http.StatusOK, DeleteSessionResponse(sess.ID)) + c.JSON(http.StatusOK, DeleteSessionResponse(s.ID)) } router.DELETE("/session", deleteSessionHandler) diff --git a/internal/api/session_get.go b/internal/api/session_get.go index 80da17df8..e2285f3e5 100644 --- a/internal/api/session_get.go +++ b/internal/api/session_get.go @@ -5,9 +5,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/get" - "github.com/photoprism/photoprism/internal/server/limiter" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/header" "github.com/photoprism/photoprism/pkg/rnd" @@ -23,54 +22,45 @@ func GetSession(router *gin.RouterGroup) { // Disable caching of responses. c.Header(header.CacheControl, header.CacheControlNoStore) + // Prevent CDNs from caching this endpoint. + if header.IsCdn(c.Request) { + c.AbortWithStatus(http.StatusNotFound) + return + } + id := clean.ID(c.Param("id")) - // Abort if session id is provided but invalid. if id != "" && !rnd.IsSessionID(id) { + // Abort if session id is provided but invalid. AbortBadRequest(c) return } conf := get.Config() - // Get client IP and auth token from request headers. - clientIp := ClientIP(c) - authToken := AuthToken(c) - - // Skip authentication if app is running in public mode. - var sess *entity.Session - if conf.Public() { - sess = get.Session().Public() - id = sess.ID - authToken = sess.AuthToken() - } else if clientIp != "" && limiter.Auth.Reject(clientIp) { - // Fail if authentication error rate limit is exceeded. - limiter.AbortJSON(c) - return - } else { - sess = Session(clientIp, authToken) - } + // Check if the session user is allowed to manage all accounts or update his/her own account. + s := AuthAny(c, acl.ResourceSessions, acl.Permissions{acl.ActionManage, acl.ActionView}) + // Check if session is valid. switch { - case sess == nil: - if clientIp != "" { - limiter.Auth.Reserve(clientIp) - } + case s.Abort(c): + return + case s.Expired(), s.ID == "": AbortUnauthorized(c) return - case sess.Expired(), sess.ID == "": - AbortUnauthorized(c) - return - case sess.Invalid(), id != "" && sess.ID != id && !conf.Public(): + case s.Invalid(), id != "" && s.ID != id && !conf.Public(): AbortForbidden(c) return } + // Get auth token from headers. + authToken := AuthToken(c) + // Update user information. - sess.RefreshUser() + s.RefreshUser() // Response includes user data, session data, and client config values. - response := GetSessionResponse(authToken, sess, get.Config().ClientSession(sess)) + response := GetSessionResponse(authToken, s, get.Config().ClientSession(s)) // Return JSON response. c.JSON(http.StatusOK, response) diff --git a/internal/api/session_oauth.go b/internal/api/session_oauth.go index 5b5f9eb54..c841c8c02 100644 --- a/internal/api/session_oauth.go +++ b/internal/api/session_oauth.go @@ -28,13 +28,19 @@ func CreateOAuthToken(router *gin.RouterGroup) { // Disable caching of responses. c.Header(header.CacheControl, header.CacheControlNoStore) + // Prevent CDNs from caching this endpoint. + if header.IsCdn(c.Request) { + c.AbortWithStatus(http.StatusNotFound) + return + } + // Get client IP address for logs and rate limiting checks. clientIp := ClientIP(c) - // Abort if running in public mode. if get.Config().Public() { + // Abort if running in public mode. event.AuditErr([]string{clientIp, "create client session", "disabled in public mode"}) - Abort(c, http.StatusForbidden, i18n.ErrForbidden) + AbortForbidden(c) return } @@ -131,6 +137,12 @@ func RevokeOAuthToken(router *gin.RouterGroup) { // Disable caching of responses. c.Header(header.CacheControl, header.CacheControlNoStore) + // Prevent CDNs from caching this endpoint. + if header.IsCdn(c.Request) { + c.AbortWithStatus(http.StatusNotFound) + return + } + // Get client IP address for logs and rate limiting checks. clientIp := ClientIP(c) diff --git a/internal/server/routes_pwa.go b/internal/server/routes_pwa.go index e8af8768c..86032389e 100644 --- a/internal/server/routes_pwa.go +++ b/internal/server/routes_pwa.go @@ -14,6 +14,12 @@ import ( func registerPWARoutes(router *gin.Engine, conf *config.Config) { // Loads Progressive Web App (PWA) on all routes beginning with "library". pwa := func(c *gin.Context) { + // Prevent CDNs from caching this endpoint. + if header.IsCdn(c.Request) { + c.AbortWithStatus(http.StatusNotFound) + return + } + values := gin.H{ "signUp": gin.H{"message": config.MsgSponsor, "url": config.SignUpURL}, "config": conf.ClientPublic(), diff --git a/internal/server/security.go b/internal/server/security.go index bd65a22bf..60c7d41d6 100644 --- a/internal/server/security.go +++ b/internal/server/security.go @@ -12,6 +12,12 @@ import ( // Security adds common HTTP security headers to the 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) { + c.AbortWithStatus(http.StatusNotFound) + return + } + // 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 != "" { diff --git a/internal/server/webdav_auth.go b/internal/server/webdav_auth.go index 3b79e1d90..76ff4825b 100644 --- a/internal/server/webdav_auth.go +++ b/internal/server/webdav_auth.go @@ -64,6 +64,12 @@ func WebDAVAuth(conf *config.Config) gin.HandlerFunc { return } + // Block CDNs from caching this endpoint. + if header.IsCdn(c.Request) { + c.AbortWithStatus(http.StatusNotFound) + return + } + // Get basic authentication credentials, if any. username, password, cacheKey, authorized := basicAuth(c) diff --git a/pkg/header/cdn.go b/pkg/header/cdn.go new file mode 100644 index 000000000..3060416f0 --- /dev/null +++ b/pkg/header/cdn.go @@ -0,0 +1,44 @@ +package header + +import ( + "net/http" + + "github.com/photoprism/photoprism/pkg/list" +) + +// Content Delivery Network (CDN) headers. +const ( + CdnHost = "Cdn-Host" + CdnMobileDevice = "Cdn-Mobiledevice" + CdnServerZone = "Cdn-Serverzone" + CdnServerID = "Cdn-Serverid" + CdnConnectionID = "Cdn-Connectionid" +) + +// IsCdn checks whether the request seems to come from a CDN. +func IsCdn(req *http.Request) bool { + if req == nil { + return false + } else if req.Header == nil || req.URL == nil { + return false + } + + if req.Header.Get(CdnHost) != "" { + return true + } + + return false +} + +// BlockCdn checks whether the request should be blocked for CDNs. +func BlockCdn(req *http.Request) bool { + if !IsCdn(req) { + return false + } + + if req.URL.Path == "/" { + return true + } + + return list.Excludes(SafeMethods, req.Method) +} diff --git a/pkg/header/cdn_test.go b/pkg/header/cdn_test.go new file mode 100644 index 000000000..605f456a7 --- /dev/null +++ b/pkg/header/cdn_test.go @@ -0,0 +1,92 @@ +package header + +import ( + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsCdn(t *testing.T) { + t.Run("Header", func(t *testing.T) { + u, _ := url.Parse("/foo") + + r := &http.Request{ + URL: u, + Header: http.Header{CdnHost: []string{"host.cdn.com"}}, + Method: http.MethodGet, + } + + assert.True(t, IsCdn(r)) + }) + t.Run("EmptyHeader", func(t *testing.T) { + u, _ := url.Parse("/foo") + + r := &http.Request{ + URL: u, + Header: http.Header{CdnHost: []string{""}}, + Method: http.MethodPost, + } + + assert.False(t, IsCdn(r)) + }) + t.Run("NoHeader", func(t *testing.T) { + u, _ := url.Parse("/foo") + + r := &http.Request{ + URL: u, + Header: http.Header{Accept: []string{"application/json"}}, + Method: http.MethodPost, + } + + assert.False(t, IsCdn(r)) + }) +} + +func TestBlockCdn(t *testing.T) { + t.Run("Allow", func(t *testing.T) { + u, _ := url.Parse("/foo") + + r := &http.Request{ + URL: u, + Header: http.Header{CdnHost: []string{"host.cdn.com"}}, + Method: http.MethodGet, + } + + assert.False(t, BlockCdn(r)) + }) + t.Run("UnsafeMethod", func(t *testing.T) { + u, _ := url.Parse("/foo") + + r := &http.Request{ + URL: u, + Header: http.Header{CdnHost: []string{"host.cdn.com"}}, + Method: http.MethodPost, + } + + assert.True(t, BlockCdn(r)) + }) + t.Run("Root", func(t *testing.T) { + u, _ := url.Parse("/") + + r := &http.Request{ + URL: u, + Header: http.Header{CdnHost: []string{"host.cdn.com"}}, + Method: http.MethodGet, + } + + assert.True(t, BlockCdn(r)) + }) + t.Run("NoCdn", func(t *testing.T) { + u, _ := url.Parse("/foo") + + r := &http.Request{ + URL: u, + Header: http.Header{Accept: []string{"application/json"}}, + Method: http.MethodPost, + } + + assert.False(t, BlockCdn(r)) + }) +}