fix: security & privacy (rate limiter fix, csrf session cookie, logout on server, drafts stale token passw, login timing, password over 72b, JWT_SECRET complexity, SMTP TLS, CSV export neutralised, fonts bundled, upload metadata, hourly cleanup job, privacy.md report)
This commit is contained in:
1 parent
e2812ee0b9
commit
ea98c5ec05
118 files changed
+3828
-130
No files matched your search
@@ -12,6 +12,7 @@ import (
|
||||
domainInvite "RenewCMS/internal/domain/invite"
|
||||
domainMail "RenewCMS/internal/domain/mail"
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
domainSession "RenewCMS/internal/domain/session"
|
||||
domainSite "RenewCMS/internal/domain/site"
|
||||
domainTransaction "RenewCMS/internal/domain/transaction"
|
||||
domainUpdate "RenewCMS/internal/domain/update"
|
||||
@@ -46,6 +47,7 @@ type UseCases struct {
|
||||
Token *authtoken.Auth
|
||||
PurgeScheduler *scheduler.Runner
|
||||
ActivityCleanupScheduler *scheduler.Runner
|
||||
RetentionScheduler *scheduler.Runner
|
||||
CreateArticleUseCase *useCases.CreateArticleUseCase
|
||||
GetArticleUseCase *useCases.GetArticleUseCase
|
||||
ListArticlesUseCase *useCases.ListArticlesUseCase
|
||||
@@ -139,7 +141,7 @@ type UseCases struct {
|
||||
|
||||
func (c *UseCases) RunSchedulers(ctx context.Context) {
|
||||
var running sync.WaitGroup
|
||||
for _, runner := range []*scheduler.Runner{c.PurgeScheduler, c.ActivityCleanupScheduler} {
|
||||
for _, runner := range []*scheduler.Runner{c.PurgeScheduler, c.ActivityCleanupScheduler, c.RetentionScheduler} {
|
||||
if runner == nil {
|
||||
continue
|
||||
}
|
||||
@@ -168,6 +170,7 @@ type Repositories struct {
|
||||
VariantRenderer domainImage.VariantRenderer
|
||||
InviteRepo domainInvite.Repository
|
||||
APIKeyRepo domainAPIKey.Repository
|
||||
SessionRepo domainSession.Repository
|
||||
PurgeSettingsRepo domainPurge.Repository
|
||||
AuditRepo domainAudit.Repository
|
||||
AuditSettingsRepo domainAudit.SettingsRepository
|
||||
@@ -311,6 +314,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
|
||||
VariantRenderer: imaging.NewVariantRenderer(),
|
||||
InviteRepo: persistence.NewInviteRepository(db),
|
||||
APIKeyRepo: persistence.NewAPIKeyRepository(db),
|
||||
SessionRepo: persistence.NewRevokedSessionRepository(db),
|
||||
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
|
||||
AuditRepo: persistence.NewAuditEventRepository(db),
|
||||
AuditSettingsRepo: persistence.NewAuditSettingsRepository(db),
|
||||
@@ -346,7 +350,8 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
return &UseCases{
|
||||
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
|
||||
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
|
||||
Token: authtoken.New([]byte(settings.JWTSecret)),
|
||||
RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()),
|
||||
Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now))),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
|
||||
@@ -395,7 +400,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
|
||||
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder),
|
||||
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder, imaging.NewMetadataStripper()),
|
||||
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
|
||||
|
||||
@@ -506,6 +506,11 @@ func isVerifiedCaller(r *http.Request, getUser *useCases.GetUserUseCase) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
tokenVersion, ok := claims.TokenVersion(r)
|
||||
if !ok || tokenVersion != currentUser.TokenVersion {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentUser.IsVerified
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ func writeAcceptInviteError(w http.ResponseWriter, err error) {
|
||||
http.Error(w, apperr.UsernameTakenErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, invite.ErrEmailHasAccount):
|
||||
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, user.ErrPasswordTooLong):
|
||||
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
@@ -41,12 +41,13 @@ func NewLoginHandler(token *authtoken.Auth, create *useCases.CreateUserUseCase,
|
||||
|
||||
func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "jwt",
|
||||
Name: authtoken.CookieName,
|
||||
Value: "",
|
||||
Expires: time.Now(),
|
||||
MaxAge: -1,
|
||||
Secure: isProduction,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
}
|
||||
@@ -57,11 +58,12 @@ func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction boo
|
||||
return err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "jwt",
|
||||
Name: authtoken.CookieName,
|
||||
Value: tokenString,
|
||||
Expires: time.Now().Add(authtoken.SessionLifetime),
|
||||
Secure: isProduction,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
return nil
|
||||
@@ -122,6 +124,10 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, apperr.RegistrationClosedErrorMessage, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, user.ErrPasswordTooLong) {
|
||||
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -139,8 +145,14 @@ func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request
|
||||
}
|
||||
|
||||
func (h *LoginHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
h.AuthenticateUseCase.Logout(r.Context())
|
||||
removeJwtCookie(w, h.IsProduction)
|
||||
if claims, err := authtoken.FromContext(r.Context()); err == nil {
|
||||
if err := h.Token.Revoke(claims); err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
h.AuthenticateUseCase.Logout(r.Context())
|
||||
message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"})
|
||||
_, _ = w.Write(message)
|
||||
}
|
||||
@@ -72,6 +72,10 @@ func (h *PasswordResetHandler) ResetPassword(w http.ResponseWriter, r *http.Requ
|
||||
http.Error(w, apperr.PasswordResetFailedErrorMessage, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, user.ErrPasswordTooLong) {
|
||||
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func CrossOriginProtection(publicURL string) func(http.Handler) http.Handler {
|
||||
protection := http.NewCrossOriginProtection()
|
||||
if origin, ok := originOf(publicURL); ok {
|
||||
_ = protection.AddTrustedOrigin(origin)
|
||||
}
|
||||
protection.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, apperr.CrossOriginErrorMessage, http.StatusForbidden)
|
||||
}))
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
protected := protection.Handler(next)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
protected.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func originOf(rawURL string) (string, bool) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", false
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host, true
|
||||
}
|
||||
+34
-12
@@ -14,8 +14,19 @@ type rateLimitWindow struct {
|
||||
count int
|
||||
}
|
||||
|
||||
type rateLimitCounters struct {
|
||||
mu sync.Mutex
|
||||
window time.Duration
|
||||
windows map[string]*rateLimitWindow
|
||||
lastSweep time.Time
|
||||
}
|
||||
|
||||
func NewRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
|
||||
return newKeyedRateLimiter(maxAttempts, window, func(r *http.Request) string { return r.RemoteAddr })
|
||||
return newKeyedRateLimiter(maxAttempts, window, connectionHostKey)
|
||||
}
|
||||
|
||||
func connectionHostKey(r *http.Request) string {
|
||||
return hostOf(r.RemoteAddr)
|
||||
}
|
||||
|
||||
func NewUserRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
|
||||
@@ -28,12 +39,11 @@ func userRateLimitKey(r *http.Request) string {
|
||||
}
|
||||
|
||||
func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http.Request) string) func(http.Handler) http.Handler {
|
||||
var mu sync.Mutex
|
||||
windows := make(map[string]*rateLimitWindow)
|
||||
counters := &rateLimitCounters{window: window, windows: make(map[string]*rateLimitWindow)}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
retryAfter, allowed := recordAttempt(&mu, windows, keyOf(r), maxAttempts, window)
|
||||
retryAfter, allowed := counters.recordAttempt(keyOf(r), maxAttempts, time.Now())
|
||||
if !allowed {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
|
||||
@@ -44,24 +54,36 @@ func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http
|
||||
}
|
||||
}
|
||||
|
||||
func recordAttempt(mu *sync.Mutex, windows map[string]*rateLimitWindow, key string, maxAttempts int, window time.Duration) (int, bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
func (c *rateLimitCounters) recordAttempt(key string, maxAttempts int, now time.Time) (int, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
current, exists := windows[key]
|
||||
if !exists || now.Sub(current.start) >= window {
|
||||
c.sweepExpired(now)
|
||||
current, exists := c.windows[key]
|
||||
if !exists || now.Sub(current.start) >= c.window {
|
||||
current = &rateLimitWindow{start: now}
|
||||
windows[key] = current
|
||||
c.windows[key] = current
|
||||
}
|
||||
|
||||
current.count++
|
||||
if current.count > maxAttempts {
|
||||
return retryAfterSeconds(window - now.Sub(current.start)), false
|
||||
return retryAfterSeconds(c.window - now.Sub(current.start)), false
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
func (c *rateLimitCounters) sweepExpired(now time.Time) {
|
||||
if now.Sub(c.lastSweep) < c.window {
|
||||
return
|
||||
}
|
||||
c.lastSweep = now
|
||||
for key, current := range c.windows {
|
||||
if now.Sub(current.start) >= c.window {
|
||||
delete(c.windows, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func retryAfterSeconds(remaining time.Duration) int {
|
||||
seconds := int(remaining / time.Second)
|
||||
if remaining%time.Second > 0 {
|
||||
|
||||
@@ -4,8 +4,8 @@ import "net/http"
|
||||
|
||||
const contentSecurityPolicy = "default-src 'self'; " +
|
||||
"script-src 'self' 'wasm-unsafe-eval'; " +
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
||||
"font-src 'self' https://fonts.gstatic.com; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"font-src 'self'; " +
|
||||
"img-src 'self' data: blob: https:; " +
|
||||
"connect-src 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
|
||||
@@ -16,6 +16,7 @@ const VerificationRequiredErrorMessage = "You must verify your account to access
|
||||
const PasswordResetRequestedMessage = "If an account exists for that email, a password reset link has been sent."
|
||||
const PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired."
|
||||
const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
|
||||
const CrossOriginErrorMessage = "Cross-origin requests are not allowed."
|
||||
const EmailHasAccountErrorMessage = "This address already has an account."
|
||||
const PendingInviteErrorMessage = "There's already a pending invite for this address."
|
||||
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
|
||||
@@ -25,6 +26,7 @@ const InviteInvalidErrorMessage = "This invite link doesn't work."
|
||||
const InviteExpiredErrorMessage = "This invite has expired."
|
||||
const OwnerRequiredErrorMessage = "Only the owner can do this."
|
||||
const UsernameTakenErrorMessage = "That username is taken. Try another."
|
||||
const PasswordTooLongErrorMessage = "The password must be 72 bytes or fewer."
|
||||
const MediaQueryErrorMessage = "The media query is invalid."
|
||||
const MediaFilenameErrorMessage = "Enter a filename."
|
||||
const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again."
|
||||
|
||||
@@ -2,6 +2,7 @@ package authtoken
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -26,8 +27,14 @@ var (
|
||||
ErrInvalid = errors.New("token is invalid")
|
||||
ErrExpired = errors.New("token is expired")
|
||||
ErrNotYetValid = errors.New("token is not yet valid")
|
||||
ErrRevoked = errors.New("token is revoked")
|
||||
)
|
||||
|
||||
type RevocationStore interface {
|
||||
Revoke(id string, expiresAt time.Time) error
|
||||
IsRevoked(id string) (bool, error)
|
||||
}
|
||||
|
||||
type Claims map[string]any
|
||||
|
||||
type header struct {
|
||||
@@ -36,8 +43,9 @@ type header struct {
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
secret []byte
|
||||
clock func() time.Time
|
||||
secret []byte
|
||||
clock func() time.Time
|
||||
revocations RevocationStore
|
||||
}
|
||||
|
||||
type Option func(*Auth)
|
||||
@@ -46,6 +54,10 @@ func WithClock(clock func() time.Time) Option {
|
||||
return func(a *Auth) { a.clock = clock }
|
||||
}
|
||||
|
||||
func WithRevocationStore(store RevocationStore) Option {
|
||||
return func(a *Auth) { a.revocations = store }
|
||||
}
|
||||
|
||||
func New(secret []byte, options ...Option) *Auth {
|
||||
auth := &Auth{secret: secret, clock: time.Now}
|
||||
for _, option := range options {
|
||||
@@ -66,6 +78,9 @@ func (a *Auth) Encode(claims map[string]any) (string, error) {
|
||||
if _, ok := payload["exp"]; !ok {
|
||||
payload["exp"] = now.Add(SessionLifetime).Unix()
|
||||
}
|
||||
if _, ok := payload["jti"]; !ok {
|
||||
payload["jti"] = rand.Text()
|
||||
}
|
||||
|
||||
headerSegment, err := encodeSegment(header{Algorithm: algorithmHS256, Type: tokenType})
|
||||
if err != nil {
|
||||
@@ -111,9 +126,46 @@ func (a *Auth) Decode(token string) (Claims, error) {
|
||||
if err := a.validateTimes(claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.checkRevocation(claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (a *Auth) Revoke(claims Claims) error {
|
||||
id, ok := claims["jti"].(string)
|
||||
if !ok || a.revocations == nil {
|
||||
return nil
|
||||
}
|
||||
expiry, present, err := numericDate(claims, "exp")
|
||||
if err != nil || !present {
|
||||
return ErrInvalid
|
||||
}
|
||||
return a.revocations.Revoke(id, expiry)
|
||||
}
|
||||
|
||||
func (a *Auth) checkRevocation(claims Claims) error {
|
||||
raw, present := claims["jti"]
|
||||
if !present {
|
||||
return nil
|
||||
}
|
||||
id, ok := raw.(string)
|
||||
if !ok {
|
||||
return ErrInvalid
|
||||
}
|
||||
if a.revocations == nil {
|
||||
return nil
|
||||
}
|
||||
revoked, err := a.revocations.IsRevoked(id)
|
||||
if err != nil {
|
||||
return errors.Join(ErrInvalid, err)
|
||||
}
|
||||
if revoked {
|
||||
return ErrRevoked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Auth) validateTimes(claims Claims) error {
|
||||
now := a.clock()
|
||||
|
||||
@@ -121,7 +173,10 @@ func (a *Auth) validateTimes(claims Claims) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if present && !now.Before(expiry) {
|
||||
if !present {
|
||||
return ErrInvalid
|
||||
}
|
||||
if !now.Before(expiry) {
|
||||
return ErrExpired
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
cookieName = "jwt"
|
||||
CookieName = "jwt"
|
||||
bearerPrefix = "bearer "
|
||||
)
|
||||
|
||||
@@ -71,7 +71,7 @@ func tokenFromHeader(r *http.Request) string {
|
||||
}
|
||||
|
||||
func tokenFromCookie(r *http.Request) string {
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
cookie, err := r.Cookie(CookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -490,6 +490,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
apiRouter := chi.NewRouter()
|
||||
apiRouter.Use(middleware.SecurityHeaders(settings.IsProduction()))
|
||||
enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins)
|
||||
apiRouter.Use(middleware.CrossOriginProtection(settings.PublicURL))
|
||||
apiRouter.Use(chimiddleware.GetHead)
|
||||
apiRouter.Get("/healthz", GetHealth)
|
||||
apiRouter.Mount("/v1", backend)
|
||||
|
||||
Reference in new issue
Block a user