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:
Florian Sylvain committed 2026-10-22 00:49:19 +02:00
1 parent e2812ee0b9
commit ea98c5ec05
118 files changed
+3828 -130

No files matched your search

+8 -3
View File
@@ -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),
+5
View File
@@ -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
}
+2
View File
@@ -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)
}
+15 -3
View File
@@ -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
}
+36
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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'; " +
+2
View File
@@ -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."
+58 -3
View File
@@ -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
}
+2 -2
View File
@@ -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 ""
}
+1
View File
@@ -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)