API: Only allow CDNs to cache GET, HEAD, and OPTIONS requests #3931

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2024-01-16 16:17:16 +01:00
parent e5aa76730f
commit c3b9b73d1d
10 changed files with 223 additions and 64 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 != "" {

View file

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

44
pkg/header/cdn.go Normal file
View file

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

92
pkg/header/cdn_test.go Normal file
View file

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