diff --git a/.env.example b/.env.example index 1ca7057..ec29bc8 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ CORS_ALLOWED_ORIGINS= AUTH_RATE_LIMIT_MAX_ATTEMPTS=5 AUTH_RATE_LIMIT_WINDOW_SECONDS=60 TRUSTED_PROXIES= +SMTP_ALLOW_INSECURE=false UPDATE_FEED_URL=https://git.floriansylvain.fr RENEWCMS_CONTAINER=false IMAGE_VARIANT_MAX_SOURCE_BYTES=10485760 diff --git a/README.md b/README.md index 9737db5..125b4e1 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ task dev # API with hot reload + Vite HMR → http://localhost:51 | [docs/deployment.md](docs/deployment.md) | Building release binaries, running as a service, HTTPS, backups, upgrades | | [docs/docker.md](docs/docker.md) | Running RenewCMS in Docker: image, Compose, data volume, backups, reverse proxy, tags, upgrades, publishing | | [docs/configuration.md](docs/configuration.md) | Every environment variable | +| [docs/privacy.md](docs/privacy.md) | Personal data stored, retention, cookies, outbound connections and GDPR requests | | [docs/architecture.md](docs/architecture.md) | Backend layering and how the SPA is embedded | | [docs/performance.md](docs/performance.md) | Measured costs, such as the `?seo=` article filter | | [docs/api.md](docs/api.md) | API reference: authentication, API keys, routes and the activity log | diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index b574208..18ca927 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -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), diff --git a/api/handlers/article/articleHandler.go b/api/handlers/article/articleHandler.go index 042cae1..f8f17a7 100644 --- a/api/handlers/article/articleHandler.go +++ b/api/handlers/article/articleHandler.go @@ -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 } diff --git a/api/handlers/auth/acceptInviteHandler.go b/api/handlers/auth/acceptInviteHandler.go index 79610c8..ce2f9ed 100644 --- a/api/handlers/auth/acceptInviteHandler.go +++ b/api/handlers/auth/acceptInviteHandler.go @@ -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) } diff --git a/api/handlers/auth/loginHandler.go b/api/handlers/auth/loginHandler.go index 4a5357a..bd41f12 100644 --- a/api/handlers/auth/loginHandler.go +++ b/api/handlers/auth/loginHandler.go @@ -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) } diff --git a/api/handlers/auth/passwordResetHandler.go b/api/handlers/auth/passwordResetHandler.go index 21471c2..3dd4111 100644 --- a/api/handlers/auth/passwordResetHandler.go +++ b/api/handlers/auth/passwordResetHandler.go @@ -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 } diff --git a/api/middleware/crossOrigin.go b/api/middleware/crossOrigin.go new file mode 100644 index 0000000..b0f15ff --- /dev/null +++ b/api/middleware/crossOrigin.go @@ -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 +} diff --git a/api/middleware/rateLimit.go b/api/middleware/rateLimit.go index af7cdd3..bff0589 100644 --- a/api/middleware/rateLimit.go +++ b/api/middleware/rateLimit.go @@ -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 { diff --git a/api/middleware/securityHeaders.go b/api/middleware/securityHeaders.go index 288262b..d9df385 100644 --- a/api/middleware/securityHeaders.go +++ b/api/middleware/securityHeaders.go @@ -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'; " + diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index fc86254..c19ee90 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -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." diff --git a/api/pkg/authtoken/authtoken.go b/api/pkg/authtoken/authtoken.go index 38de3df..20f1684 100644 --- a/api/pkg/authtoken/authtoken.go +++ b/api/pkg/authtoken/authtoken.go @@ -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 } diff --git a/api/pkg/authtoken/middleware.go b/api/pkg/authtoken/middleware.go index 3ffa525..ce50a2b 100644 --- a/api/pkg/authtoken/middleware.go +++ b/api/pkg/authtoken/middleware.go @@ -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 "" } diff --git a/api/router.go b/api/router.go index 7865566..c9d7b28 100644 --- a/api/router.go +++ b/api/router.go @@ -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) diff --git a/cmd/server/main.go b/cmd/server/main.go index 15f03b0..9bb636c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -41,27 +41,28 @@ var requiredEnvVars = []string{ } var envDefaults = map[string]string{ - "ENVIRONMENT": "production", - "PORT": "8080", - "DB_FILE": "./data/renewcms.db", - config.EnvDatabaseDriver: config.DefaultDatabaseDriver, - "DB_TLS": config.DefaultDatabaseTLS, - "DB_SSLMODE": config.DefaultDatabaseSSLMode, - "DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns, - "DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns, - "DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds, - "UPLOAD_DIR": "./data/uploads", - config.EnvStorageDriver: config.DefaultStorageDriver, - "S3_REGION": config.DefaultS3Region, - "S3_PATH_STYLE": config.DefaultS3PathStyle, - "S3_TLS": config.DefaultS3TLS, - "AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5", - "AUTH_RATE_LIMIT_WINDOW_SECONDS": "60", - "TRUSTED_PROXIES": "", - "UPDATE_FEED_URL": "https://git.floriansylvain.fr", - "RENEWCMS_CONTAINER": "false", - config.EnvImageVariantMaxSourceBytes: config.DefaultImageVariantMaxSourceBytes, - config.EnvImageVariantMaxSourcePixels: config.DefaultImageVariantMaxSourcePixels, + "ENVIRONMENT": "production", + "PORT": "8080", + "DB_FILE": "./data/renewcms.db", + config.EnvDatabaseDriver: config.DefaultDatabaseDriver, + "DB_TLS": config.DefaultDatabaseTLS, + "DB_SSLMODE": config.DefaultDatabaseSSLMode, + "DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns, + "DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns, + "DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds, + "UPLOAD_DIR": "./data/uploads", + config.EnvStorageDriver: config.DefaultStorageDriver, + "S3_REGION": config.DefaultS3Region, + "S3_PATH_STYLE": config.DefaultS3PathStyle, + "S3_TLS": config.DefaultS3TLS, + "AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5", + "AUTH_RATE_LIMIT_WINDOW_SECONDS": "60", + "TRUSTED_PROXIES": "", + config.EnvSmtpAllowInsecure: config.DefaultSmtpAllowInsecure, + "UPDATE_FEED_URL": "https://git.floriansylvain.fr", + "RENEWCMS_CONTAINER": "false", + config.EnvImageVariantMaxSourceBytes: config.DefaultImageVariantMaxSourceBytes, + config.EnvImageVariantMaxSourcePixels: config.DefaultImageVariantMaxSourcePixels, config.EnvImageVariantMaxConcurrentRenders: config.DefaultImageVariantMaxConcurrentRenders, } diff --git a/docs/api.md b/docs/api.md index 1310c2a..df34cc4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -21,6 +21,12 @@ for example `https://cms.example/v1/article/`. or revoked key answers 401, even on public routes. The site writes (`POST`, `PATCH` and `DELETE` under `/v1/site`) and the author writes (`POST`, `PATCH` and `DELETE` under `/v1/author`, `POST /v1/author/me` included) are session only too. - Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header. +- A session lasts 24 hours. `POST /v1/auth/logout` revokes it on the server, so a copied cookie + stops working at once; other sessions of the same user stay valid. A password reset ends every + session and revokes every key of that user. +- A browser `POST`, `PUT`, `PATCH` or `DELETE` sent from another origin without an + `Authorization` header answers 403 "Cross-origin requests are not allowed." This blocks + cross-site request forgery with the cookie; scripts on other origins must send their key. ## Routes a key can call (30) | Group | Routes | diff --git a/docs/architecture.md b/docs/architecture.md index 13185ab..f80b001 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,6 +55,14 @@ The use cases `GetPurgeSettings`, `UpdatePurgeSettings`, `PreviewPurge` and `Run | `GET /v1/media/purge-preview` | Groups, copies, distinct articles, the first 5 groups, `more` and the fingerprint. | | `POST /v1/media/purge` | Runs the purge. Optional `fingerprint`; 409 when stale or when a run is in progress. | +### Sessions and personal data retention + +A session is an HS256 token from `api/pkg/authtoken`. `Encode` adds a random `jti` and an `exp` 24 hours ahead; `Decode` refuses a token without `exp`. `authtoken.WithRevocationStore` plugs in `useCases.SessionRevocationUseCase`, which stores a `session.Revocation` (`jti`, `exp`) through the `internal/domain/session` `Repository` (`persistence.RevokedSessionRepository`, table `revoked_sessions`). `LoginHandler.Logout` revokes the presented token; from then on `Decode` answers `ErrRevoked` for it, and a store that cannot be read makes every token invalid rather than valid. Revoking every session of a user stays the job of `token_version`, bumped by a password reset. + +`useCases.EnforceRetentionUseCase.Tick(now)` deletes revocations past their expiry, unverified accounts 7 days after their code expired (`user.UnverifiedRetention`) and invites 30 days after they expired or were accepted (`invite.Retention`), and returns a `retention.Result`. `scheduler.NewSystemRetentionRunner` calls it one minute after startup and then hourly, logs one line only when something was removed, and stops with the other runners on shutdown. + +Uploads go through the `image.MetadataStripper` port before they are measured for size, hashed and stored. `imaging.MetadataStripper` walks the JPEG segments, PNG chunks and WebP RIFF chunks without re-encoding pixels, drops metadata, keeps colour profiles and re-adds an orientation-only EXIF block when the photo was rotated. A container it cannot walk fails the upload with `image.ErrImageUnreadable`. + ### Activity log `internal/domain/audit` holds the append-only `Event` (actor, target and failure-reason snapshots, outcome, security flag, source, ip, user agent, `params` and `changes`), the closed vocabularies (actor type, category, outcome, source, change kind), the `Filter`, `Summary` and `Settings` types, the `Repository` interface (`Append`, `List`, `Summary`, `DeleteOlderThan`: no way to edit or delete one event), the `SettingsRepository` (`Get`, `SaveRetention`, `SaveLastCleanup`; no row means 90 days) and the sentinels `ErrInvalidFilter`, `ErrInvalidRetention`, `ErrInvalidEvent` and `ErrActorNotAllowed`. The `Recorder` port is `Record(ctx, event)`; it returns nothing, so a caller can never fail on it. diff --git a/docs/configuration.md b/docs/configuration.md index b263f7c..279ab0e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,7 +11,7 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required | Name | Description | Example | |-----------------|---------------------------------------------------------------------------------------------------------------------|----------------------------| | `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. It is also the CMS's own address: at the first migration, when the sites table is empty, it seeds the default site (name "Default", key "default") with its scheme and host (path dropped, non-loopback `http` upgraded to `https`); an unparseable value stops the migration. It is never read for sites afterwards. | `https://cms.example.com` | -| `JWT_SECRET` | Secret used to sign the auth JWT. Use a long random value; changing it logs everyone out. | `openssl rand -hex 32` | +| `JWT_SECRET` | Secret used to sign the auth JWT. It must be at least 32 bytes, and the example values from `.env.example` and `docker.env.example` are refused: the server stops at startup with `JWT_SECRET is too weak` and how to generate one. Changing it logs everyone out. | `openssl rand -hex 32` | | `SMTP_LOGIN` | SMTP username used to authenticate with the server. Some providers (e.g. Brevo) issue a login distinct from the sender address. | `noreply@example.com` | | `SMTP_SENDER` | Sender address used in the `From` header. Must be an address/domain the SMTP provider allows that login to send as. | `noreply@example.com` | | `SMTP_PASSWORD` | SMTP password. | | @@ -32,6 +32,7 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required | `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. Matching is case-insensitive. An entry may contain one `*` (`https://*.preview.example.com`), and a lone `*` allows every origin. Credentials are never allowed cross-origin. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). | | `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. | | `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. | +| `SMTP_ALLOW_INSECURE` | `false` | `true` lets RenewCMS deliver mail without encryption to a server on another host that does not offer STARTTLS (a relay on a private network, a local Mailpit container). By default such delivery fails with an error naming this variable; port `465` always uses implicit TLS and `localhost`, `127.0.0.1` and `::1` are always allowed in clear text. Credentials (`SMTP_LOGIN`/`SMTP_PASSWORD`) are never sent unencrypted to another host, whatever this says. Any value other than `true` or `false` (case-insensitive) stops the server at boot. | | `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). | | `RENEWCMS_CONTAINER` | `false` | Set to `true` (case-insensitive; any other value means `false`) when the app runs in a container. Settings then tells the owner to update by pulling a new image instead of offering browser updates, and `can_apply` is always `false`. The Dockerfile and `docker-compose.yml` set it to `true`. Update checks still work. | | `IMAGE_VARIANT_MAX_SOURCE_BYTES` | `10485760` | Largest stored image, in bytes, that an [image variant](image-variants.md) may be generated from. A request for a bigger source answers 413 with a plain-text body. The original is never affected, and SVG and animated GIF are always served as they are. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. | diff --git a/docs/deployment.md b/docs/deployment.md index 1e66c65..99d93e8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -274,7 +274,7 @@ Either way, set `TRUSTED_PROXIES` (see [configuration.md](configuration.md)) to RenewCMS sends `Content-Security-Policy` (including `frame-ancestors 'none'`), `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` and `Cross-Origin-Opener-Policy` itself, so the proxy doesn't need to add them. With `ENVIRONMENT=production` it also sends `Strict-Transport-Security: max-age=31536000` (browsers ignore it over plain HTTP, so serve the site over HTTPS only). Don't add the same headers in the proxy, or they will be duplicated. -The CSP allows Google Fonts (`fonts.googleapis.com`, `fonts.gstatic.com`) and `https:` images, because the SPA loads its fonts from Google and articles may embed remote images. `style-src` keeps `'unsafe-inline'` for the article editor; `script-src` is `'self'` only. +The CSP serves fonts from `'self'` only (the SPA bundles its fonts, so no visitor IP address is sent to a font CDN) and allows `https:` images, because articles may embed remote images. `style-src` keeps `'unsafe-inline'` for the article editor; `script-src` is `'self'` only. ## Using MySQL or MariaDB diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..fe10885 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,77 @@ +# Privacy and personal data + +This page is for operators: whoever runs a RenewCMS instance is the data controller for it under the GDPR. It lists what the software stores about people, where, for how long, and what it sends elsewhere, so you can fill in your record of processing (art. 30), write your privacy notice (art. 13) and answer requests from the people concerned. It describes the software, not your obligations; it is not legal advice. + +## Who is concerned + +- **Back-office users**: the owner and the admins they invite. +- **Invitees** who have not accepted yet. +- **People named in content**: authors, and anyone your editors write about or photograph. +- **Anyone who performs an audited action** (logging in, failing to log in, changing anything) has their IP address and browser recorded in the activity log. + +Readers of your public site are not tracked by RenewCMS: the public routes set no cookie and log nothing in production. + +## What is stored, and for how long + +| Data | Where | Kept | +|---|---|---| +| Username, email, display name, role, bcrypt password hash | `users` table | Until the account is deleted (see [Gaps](#gaps)). | +| Verification and password-reset codes | `users` table, bcrypt-hashed | Verification: until the account is verified. Reset: one hour, then useless; cleared when used. | +| Unverified sign-ups | `users` table | Deleted automatically 7 days after their verification code expired. | +| Invites: invitee email, hashed code, inviter | `invites` table | Deleted automatically 30 days after they expired or were accepted. | +| Sessions | The `jwt` cookie in the user's browser, valid 24 hours | Logging out revokes the session on the server; the revocation (a random id and an expiry, nothing else) is deleted once the session would have expired anyway. | +| API keys: name, prefix, SHA-256 hash, creator, last use | `api_keys` table | Until revoked. A revoked key stays listed. | +| Activity log: who (user id and name, API key name, or the username typed at a failed login), what, when, IP address, browser (user agent, truncated to 255 characters) | `audit_events` table | Settings → Activity: 30, 90 (default), 365 days or forever, and an optional size cap. Cleaned up once a night, at 03:00 server time. | +| Authors: name, slug, bio, avatar, website, social links; the linked user's email is shown to admins | `authors` table | Until deleted. | +| Articles and custom entries | `articles`, `entries` tables | Deleted articles are soft-deleted: the row and its content stay in the database (see [Gaps](#gaps)). | +| Uploaded images | `UPLOAD_DIR` or the S3 bucket, plus the `images` table (file name, size, uploader) | Until deleted from the media library. Deleted images are soft-deleted in the table; the file itself is removed. | + +### Photo metadata + +Since this version, uploads are cleaned before they are stored. JPEG, PNG and WebP files lose their EXIF (GPS position, camera serial number, date), XMP, IPTC, comments and text chunks; only the orientation is kept, so rotated photos still display correctly. The pixels are not re-encoded. SVG files are stored as uploaded. **Images uploaded with an earlier version are not cleaned retroactively**: if they may carry GPS positions, download, clean and re-upload them, or merge them into a cleaned copy from the media library. + +### Activity log retention + +For security logs, the CNIL generally recommends keeping access logs between six months and a year. The default of 90 days is shorter; choose 365 days in Settings if that suits your purpose. Avoid "Forever": it keeps IP addresses indefinitely, which the storage-limitation principle rarely justifies. + +## Cookies and browser storage + +- `jwt`: the session cookie. HttpOnly, `SameSite=Lax`, `Secure` in production, 24 hours. Strictly necessary for the back office, so it needs no consent (ePrivacy art. 5(3); in France, Loi Informatique et Libertés art. 82). +- `localStorage` on the user's device holds interface choices only: language (`renewcms-language`), page sizes, open or collapsed panels, dismissed hints and columns. `sessionStorage` holds unsaved article drafts until the tab closes. None of it is sent to the server or to anyone else, and it needs no consent either. + +RenewCMS sets no analytics, advertising or third-party cookie, so no consent banner is needed for the back office. Your public site may need one if *it* adds trackers. + +## What leaves the server + +| Destination | When | What | +|---|---|---| +| Your SMTP provider (`SMTP_HOST`) | Sign-up, invite, password reset | Recipient address, the message (with a one-time link). STARTTLS or implicit TLS is required for any non-loopback host unless you set `SMTP_ALLOW_INSECURE=true`. | +| Your S3 provider, if `STORAGE_DRIVER=s3` | Every upload and image read | The image files. | +| `UPDATE_FEED_URL` (default `https://git.floriansylvain.fr`) | Only when the owner clicks "Check for updates" or downloads one | The server's IP address and a standard HTTP request. No account or content data. | + +The back office loads nothing from third parties: fonts, icons and scripts are served by RenewCMS itself, and the Content-Security-Policy forbids any other source. Pick EU-based SMTP and storage providers, or make sure transfer safeguards (art. 44 and following) cover them, and list them as processors. + +## Security measures you can cite (art. 32) + +- Passwords are hashed with bcrypt (cost 12). One-time codes are random UUIDs, stored hashed, single-use and short-lived. API keys are 256-bit random values stored as SHA-256 hashes. +- Login, password-reset and invite routes are rate-limited per client IP; failed logins are recorded as security events. +- Sessions are revocable: logout ends one session, a password reset ends all of them and revokes the user's API keys. +- Cross-site writes are refused, cookies are `SameSite=Lax`, and a strict Content-Security-Policy, `X-Frame-Options: DENY` and HSTS (in production) are sent. +- The server refuses to start with a short or example `JWT_SECRET`. +- Release binaries are signed (ed25519) and checked before a browser update is installed. + +## Answering requests from the people concerned + +- **Access and portability (art. 15 and 20)**: there is no self-service export yet. The data of a user is their row in `users`, their author profile, their API keys and the activity events where they are the actor; an owner can export the activity log as CSV from the Activity page and filter it by person. +- **Rectification (art. 16)**: users can change their display name and author profile themselves. Changing an email address currently needs a database edit. +- **Erasure (art. 17)**: an unverified account can cancel its own registration. Deleting a verified user has no button yet; see [Gaps](#gaps). + +## Gaps + +These are known and tracked; until they are fixed, handle them by hand. + +- No way to delete a verified user or remove an admin from the interface. +- No self-service export or email change. +- Soft-deleted articles and images keep their rows indefinitely. +- The username typed at a failed login is stored as typed, so a password typed in the wrong field can end up in the activity log; clean such events by hand if it happens. +- Images uploaded before metadata stripping keep their metadata. diff --git a/features/activity-log-backend.feature b/features/activity-log-backend.feature index b0b1264..782f519 100644 --- a/features/activity-log-backend.feature +++ b/features/activity-log-backend.feature @@ -358,6 +358,8 @@ Feature: The back-office keeps an append-only activity log that owners can read | target | +1+1 | '+1+1 | | target | -2 | '-2 | | actor | @SUM(A1) | '@SUM(A1) | + | target | \t=cmd | '\t=cmd | + | target | \r=cmd | '\r=cmd | | target | Spring release | Spring release | | target | a=b | a=b | diff --git a/features/hasher_domain_interface.feature b/features/hasher_domain_interface.feature index 4c5448f..ebe47c8 100644 --- a/features/hasher_domain_interface.feature +++ b/features/hasher_domain_interface.feature @@ -53,12 +53,14 @@ Feature: Password and code hashing go through a domain Hasher interface Then the result is the error user.ErrInvalidCredentials And errors.Is(err, user.ErrInvalidCredentials) is true - Scenario: Authenticating an unknown username never calls the hasher + Scenario: Authenticating an unknown username spends the same hashing work as a wrong password Given a user repository with no user "bob" - When AuthenticateUserUseCase.Authenticate is called with username "bob" and password + When AuthenticateUserUseCase.Authenticate is called twice with username "bob" and password "hunter2example" - Then the result is the error user.ErrUserNotFound - And the hasher's Compare is never called + Then each result is the error user.ErrUserNotFound + And the hasher's Compare is called once per attempt, against a decoy hash of a random value + that no password can match, so response time does not reveal whether a username exists + And the decoy is hashed once and reused for every later unknown username Scenario Outline: Login over HTTP still can't distinguish a bad password from an unknown username Given a user repository containing a user "alice" with stored password "hashed:hunter2example" diff --git a/features/hasher_domain_interface.qa.md b/features/hasher_domain_interface.qa.md index 24d0202..680d1e7 100644 --- a/features/hasher_domain_interface.qa.md +++ b/features/hasher_domain_interface.qa.md @@ -29,9 +29,10 @@ Package: `test` (black-box, alongside the existing `article_test.go` / `user_tes assert the returned user matches and `err` is `nil`. - `Authenticate("alice", "wrongpassword12")` with `fakeHasher.Compare` returning an error → assert `errors.Is(err, user.ErrInvalidCredentials)` is `true`. - - `Authenticate("bob", "hunter2example")` with no "bob" in the repo → assert - `errors.Is(err, user.ErrUserNotFound)` is `true` and `fakeHasher`'s `Compare` was never - invoked (record a `compareCalled` flag). + - `Authenticate("bob", "hunter2example")` twice with no "bob" in the repo → assert + `errors.Is(err, user.ErrUserNotFound)` is `true` each time, that `fakeHasher`'s `Compare` + ran once per attempt (against the decoy hash), and that `Hash` ran exactly once overall + (the decoy is computed lazily and reused). ## HTTP checks (real BcryptHasher, in-memory fake repo, mirroring api/router.go) 8. Call `validator.InitValidator()`. diff --git a/features/login_and_password_reset_rate_limiting.feature b/features/login_and_password_reset_rate_limiting.feature index 75c0c13..5d5f3d7 100644 --- a/features/login_and_password_reset_rate_limiting.feature +++ b/features/login_and_password_reset_rate_limiting.feature @@ -16,7 +16,8 @@ Feature: Login and password-reset requests are rate-limited per client IP (github.com/go-chi/chi/v5/middleware) runs first and rewrites r.RemoteAddr from X-Forwarded-For/X-Real-IP when present (matching the nginx reverse proxy setup in docs/deployment.md that already sets X-Forwarded-For), falling back to the raw RemoteAddr - otherwise; RateLimiter keys its counters on r.RemoteAddr + otherwise; RateLimiter keys its counters on the host portion of r.RemoteAddr (the source + port is ignored, so opening a new connection never yields a fresh counter) And RateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler keeps an in-memory, mutex-guarded fixed-window counter per (route, client IP) pair: the first request from a client opens a window starting now; each subsequent request from the same diff --git a/features/pending_screen.feature b/features/pending_screen.feature index b6e224f..455310c 100644 --- a/features/pending_screen.feature +++ b/features/pending_screen.feature @@ -26,8 +26,9 @@ Feature: Pending registration screen you'll log in there first. Can't find it? Check your spam folder. If the link has expired, cancel your registration and start again.") is rendered through ResultPanel's default slot as a second paragraph below the description, per components.md's "Slot default for extras" - And "Log out" calls POST /v1/auth/logout, which per loginHandler.Logout always responds 200 and - clears the jwt cookie; on its response, PendingPage calls session.clear() and navigates to + And "Log out" calls POST /v1/auth/logout, which per loginHandler.Logout clears the jwt cookie + and responds 200 (500 only when the session cannot be revoked server-side; the cookie is + cleared either way); on its response, PendingPage calls session.clear() and navigates to /login, disabling both actions while the call is in flight And "Cancel registration" opens ConfirmDialog (built by this slice; components.md documents it but no file exists yet) via useOverlay().create(ConfirmDialog).open(), with title "Cancel diff --git a/features/security_privacy_hardening.feature b/features/security_privacy_hardening.feature new file mode 100644 index 0000000..ba6f33a --- /dev/null +++ b/features/security_privacy_hardening.feature @@ -0,0 +1,190 @@ +Feature: Sessions, uploads and stored personal data are hardened against forgery, replay and over-retention + + As the operator of a RenewCMS instance + I want cross-site writes refused, logged-out sessions dead, weak secrets rejected, mail encrypted, + photo metadata removed and stale personal data deleted on a schedule + So that the instance meets the security-of-processing and storage-limitation duties of the GDPR + (art. 5(1)(c), 5(1)(e), 25 and 32) without the operator having to configure anything + + Background: + Given api.InitRoutes wraps every route in middleware.CrossOriginProtection(settings.PublicURL), + built on the standard library's http.CrossOriginProtection, after CORS and before any router + And the jwt cookie is set and cleared with HttpOnly, SameSite=Lax, Path "/" and, in + production, Secure + + Rule: Cross-site writes are refused + + Scenario Outline: An unsafe cross-origin browser request is refused before any handler runs + Given a request /v1/user/invite carrying the jwt cookie and the headers + Then the response is 403 with the body "Cross-origin requests are not allowed." + And the handler never runs + + Examples: + | method | headers | + | POST | Sec-Fetch-Site: cross-site, Origin: https://evil.example | + | POST | Sec-Fetch-Site: same-site, Origin: https://blog.example.com | + | DELETE | Sec-Fetch-Site: cross-site, Origin: https://evil.example | + | POST | Origin: https://evil.example (an older browser, no Sec-Fetch-Site) | + + Scenario Outline: Requests that cannot be forged are let through + Given a request + Then the handler runs + + Examples: + | request | + | POST with Sec-Fetch-Site: same-origin | + | POST with Sec-Fetch-Site: none | + | POST with neither Sec-Fetch-Site nor Origin (a non-browser client) | + | GET, HEAD or OPTIONS from any origin | + | POST from another origin carrying an Authorization header (an API key or bearer jwt) | + | POST whose Origin is the origin of PUBLIC_URL while a proxy rewrote Host | + + Scenario: A PUBLIC_URL that cannot be parsed still leaves the protection on + Given PUBLIC_URL is not a URL + When a cross-site POST arrives + Then it is refused with 403 + + Rule: Logging out kills the session server-side + + Scenario: Every session token carries a unique id and an expiry + When authtoken.Encode issues a token without "jti" or "exp" + Then it adds a random "jti" (crypto/rand.Text) and "exp" 24 hours after "iat" + And authtoken.Decode refuses a token without "exp" and a token whose "jti" is not a string + with ErrInvalid + + Scenario: The token presented at logout is refused afterwards, other sessions are not + Given alice logged in twice, getting tokens A and B + When she sends POST /v1/auth/logout with token A + Then the response is 200 and clears the cookie + And a session.Revocation for A's jti, expiring at A's exp, is stored in revoked_sessions + And GET /v1/auth/me with token A answers 401 and with token B answers 200 + + Scenario: Logout without a valid session stores nothing + When POST /v1/auth/logout arrives without a cookie, or with an expired or revoked one + Then the response is 200, the cookie is cleared and nothing is stored + + Scenario: A revocation that cannot be stored is reported + Given the revoked_sessions store refuses writes + When alice logs out + Then the response is 500 and the cookie is still cleared + + Scenario: A token whose revocation cannot be checked is refused + Given the revoked_sessions store cannot be read + Then any request authenticated by a jwt is treated as unauthenticated + + Rule: Only a current session sees drafts + + Scenario: A verified user's stale token reads like an anonymous visitor + Given alice is verified and her token_version is 1 + When GET /v1/article/ arrives with a token carrying token_version 0 + Then only online articles are listed + + Rule: Passwords fit bcrypt + + Scenario Outline: A password over 72 bytes is refused with 400 + When carries a password of 73 bytes, or of 37 two-byte characters + Then the response is 400 with the body "The password must be 72 bytes or fewer." + And nothing is created or changed + + Examples: + | request | + | POST /v1/auth/register | + | POST /v1/auth/password-reset/confirm (valid code) | + | POST /v1/auth/invite/accept (valid code) | + + Scenario: A wrong reset code still wins over a long password + When POST /v1/auth/password-reset/confirm carries a wrong code and a 73-byte password + Then the response is 403 as for any wrong code + + Rule: The server refuses to start with a guessable signing secret + + Scenario Outline: A weak JWT_SECRET stops startup + Given JWT_SECRET is + Then config.NewSettingsFromEnv fails with config.ErrWeakJWTSecret + And the message says to generate one with `openssl rand -hex 32` + + Examples: + | value | + | empty | + | shorter than 32 bytes | + | "change-me-to-a-long-random-string" | + | "replace-with-output-of-openssl-rand-hex-32" | + + Rule: Mail is never sent in clear text over the network by default + + Scenario: A non-loopback SMTP server without STARTTLS is refused + Given SMTP_PORT is not 465, SMTP_HOST is not localhost, 127.0.0.1 or ::1, and the server does + not offer STARTTLS + When a mail is sent + Then delivery fails before MAIL FROM with an error naming SMTP_ALLOW_INSECURE + + Scenario: The operator can opt out for a trusted relay + Given SMTP_ALLOW_INSECURE is "true" + When a mail is sent to such a server without credentials + Then it is delivered in clear text + And credentials are still never sent in clear text + + Rule: Uploaded photos lose their metadata + + Scenario Outline: Metadata is removed before the bytes are stored, hashed or measured for size + When a image is uploaded + Then the stored bytes no longer contain + And they still contain + And the image's size and sha256 describe the stored bytes, and its pixels are unchanged + + Examples: + | format | removed | kept | + | JPEG | EXIF, XMP, IPTC (APP13), comments, MPF and other APPn segments, trailing images | JFIF, ICC profile, Adobe | + | PNG | eXIf, tEXt, zTXt, iTXt, tIME and data after IEND | every other chunk | + | WebP | EXIF and XMP chunks (their VP8X flags cleared) and data after the RIFF size | every other chunk | + + Scenario: A rotated photo keeps only its orientation + Given an uploaded JPEG, PNG or WebP whose EXIF orientation is 2 to 8 + Then the stored file carries a minimal EXIF block holding only that orientation (after SOI or + JFIF for JPEG, after IHDR for PNG, at the end with the EXIF flag set for WebP) + + Scenario: An image whose container cannot be walked is refused + When a JPEG, PNG or WebP with a truncated header, a chunk longer than the file or no IEND + (PNG) is uploaded + Then the upload fails with image.ErrImageUnreadable and nothing is stored + And a JPEG cut off inside its scan data is kept as it is + + Scenario: SVG is stored as uploaded + When an SVG is uploaded + Then its bytes are stored unchanged + + Rule: Stale personal data is deleted on a schedule + + Scenario: The retention job runs hourly and logs only when it removed something + Given scheduler.NewSystemRetentionRunner runs useCases.EnforceRetentionUseCase a minute after + startup and then every hour + When a run removes something + Then the log reads "scheduler: personal data retention completed: revoked_sessions= + unverified_users= invites=" + And a run that removes nothing logs nothing, and a failed run logs "... failed: " + + Scenario Outline: What is removed + Given + Then the retention job + + Examples: + | record | outcome | + | a revoked session whose exp has passed | deletes it | + | an unverified account whose verification code expired more than 7 days ago | deletes it | + | an unverified account without a code expiry created more than 7 days ago | deletes it | + | an invite that expired more than 30 days ago | deletes it | + | an invite accepted more than 30 days ago | deletes it | + | a verified account, a pending invite or an account still inside its 7 days | keeps it | + + Scenario: A failure stops the run and keeps the counts of what was done + Given deleting a user fails + Then the run returns the error with the sessions already purged counted and touches no invite + + Rule: The back office loads nothing from third parties + + Scenario: Fonts are bundled + Given the SPA's fonts (IBM Plex Sans, IBM Plex Mono, Source Serif 4) live in + web/src/assets/fonts with their OFL licences + Then the Content-Security-Policy allows "font-src 'self'" and "style-src 'self' + 'unsafe-inline'" with no Google Fonts host + And loading the back office sends no request to fonts.googleapis.com or fonts.gstatic.com diff --git a/features/security_privacy_hardening.qa.md b/features/security_privacy_hardening.qa.md new file mode 100644 index 0000000..0d21cfa --- /dev/null +++ b/features/security_privacy_hardening.qa.md @@ -0,0 +1,84 @@ +# QA: security and privacy hardening + +Everything below runs in `task test` (package `test`, `-tags nofrontend`) unless marked manual. + +## Cross-origin protection and cookie flags + +1. `test/cross_origin_protection_http_test.go`: wrap a counting handler in + `middleware.CrossOriginProtection("https://cms.example.com")`. + - Refused with 403 and `apperr.CrossOriginErrorMessage`, handler not reached: a cross-site POST, a + same-site POST from `https://blog.example.com`, a cross-site DELETE, and a POST with only + `Origin: https://evil.example`. + - Allowed: same-origin POST, `Sec-Fetch-Site: none`, no headers, cross-site GET and OPTIONS, a + cross-site POST with `Authorization: Bearer rcms_key`, and a POST whose `Origin` is the public + URL while `Host` is `127.0.0.1:8080`. + - With `PUBLIC_URL` `::not a url`, a cross-site POST is still 403. +2. `test/session_revocation_http_test.go` drives `api.InitRoutes`: the login cookie is HttpOnly, + Secure and `SameSite=Lax`, and a cross-site `POST /v1/auth/logout` is 403 and revokes nothing. + +## Session revocation + +3. `test/authtoken_test.go` (`TestAuthTokenRevocation`, `TestAuthTokenDecodeRejections`): distinct + `jti` per token; explicit `jti` kept; a revoked token gives `ErrRevoked` and is stored until its + `exp`; other tokens stay valid; store read failure gives `ErrInvalid`; store write failure is + returned by `Revoke`; no `jti` is a no-op; no `exp` refuses; tokens without `exp` or with a + non-string `jti` are refused by `Decode`. +4. `TestSessionRevocationUseCase`: an already-expired session is not stored; an empty or 65-byte id + is `session.ErrInvalidSessionID`; 64 bytes is accepted. +5. `TestRevokedSessionRepository` on a migrated SQLite file: revoking twice is harmless, + `DeleteExpired(now)` removes rows whose `expires_at <= now` and reports the count. +6. `TestLogoutRevokesTheSession` through `api.InitRoutes`: two logins, logout with the first, `/me` + with it is 401 and with the second is 200; logout without a cookie is 200 and stores nothing; + a failing write gives 500 with the cookie cleared; a failing read makes `/me` 401. +7. `test/verified_account_required_http_test.go`: a verified user whose `token_version` moved on + only sees online articles. + +## Passwords + +8. `test/password_length_limit_test.go`: 72 bytes accepted, 73 bytes and 37 × "é" refused by + `user.ValidatePassword`; register, reset (valid code) and invite accept answer 400 with + `apperr.PasswordTooLongErrorMessage` and change nothing; a wrong reset code still answers 403. +9. `test/hasher_test.go`: an unknown username compares against a decoy hash once per attempt and + hashes the decoy once. + +## Configuration and mail + +10. `test/runtime_settings_from_env_test.go`: empty, short, 31-byte and both example + `JWT_SECRET` values fail with `config.ErrWeakJWTSecret` and mention `openssl rand -hex 32`; + 32 bytes passes. `SMTP_ALLOW_INSECURE` reads `""`/`false` as false, `true`/`TRUE` as true, and + `maybe` fails naming the variable. +11. `test/smtp_mailer_test.go` against the fake server on `127.0.0.2`: no STARTTLS and no opt-out + fails before `MAIL FROM` naming `SMTP_ALLOW_INSECURE`; with the opt-out the message is + delivered in clear text; with the opt-out and credentials, nothing is sent. + +## Upload metadata + +12. `test/image_metadata_stripping_test.go` builds JPEG, PNG and WebP files carrying a GPS-like + marker in EXIF, XMP, IPTC, comments, text chunks, MPF and trailing data. Assert the marker is + gone, ICC is kept, pixels decode identically, the orientation-only EXIF appears only when the + orientation is not 1 and sits in the documented place, WebP flags and RIFF size are correct, + a plain WebP and an SVG come back unchanged, and each malformed container is + `ErrImageUnreadable`. +13. `TestUploadStoresTheStrippedImage`: the stored bytes carry no marker and `Size`/`SHA256` match + them. `TestUploadRefusesAnImageWhoseContainerIsBroken`: nothing is stored. + +## Retention + +14. `test/personal_data_retention_test.go`: the user and invite rules at their boundaries; one run + over a seeded world removes exactly one expired revocation, one abandoned account and two + invites; a second run removes nothing; a session store failure touches nothing else; a user + or invite deletion failure returns the partial counts; the runner logs counts only when + something was removed and logs failures. + +## Fonts + +15. `test/security_headers_test.go`: the CSP contains `font-src 'self';` and no Google host. +16. Manual: `task build:web`, open the back office with the network panel; no request leaves for + `fonts.googleapis.com` or `fonts.gstatic.com`, and `task visual:check -- log-in` passes. + +## Manual verification + +- Behind the real reverse proxy, log in, copy the `jwt` cookie, log out, then replay the cookie + with `curl`: `/v1/auth/me` answers 401. +- With an SMTP relay that lacks STARTTLS on a non-loopback host, sending fails until + `SMTP_ALLOW_INSECURE=true` is set. diff --git a/features/trusted_proxy_client_ip_for_rate_limiting.feature b/features/trusted_proxy_client_ip_for_rate_limiting.feature index 9f0c636..1f9fb20 100644 --- a/features/trusted_proxy_client_ip_for_rate_limiting.feature +++ b/features/trusted_proxy_client_ip_for_rate_limiting.feature @@ -10,7 +10,7 @@ Feature: The rate limiter keys on the real connection address, trusting a forwar Background: Given POST /v1/auth/login and POST /v1/auth/password-reset are each wrapped, inside NewAuthRouter, by a middleware that runs before RateLimiter (api/middleware/rateLimit.go), - which keys its counters on r.RemoteAddr + which keys its counters on the host portion of r.RemoteAddr, never on its source port And this slice replaces chimiddleware.RealIP on those two route groups with TrustedProxyRealIP(trustedProxies []string) func(http.Handler) http.Handler (api/middleware/trustedProxyRealIP.go) @@ -96,3 +96,23 @@ Feature: The rate limiter keys on the real connection address, trusting a forwar X-Real-IP header Then the first response is not rejected by rate limiting and the second is 429 — both are counted against "10.0.0.1" itself + + Scenario: New connections from the same address share one counter + Given TRUSTED_PROXIES is "" and AUTH_RATE_LIMIT_MAX_ATTEMPTS is 1 + When a request arrives with connection address "203.0.113.7:40000" + And a second request arrives with connection address "203.0.113.7:40001" + Then the first response is not rejected by rate limiting and the second is 429, because the + counter is keyed on "203.0.113.7" and the source port is ignored + + Scenario: A forwarded client keeps one counter across the proxy's connections + Given TRUSTED_PROXIES is "10.0.0.1" and AUTH_RATE_LIMIT_MAX_ATTEMPTS is 1 + When a request arrives with connection address "10.0.0.1:51000" and header + X-Real-IP: "198.51.100.9" + And a second request arrives with connection address "10.0.0.1:51001" and the same header + Then the first response is not rejected by rate limiting and the second is 429 + + Scenario: Expired counters do not accumulate + Given requests from many distinct addresses + When a full window has passed since a counter started + Then the next request evicts every counter whose window has ended, so memory stays bounded by + the addresses seen within one window diff --git a/features/trusted_proxy_client_ip_for_rate_limiting.qa.md b/features/trusted_proxy_client_ip_for_rate_limiting.qa.md index 6e5952c..cc4d403 100644 --- a/features/trusted_proxy_client_ip_for_rate_limiting.qa.md +++ b/features/trusted_proxy_client_ip_for_rate_limiting.qa.md @@ -56,6 +56,14 @@ setup (`mockUserRepo`, `stubHasher`, `mockMailRepo`, the full `useCases` set, `a no forwarding header at all. Assert not `429`. Send a second, identical request. Assert `429` — a trusted proxy with nothing to forward falls back to its own address as the key. +14. `trustedProxies = nil`, limit 5. Send 100 requests from `203.0.113.7` on ports `40000`–`40099`. + Assert exactly 95 are `429` — the source port never yields a fresh counter + (`test/rate_limit_connection_key_http_test.go`). +15. Same for IPv6: `[2001:db8::1]:50000` then `[2001:db8::1]:50001` with limit 1 → `200` then `429`. +16. `trustedProxies = ["10.0.0.1"]`, limit 1. `10.0.0.1:51000` and `10.0.0.1:51001`, both with + `X-Real-IP: 198.51.100.9` → `200` then `429`. +17. Limit 1, window 20 ms: `200`, `429`, wait 30 ms, `200` — an expired counter starts over. + ## Manual verification (not exercised by the Go test) - Real behaviour behind the documented nginx and Caddy reverse proxies (`docs/deployment.md`) — diff --git a/internal/domain/image/metadataStripper.go b/internal/domain/image/metadataStripper.go new file mode 100644 index 0000000..f7d03b6 --- /dev/null +++ b/internal/domain/image/metadataStripper.go @@ -0,0 +1,5 @@ +package image + +type MetadataStripper interface { + Strip(contentType string, data []byte) ([]byte, error) +} diff --git a/internal/domain/invite/inviteDomain.go b/internal/domain/invite/inviteDomain.go index 0204a6b..6dadd12 100644 --- a/internal/domain/invite/inviteDomain.go +++ b/internal/domain/invite/inviteDomain.go @@ -15,6 +15,8 @@ var ErrInviteAlreadyAccepted = errors.New("invite already accepted") const Lifetime = 3 * 24 * time.Hour +const Retention = 30 * 24 * time.Hour + type Status string const ( @@ -42,3 +44,13 @@ func (i Invite) StatusAt(now time.Time) Status { } return StatusExpired } + +func (i Invite) IsStaleAt(now time.Time) bool { + switch i.StatusAt(now) { + case StatusAccepted: + return now.After(i.AcceptedAt.Add(Retention)) + case StatusExpired: + return now.After(i.ExpiresAt.Add(Retention)) + } + return false +} diff --git a/internal/domain/retention/retentionDomain.go b/internal/domain/retention/retentionDomain.go new file mode 100644 index 0000000..43518b1 --- /dev/null +++ b/internal/domain/retention/retentionDomain.go @@ -0,0 +1,11 @@ +package retention + +type Result struct { + RevokedSessions int + UnverifiedUsers int + Invites int +} + +func (r Result) Total() int { + return r.RevokedSessions + r.UnverifiedUsers + r.Invites +} diff --git a/internal/domain/session/sessionDomain.go b/internal/domain/session/sessionDomain.go new file mode 100644 index 0000000..d718586 --- /dev/null +++ b/internal/domain/session/sessionDomain.go @@ -0,0 +1,22 @@ +package session + +import ( + "errors" + "time" +) + +var ErrInvalidSessionID = errors.New("invalid session id") + +const MaxIDLength = 64 + +type Revocation struct { + ID string + ExpiresAt time.Time +} + +func ValidateID(id string) error { + if id == "" || len(id) > MaxIDLength { + return ErrInvalidSessionID + } + return nil +} diff --git a/internal/domain/session/sessionRepositoryInterface.go b/internal/domain/session/sessionRepositoryInterface.go new file mode 100644 index 0000000..5b1cdb0 --- /dev/null +++ b/internal/domain/session/sessionRepositoryInterface.go @@ -0,0 +1,9 @@ +package session + +import "time" + +type Repository interface { + Revoke(revocation Revocation) error + IsRevoked(id string) (bool, error) + DeleteExpired(now time.Time) (int, error) +} diff --git a/internal/domain/user/userDomain.go b/internal/domain/user/userDomain.go index 40158d1..51d3b04 100644 --- a/internal/domain/user/userDomain.go +++ b/internal/domain/user/userDomain.go @@ -18,9 +18,19 @@ var ErrInvalidDisplayName = errors.New("invalid display name") var ErrUsernameTaken = errors.New("username taken") var ErrInvalidLookup = errors.New("invalid user lookup") var ErrVerificationEmailNotSent = errors.New("verification email not sent") +var ErrPasswordTooLong = errors.New("password too long") const MaxLookupIDs = 100 +const MaxPasswordBytes = 72 + +func ValidatePassword(password string) error { + if len(password) > MaxPasswordBytes { + return ErrPasswordTooLong + } + return nil +} + type Role string const ( @@ -45,6 +55,22 @@ type User struct { UpdatedAt time.Time `json:"updated_at"` } +const UnverifiedRetention = 7 * 24 * time.Hour + +func (u User) IsStaleUnverifiedAt(now time.Time) bool { + if u.IsVerified { + return false + } + deadline := u.VerificationExpiration + if deadline.IsZero() { + deadline = u.CreatedAt + } + if deadline.IsZero() { + return false + } + return now.After(deadline.Add(UnverifiedRetention)) +} + func (u User) NameOrUsername() string { if trimmed := strings.TrimSpace(u.DisplayName); trimmed != "" { return trimmed diff --git a/internal/infrastructure/config/securitySettings.go b/internal/infrastructure/config/securitySettings.go new file mode 100644 index 0000000..df1345a --- /dev/null +++ b/internal/infrastructure/config/securitySettings.go @@ -0,0 +1,39 @@ +package config + +import ( + "errors" + "fmt" + "os" +) + +const ( + EnvJWTSecret = "JWT_SECRET" + MinJWTSecretBytes = 32 + EnvSmtpAllowInsecure = "SMTP_ALLOW_INSECURE" + DefaultSmtpAllowInsecure = "false" +) + +var ErrWeakJWTSecret = errors.New("JWT_SECRET is too weak") + +var placeholderJWTSecrets = map[string]bool{ + "change-me-to-a-long-random-string": true, + "replace-with-output-of-openssl-rand-hex-32": true, +} + +func ValidateJWTSecret(secret string) error { + if placeholderJWTSecrets[secret] { + return fmt.Errorf("%w: it is still the example value; generate one with `openssl rand -hex 32`", ErrWeakJWTSecret) + } + if len(secret) < MinJWTSecretBytes { + return fmt.Errorf("%w: it must be at least %d bytes long; generate one with `openssl rand -hex 32`", ErrWeakJWTSecret, MinJWTSecretBytes) + } + return nil +} + +func jwtSecretFromEnv() (string, error) { + secret := os.Getenv(EnvJWTSecret) + if err := ValidateJWTSecret(secret); err != nil { + return "", err + } + return secret, nil +} diff --git a/internal/infrastructure/config/settings.go b/internal/infrastructure/config/settings.go index 28fa863..9a8cc11 100644 --- a/internal/infrastructure/config/settings.go +++ b/internal/infrastructure/config/settings.go @@ -9,11 +9,12 @@ import ( ) type SmtpSettings struct { - Login string - Sender string - Password string - Host string - Port int + Login string + Sender string + Password string + Host string + Port int + AllowInsecure bool } type AuthRateLimitSettings struct { @@ -55,11 +56,21 @@ func (s Settings) IsProduction() bool { } func NewSettingsFromEnv() (Settings, error) { + jwtSecret, err := jwtSecretFromEnv() + if err != nil { + return Settings{}, err + } + smtpPort, err := intEnv("SMTP_PORT") if err != nil { return Settings{}, err } + smtpAllowInsecure, err := boolEnv(EnvSmtpAllowInsecure, DefaultSmtpAllowInsecure) + if err != nil { + return Settings{}, err + } + authRateLimitMaxAttempts, err := intEnv("AUTH_RATE_LIMIT_MAX_ATTEMPTS") if err != nil { return Settings{}, err @@ -100,18 +111,19 @@ func NewSettingsFromEnv() (Settings, error) { CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")), PublicURL: os.Getenv("PUBLIC_URL"), UploadDir: os.Getenv("UPLOAD_DIR"), - JWTSecret: os.Getenv("JWT_SECRET"), + JWTSecret: jwtSecret, DBFile: os.Getenv("DB_FILE"), Database: database, Storage: storage, DataDirPath: os.Getenv("DATA_DIR"), Port: os.Getenv("PORT"), Smtp: SmtpSettings{ - Login: os.Getenv("SMTP_LOGIN"), - Sender: os.Getenv("SMTP_SENDER"), - Password: os.Getenv("SMTP_PASSWORD"), - Host: os.Getenv("SMTP_HOST"), - Port: smtpPort, + Login: os.Getenv("SMTP_LOGIN"), + Sender: os.Getenv("SMTP_SENDER"), + Password: os.Getenv("SMTP_PASSWORD"), + Host: os.Getenv("SMTP_HOST"), + Port: smtpPort, + AllowInsecure: smtpAllowInsecure, }, AuthRateLimit: AuthRateLimitSettings{ MaxAttempts: authRateLimitMaxAttempts, diff --git a/internal/infrastructure/imaging/exifOrientation.go b/internal/infrastructure/imaging/exifOrientation.go new file mode 100644 index 0000000..7c14160 --- /dev/null +++ b/internal/infrastructure/imaging/exifOrientation.go @@ -0,0 +1,75 @@ +package imaging + +import ( + "bytes" + "encoding/binary" +) + +const ( + tiffOrientationTag = 0x0112 + tiffTypeShort = 3 + tiffHeaderSize = 8 + tiffEntrySize = 12 + tiffMagic = 42 + uprightOrientation = 1 + maxOrientation = 8 +) + +var exifHeader = []byte("Exif\x00\x00") + +func exifOrientation(payload []byte) (uint16, bool) { + tiff := bytes.TrimPrefix(payload, exifHeader) + if len(tiff) < tiffHeaderSize { + return 0, false + } + var order binary.ByteOrder + switch string(tiff[:2]) { + case "II": + order = binary.LittleEndian + case "MM": + order = binary.BigEndian + default: + return 0, false + } + if order.Uint16(tiff[2:]) != tiffMagic { + return 0, false + } + ifd := int(order.Uint32(tiff[4:])) + if ifd < tiffHeaderSize || ifd+2 > len(tiff) { + return 0, false + } + count := int(order.Uint16(tiff[ifd:])) + for i := range count { + entry := ifd + 2 + i*tiffEntrySize + if entry+tiffEntrySize > len(tiff) { + return 0, false + } + if order.Uint16(tiff[entry:]) != tiffOrientationTag || order.Uint16(tiff[entry+2:]) != tiffTypeShort { + continue + } + value := order.Uint16(tiff[entry+8:]) + if value < uprightOrientation || value > maxOrientation { + return 0, false + } + return value, true + } + return 0, false +} + +func needsOrientation(orientation uint16) bool { + return orientation > uprightOrientation +} + +func orientationOnlyTIFF(orientation uint16) []byte { + tiff := make([]byte, 0, tiffHeaderSize+2+tiffEntrySize+4) + tiff = append(tiff, 'M', 'M') + tiff = binary.BigEndian.AppendUint16(tiff, tiffMagic) + tiff = binary.BigEndian.AppendUint32(tiff, tiffHeaderSize) + tiff = binary.BigEndian.AppendUint16(tiff, 1) + tiff = binary.BigEndian.AppendUint16(tiff, tiffOrientationTag) + tiff = binary.BigEndian.AppendUint16(tiff, tiffTypeShort) + tiff = binary.BigEndian.AppendUint32(tiff, 1) + tiff = binary.BigEndian.AppendUint16(tiff, orientation) + tiff = binary.BigEndian.AppendUint16(tiff, 0) + return binary.BigEndian.AppendUint32(tiff, 0) +} diff --git a/internal/infrastructure/imaging/jpegMetadata.go b/internal/infrastructure/imaging/jpegMetadata.go new file mode 100644 index 0000000..ff85601 --- /dev/null +++ b/internal/infrastructure/imaging/jpegMetadata.go @@ -0,0 +1,168 @@ +package imaging + +import ( + "bytes" + "encoding/binary" +) + +const ( + jpegMarkerPrefix = 0xFF + jpegSOI = 0xD8 + jpegEOI = 0xD9 + jpegSOS = 0xDA + jpegTEM = 0x01 + jpegRST0 = 0xD0 + jpegRST7 = 0xD7 + jpegAPP0 = 0xE0 + jpegAPP1 = 0xE1 + jpegAPP2 = 0xE2 + jpegAPP14 = 0xEE + jpegAPP15 = 0xEF + jpegCOM = 0xFE + jpegStuffedZero = 0x00 +) + +var iccProfileSignature = []byte("ICC_PROFILE\x00") + +type jpegSegment struct { + marker byte + payload []byte +} + +func stripJPEG(data []byte) ([]byte, error) { + if len(data) < 4 || data[0] != jpegMarkerPrefix || data[1] != jpegSOI { + return nil, errMalformedContainer + } + + out := []byte{jpegMarkerPrefix, jpegSOI} + insertAt := len(out) + orientation := uint16(uprightOrientation) + first := true + + for pos := 2; ; { + marker, next, err := readJPEGMarker(data, pos) + if err != nil { + return nil, err + } + pos = next + + if marker == jpegEOI { + out = append(out, jpegMarkerPrefix, jpegEOI) + return withOrientationSegment(out, insertAt, orientation), nil + } + if isStandaloneJPEGMarker(marker) { + out = append(out, jpegMarkerPrefix, marker) + continue + } + + segment, next, err := readJPEGSegment(data, pos, marker) + if err != nil { + return nil, err + } + pos = next + + if isJPEGMetadata(segment) { + if found, ok := jpegExifOrientation(segment); ok { + orientation = found + } + } else { + out = appendJPEGSegment(out, segment) + if first && segment.marker == jpegAPP0 { + insertAt = len(out) + } + } + first = false + + if marker == jpegSOS { + end := jpegScanEnd(data, pos) + out = append(out, data[pos:end]...) + if end == len(data) { + return withOrientationSegment(out, insertAt, orientation), nil + } + pos = end + } + } +} + +func readJPEGMarker(data []byte, pos int) (byte, int, error) { + if pos >= len(data) || data[pos] != jpegMarkerPrefix { + return 0, 0, errMalformedContainer + } + for pos < len(data) && data[pos] == jpegMarkerPrefix { + pos++ + } + if pos >= len(data) { + return 0, 0, errMalformedContainer + } + return data[pos], pos + 1, nil +} + +func readJPEGSegment(data []byte, pos int, marker byte) (jpegSegment, int, error) { + if pos+2 > len(data) { + return jpegSegment{}, 0, errMalformedContainer + } + length := int(binary.BigEndian.Uint16(data[pos:])) + if length < 2 || pos+length > len(data) { + return jpegSegment{}, 0, errMalformedContainer + } + return jpegSegment{marker: marker, payload: data[pos+2 : pos+length]}, pos + length, nil +} + +func jpegScanEnd(data []byte, pos int) int { + for pos < len(data) { + if data[pos] != jpegMarkerPrefix || pos+1 >= len(data) { + pos++ + continue + } + next := data[pos+1] + if next == jpegStuffedZero || isRestartMarker(next) { + pos += 2 + continue + } + return pos + } + return len(data) +} + +func isStandaloneJPEGMarker(marker byte) bool { + return marker == jpegTEM || isRestartMarker(marker) +} + +func isRestartMarker(marker byte) bool { + return marker >= jpegRST0 && marker <= jpegRST7 +} + +func isJPEGMetadata(segment jpegSegment) bool { + if segment.marker == jpegCOM { + return true + } + if segment.marker == jpegAPP2 { + return !bytes.HasPrefix(segment.payload, iccProfileSignature) + } + if segment.marker == jpegAPP0 || segment.marker == jpegAPP14 { + return false + } + return segment.marker >= jpegAPP1 && segment.marker <= jpegAPP15 +} + +func jpegExifOrientation(segment jpegSegment) (uint16, bool) { + if segment.marker != jpegAPP1 || !bytes.HasPrefix(segment.payload, exifHeader) { + return 0, false + } + return exifOrientation(segment.payload) +} + +func appendJPEGSegment(out []byte, segment jpegSegment) []byte { + out = append(out, jpegMarkerPrefix, segment.marker) + out = binary.BigEndian.AppendUint16(out, uint16(len(segment.payload)+2)) + return append(out, segment.payload...) +} + +func withOrientationSegment(out []byte, insertAt int, orientation uint16) []byte { + if !needsOrientation(orientation) { + return out + } + payload := append(bytes.Clone(exifHeader), orientationOnlyTIFF(orientation)...) + segment := appendJPEGSegment(nil, jpegSegment{marker: jpegAPP1, payload: payload}) + return append(out[:insertAt:insertAt], append(segment, out[insertAt:]...)...) +} diff --git a/internal/infrastructure/imaging/metadataStripper.go b/internal/infrastructure/imaging/metadataStripper.go new file mode 100644 index 0000000..23750f1 --- /dev/null +++ b/internal/infrastructure/imaging/metadataStripper.go @@ -0,0 +1,35 @@ +package imaging + +import ( + domain "RenewCMS/internal/domain/image" + "errors" +) + +var errMalformedContainer = errors.New("malformed image container") + +type MetadataStripper struct{} + +var _ domain.MetadataStripper = &MetadataStripper{} + +func NewMetadataStripper() *MetadataStripper { + return &MetadataStripper{} +} + +func (s *MetadataStripper) Strip(contentType string, data []byte) ([]byte, error) { + var stripped []byte + var err error + switch contentType { + case "image/jpeg": + stripped, err = stripJPEG(data) + case "image/png": + stripped, err = stripPNG(data) + case "image/webp": + stripped, err = stripWebP(data) + default: + return data, nil + } + if err != nil { + return nil, errors.Join(domain.ErrImageUnreadable, err) + } + return stripped, nil +} diff --git a/internal/infrastructure/imaging/pngMetadata.go b/internal/infrastructure/imaging/pngMetadata.go new file mode 100644 index 0000000..0ab0874 --- /dev/null +++ b/internal/infrastructure/imaging/pngMetadata.go @@ -0,0 +1,86 @@ +package imaging + +import ( + "bytes" + "encoding/binary" + "hash/crc32" +) + +const ( + pngChunkHeaderSize = 8 + pngChunkCRCSize = 4 + pngIHDR = "IHDR" + pngIEND = "IEND" + pngEXIF = "eXIf" +) + +var pngSignature = []byte("\x89PNG\r\n\x1a\n") + +var pngMetadataChunks = map[string]bool{ + pngEXIF: true, + "tEXt": true, + "zTXt": true, + "iTXt": true, + "tIME": true, +} + +func stripPNG(data []byte) ([]byte, error) { + if !bytes.HasPrefix(data, pngSignature) { + return nil, errMalformedContainer + } + + out := bytes.Clone(pngSignature) + orientation := uint16(uprightOrientation) + insertAt := 0 + + for pos := len(pngSignature); pos < len(data); { + available := len(data) - pos - pngChunkHeaderSize - pngChunkCRCSize + if available < 0 { + return nil, errMalformedContainer + } + size := binary.BigEndian.Uint32(data[pos:]) + if uint64(size) > uint64(available) { + return nil, errMalformedContainer + } + kind := string(data[pos+4 : pos+pngChunkHeaderSize]) + payload := data[pos+pngChunkHeaderSize : pos+pngChunkHeaderSize+int(size)] + end := pos + pngChunkHeaderSize + len(payload) + pngChunkCRCSize + + if pngMetadataChunks[kind] { + if kind == pngEXIF { + if found, ok := exifOrientation(payload); ok { + orientation = found + } + } + } else { + out = append(out, data[pos:end]...) + if kind == pngIHDR { + insertAt = len(out) + } + } + pos = end + + if kind == pngIEND { + return withPNGOrientation(out, insertAt, orientation) + } + } + return nil, errMalformedContainer +} + +func withPNGOrientation(out []byte, insertAt int, orientation uint16) ([]byte, error) { + if !needsOrientation(orientation) { + return out, nil + } + if insertAt == 0 { + return nil, errMalformedContainer + } + chunk := pngChunk(pngEXIF, orientationOnlyTIFF(orientation)) + return append(out[:insertAt:insertAt], append(chunk, out[insertAt:]...)...), nil +} + +func pngChunk(kind string, payload []byte) []byte { + chunk := binary.BigEndian.AppendUint32(nil, uint32(len(payload))) + chunk = append(chunk, kind...) + chunk = append(chunk, payload...) + return binary.BigEndian.AppendUint32(chunk, crc32.ChecksumIEEE(chunk[4:])) +} diff --git a/internal/infrastructure/imaging/webpMetadata.go b/internal/infrastructure/imaging/webpMetadata.go new file mode 100644 index 0000000..215cabb --- /dev/null +++ b/internal/infrastructure/imaging/webpMetadata.go @@ -0,0 +1,88 @@ +package imaging + +import ( + "bytes" + "encoding/binary" +) + +const ( + riffHeaderSize = 12 + riffChunkHeaderSize = 8 + webpVP8X = "VP8X" + webpEXIF = "EXIF" + webpXMP = "XMP " + webpEXIFFlag = 0x08 + webpXMPFlag = 0x04 +) + +func stripWebP(data []byte) ([]byte, error) { + if len(data) < riffHeaderSize || string(data[:4]) != "RIFF" || string(data[8:12]) != "WEBP" { + return nil, errMalformedContainer + } + declared := int(binary.LittleEndian.Uint32(data[4:])) + riffChunkHeaderSize + if declared > len(data) || declared < riffHeaderSize { + return nil, errMalformedContainer + } + data = data[:declared] + + var chunks [][]byte + orientation := uint16(uprightOrientation) + for pos := riffHeaderSize; pos < len(data); { + available := len(data) - pos - riffChunkHeaderSize + if available < 0 { + return nil, errMalformedContainer + } + size := binary.LittleEndian.Uint32(data[pos+4:]) + padded := uint64(size) + uint64(size%2) + if padded > uint64(available) { + return nil, errMalformedContainer + } + kind := string(data[pos : pos+4]) + payload := data[pos+riffChunkHeaderSize : pos+riffChunkHeaderSize+int(size)] + end := pos + riffChunkHeaderSize + int(padded) + switch kind { + case webpEXIF: + if found, ok := exifOrientation(payload); ok { + orientation = found + } + case webpXMP: + default: + chunks = append(chunks, bytes.Clone(data[pos:end])) + } + pos = end + } + + if len(chunks) == 0 { + return nil, errMalformedContainer + } + if string(chunks[0][:4]) == webpVP8X { + if len(chunks[0]) <= riffChunkHeaderSize { + return nil, errMalformedContainer + } + flags := &chunks[0][riffChunkHeaderSize] + *flags &^= webpEXIFFlag | webpXMPFlag + if needsOrientation(orientation) { + *flags |= webpEXIFFlag + chunks = append(chunks, riffChunk(webpEXIF, orientationOnlyTIFF(orientation))) + } + } + return riffContainer(chunks), nil +} + +func riffChunk(kind string, payload []byte) []byte { + chunk := append([]byte(kind), binary.LittleEndian.AppendUint32(nil, uint32(len(payload)))...) + chunk = append(chunk, payload...) + if len(payload)%2 == 1 { + chunk = append(chunk, 0) + } + return chunk +} + +func riffContainer(chunks [][]byte) []byte { + body := []byte("WEBP") + for _, chunk := range chunks { + body = append(body, chunk...) + } + out := append([]byte("RIFF"), binary.LittleEndian.AppendUint32(nil, uint32(len(body)))...) + return append(out, body...) +} diff --git a/internal/infrastructure/mailer/transport.go b/internal/infrastructure/mailer/transport.go index bdeed0c..d1fd1f6 100644 --- a/internal/infrastructure/mailer/transport.go +++ b/internal/infrastructure/mailer/transport.go @@ -81,17 +81,27 @@ func (t transport) connect() (*smtp.Client, error) { return client, nil } +var errTLSUnavailable = errors.New("smtp server does not offer STARTTLS; set SMTP_ALLOW_INSECURE=true to send without encryption") + func (t transport) negotiate(client *smtp.Client) error { if t.settings.Port != implicitTLSPort { - if supported, _ := client.Extension("STARTTLS"); supported { - if err := client.StartTLS(t.tlsConfig); err != nil { - return err - } + if err := t.startTLS(client); err != nil { + return err } } return t.authenticate(client) } +func (t transport) startTLS(client *smtp.Client) error { + if supported, _ := client.Extension("STARTTLS"); supported { + return client.StartTLS(t.tlsConfig) + } + if t.settings.AllowInsecure || isLoopback(t.settings.Host) { + return nil + } + return errTLSUnavailable +} + func (t transport) authenticate(client *smtp.Client) error { if t.settings.Login == "" { return nil diff --git a/internal/infrastructure/persistence/database/migrate.go b/internal/infrastructure/persistence/database/migrate.go index e34c653..d3e2a7b 100644 --- a/internal/infrastructure/persistence/database/migrate.go +++ b/internal/infrastructure/persistence/database/migrate.go @@ -7,7 +7,7 @@ import ( ) func Migrate(db *gorm.DB, publicURL string) error { - err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}) + err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}) if err != nil { return err } diff --git a/internal/infrastructure/persistence/mappers/sessionMapper.go b/internal/infrastructure/persistence/mappers/sessionMapper.go new file mode 100644 index 0000000..3d5ab1c --- /dev/null +++ b/internal/infrastructure/persistence/mappers/sessionMapper.go @@ -0,0 +1,10 @@ +package mappers + +import ( + "RenewCMS/internal/domain/session" + "RenewCMS/internal/infrastructure/persistence/models" +) + +func RevokedSessionToModel(r session.Revocation) models.RevokedSession { + return models.RevokedSession{ID: r.ID, ExpiresAt: r.ExpiresAt.UTC()} +} diff --git a/internal/infrastructure/persistence/models/revokedSessionModel.go b/internal/infrastructure/persistence/models/revokedSessionModel.go new file mode 100644 index 0000000..8c49c06 --- /dev/null +++ b/internal/infrastructure/persistence/models/revokedSessionModel.go @@ -0,0 +1,8 @@ +package models + +import "time" + +type RevokedSession struct { + ID string `gorm:"primaryKey;size:64"` + ExpiresAt time.Time `gorm:"index;not null"` +} diff --git a/internal/infrastructure/persistence/repositories/revokedSessionRepository.go b/internal/infrastructure/persistence/repositories/revokedSessionRepository.go new file mode 100644 index 0000000..fe9caeb --- /dev/null +++ b/internal/infrastructure/persistence/repositories/revokedSessionRepository.go @@ -0,0 +1,39 @@ +package persistence + +import ( + domainSession "RenewCMS/internal/domain/session" + "RenewCMS/internal/infrastructure/persistence/mappers" + entity "RenewCMS/internal/infrastructure/persistence/models" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type RevokedSessionRepository struct { + db *gorm.DB +} + +func NewRevokedSessionRepository(db *gorm.DB) *RevokedSessionRepository { + return &RevokedSessionRepository{db} +} + +func (s *RevokedSessionRepository) Revoke(revocation domainSession.Revocation) error { + model := mappers.RevokedSessionToModel(revocation) + return s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&model).Error +} + +func (s *RevokedSessionRepository) IsRevoked(id string) (bool, error) { + var count int64 + if err := s.db.Model(&entity.RevokedSession{}).Where("id = ?", id).Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (s *RevokedSessionRepository) DeleteExpired(now time.Time) (int, error) { + result := s.db.Where("expires_at <= ?", now.UTC()).Delete(&entity.RevokedSession{}) + return int(result.RowsAffected), result.Error +} + +var _ domainSession.Repository = &RevokedSessionRepository{} diff --git a/internal/infrastructure/scheduler/runner.go b/internal/infrastructure/scheduler/runner.go index 7c10556..2896f0f 100644 --- a/internal/infrastructure/scheduler/runner.go +++ b/internal/infrastructure/scheduler/runner.go @@ -3,6 +3,7 @@ package scheduler import ( "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/purge" + "RenewCMS/internal/domain/retention" "context" "errors" "fmt" @@ -23,6 +24,10 @@ type CleanupJob interface { Tick(now time.Time) (audit.CleanupResult, bool, error) } +type RetentionJob interface { + Tick(now time.Time) (retention.Result, error) +} + type task interface { tick(ctx context.Context, now time.Time) (summary string, ran bool, err error) } @@ -49,6 +54,16 @@ func (c cleanupTask) tick(_ context.Context, now time.Time) (string, bool, error return fmt.Sprintf("removed=%d", result.Removed), ran, err } +type retentionTask struct { + job RetentionJob +} + +func (r retentionTask) tick(_ context.Context, now time.Time) (string, bool, error) { + result, err := r.job.Tick(now) + summary := fmt.Sprintf("revoked_sessions=%d unverified_users=%d invites=%d", result.RevokedSessions, result.UnverifiedUsers, result.Invites) + return summary, result.Total() > 0, err +} + type Runner struct { label string task task @@ -60,8 +75,9 @@ type Runner struct { } const ( - purgeLabel = "automatic purge" - cleanupLabel = "activity cleanup" + purgeLabel = "automatic purge" + cleanupLabel = "activity cleanup" + retentionLabel = "personal data retention" ) func newRunner(label string, task task, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner { @@ -76,6 +92,16 @@ func NewCleanupRunner(job CleanupJob, now func() time.Time, ticks <-chan time.Ti return newRunner(cleanupLabel, cleanupTask{job}, now, ticks, startup, logger) } +func NewRetentionRunner(job RetentionJob, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner { + return newRunner(retentionLabel, retentionTask{job}, now, ticks, startup, logger) +} + +func NewSystemRetentionRunner(job RetentionJob, logger *log.Logger) *Runner { + return withSystemTimers(func(now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time) *Runner { + return NewRetentionRunner(job, now, ticks, startup, logger) + }) +} + func NewSystemRunner(job Job, logger *log.Logger) *Runner { return withSystemTimers(func(now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time) *Runner { return NewRunner(job, now, ticks, startup, logger) diff --git a/internal/infrastructure/useCases/AcceptInvite.go b/internal/infrastructure/useCases/AcceptInvite.go index 8d213bf..ae77711 100644 --- a/internal/infrastructure/useCases/AcceptInvite.go +++ b/internal/infrastructure/useCases/AcceptInvite.go @@ -66,6 +66,10 @@ func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite return user.User{}, err } + if err := user.ValidatePassword(command.Password); err != nil { + return user.User{}, err + } + hashedPassword, err := a.hasher.Hash(command.Password) if err != nil { return user.User{}, err diff --git a/internal/infrastructure/useCases/AuthenticateUser.go b/internal/infrastructure/useCases/AuthenticateUser.go index aae827e..4fb6deb 100644 --- a/internal/infrastructure/useCases/AuthenticateUser.go +++ b/internal/infrastructure/useCases/AuthenticateUser.go @@ -5,17 +5,21 @@ import ( "RenewCMS/internal/domain/hash" "RenewCMS/internal/domain/user" "context" + "crypto/rand" "errors" + "sync" ) type AuthenticateUserUseCase struct { userRepository user.Repository hasher hash.Hasher recorder audit.Recorder + decoyOnce sync.Once + decoyHash string } func NewAuthenticateUserUseCase(userRepository user.Repository, hasher hash.Hasher, recorder audit.Recorder) *AuthenticateUserUseCase { - return &AuthenticateUserUseCase{userRepository, hasher, recorder} + return &AuthenticateUserUseCase{userRepository: userRepository, hasher: hasher, recorder: recorder} } func (a *AuthenticateUserUseCase) Authenticate(ctx context.Context, username string, password string) (user.User, error) { @@ -34,6 +38,9 @@ func (a *AuthenticateUserUseCase) Authenticate(ctx context.Context, username str func (a *AuthenticateUserUseCase) verifyCredentials(username string, password string) (user.User, error) { dbUser, err := a.userRepository.GetByUsername(username) if err != nil { + if errors.Is(err, user.ErrUserNotFound) { + a.compareWithDecoy(password) + } return user.User{}, err } @@ -44,6 +51,15 @@ func (a *AuthenticateUserUseCase) verifyCredentials(username string, password st return dbUser, nil } +func (a *AuthenticateUserUseCase) compareWithDecoy(password string) { + a.decoyOnce.Do(func() { + a.decoyHash, _ = a.hasher.Hash(rand.Text()) + }) + if a.decoyHash != "" { + _ = a.hasher.Compare(a.decoyHash, password) + } +} + func (a *AuthenticateUserUseCase) recordRejectedLogin(ctx context.Context, username string, cause error) { if !errors.Is(cause, user.ErrUserNotFound) && !errors.Is(cause, user.ErrInvalidCredentials) { return diff --git a/internal/infrastructure/useCases/CreateImage.go b/internal/infrastructure/useCases/CreateImage.go index ab5ed96..8e17c93 100644 --- a/internal/infrastructure/useCases/CreateImage.go +++ b/internal/infrastructure/useCases/CreateImage.go @@ -29,10 +29,11 @@ type CreateImageUseCase struct { imageRepository image.Repository storage image.Storage recorder audit.Recorder + stripper image.MetadataStripper } -func NewCreateImageUseCase(imageRepository image.Repository, storage image.Storage, recorder audit.Recorder) *CreateImageUseCase { - return &CreateImageUseCase{imageRepository, storage, recorder} +func NewCreateImageUseCase(imageRepository image.Repository, storage image.Storage, recorder audit.Recorder, stripper image.MetadataStripper) *CreateImageUseCase { + return &CreateImageUseCase{imageRepository, storage, recorder, stripper} } func (g *CreateImageUseCase) CreateImage(ctx context.Context, input image.ImageInput) (image.Image, error) { @@ -74,6 +75,11 @@ func (g *CreateImageUseCase) create(input image.ImageInput) (image.Image, error) return image.Image{}, err } + data, err = g.stripper.Strip(input.ContentType, data) + if err != nil { + return image.Image{}, err + } + checksum := sha256.Sum256(data) storedName, err := g.storage.Put(input.ContentType, bytes.NewReader(data)) if err != nil { diff --git a/internal/infrastructure/useCases/CreateUser.go b/internal/infrastructure/useCases/CreateUser.go index 8021911..dae1b3e 100644 --- a/internal/infrastructure/useCases/CreateUser.go +++ b/internal/infrastructure/useCases/CreateUser.go @@ -55,6 +55,10 @@ func (g *CreateUserUseCase) create(createUser CreateUserCommand) (user.User, err return user.User{}, user.ErrRegistrationClosed } + if err := user.ValidatePassword(createUser.Password); err != nil { + return user.User{}, err + } + rawUuid := uuid.NewString() hashedPassword, err := g.hasher.Hash(createUser.Password) diff --git a/internal/infrastructure/useCases/EnforceRetention.go b/internal/infrastructure/useCases/EnforceRetention.go new file mode 100644 index 0000000..e073246 --- /dev/null +++ b/internal/infrastructure/useCases/EnforceRetention.go @@ -0,0 +1,61 @@ +package useCases + +import ( + "RenewCMS/internal/domain/invite" + "RenewCMS/internal/domain/retention" + "RenewCMS/internal/domain/session" + "RenewCMS/internal/domain/user" + "time" +) + +type EnforceRetentionUseCase struct { + users user.Repository + invites invite.Repository + sessions session.Repository +} + +func NewEnforceRetentionUseCase(users user.Repository, invites invite.Repository, sessions session.Repository) *EnforceRetentionUseCase { + return &EnforceRetentionUseCase{users, invites, sessions} +} + +func (e *EnforceRetentionUseCase) Tick(now time.Time) (retention.Result, error) { + var result retention.Result + var err error + + if result.RevokedSessions, err = e.sessions.DeleteExpired(now); err != nil { + return result, err + } + if result.UnverifiedUsers, err = e.deleteStaleUnverifiedUsers(now); err != nil { + return result, err + } + result.Invites, err = e.deleteStaleInvites(now) + return result, err +} + +func (e *EnforceRetentionUseCase) deleteStaleUnverifiedUsers(now time.Time) (int, error) { + removed := 0 + for _, candidate := range e.users.GetAll() { + if !candidate.IsStaleUnverifiedAt(now) { + continue + } + if err := e.users.Delete(candidate.ID); err != nil { + return removed, err + } + removed++ + } + return removed, nil +} + +func (e *EnforceRetentionUseCase) deleteStaleInvites(now time.Time) (int, error) { + removed := 0 + for _, candidate := range e.invites.GetAll() { + if !candidate.IsStaleAt(now) { + continue + } + if err := e.invites.Delete(candidate.ID); err != nil { + return removed, err + } + removed++ + } + return removed, nil +} diff --git a/internal/infrastructure/useCases/ExportActivity.go b/internal/infrastructure/useCases/ExportActivity.go index 8bcec66..eeefeda 100644 --- a/internal/infrastructure/useCases/ExportActivity.go +++ b/internal/infrastructure/useCases/ExportActivity.go @@ -11,7 +11,7 @@ import ( const exportBatchSize = 500 const formulaGuard = "'" -const formulaStarts = "=+-@" +const formulaStarts = "=+-@\t\r" var exportHeader = []string{"when", "actor", "action", "target", "outcome", "source", "ip"} diff --git a/internal/infrastructure/useCases/ResetPassword.go b/internal/infrastructure/useCases/ResetPassword.go index d45f0ec..e367df2 100644 --- a/internal/infrastructure/useCases/ResetPassword.go +++ b/internal/infrastructure/useCases/ResetPassword.go @@ -35,6 +35,10 @@ func (r *ResetPasswordUseCase) ResetPassword(ctx context.Context, email string, return user.ErrPasswordResetCodeExpired } + if err := user.ValidatePassword(newPassword); err != nil { + return err + } + hashedPassword, err := r.hasher.Hash(newPassword) if err != nil { return err diff --git a/internal/infrastructure/useCases/RevokeSession.go b/internal/infrastructure/useCases/RevokeSession.go new file mode 100644 index 0000000..4d47df7 --- /dev/null +++ b/internal/infrastructure/useCases/RevokeSession.go @@ -0,0 +1,32 @@ +package useCases + +import ( + "RenewCMS/internal/domain/session" + "time" +) + +type SessionRevocationUseCase struct { + sessions session.Repository + now func() time.Time +} + +func NewSessionRevocationUseCase(sessions session.Repository, now func() time.Time) *SessionRevocationUseCase { + return &SessionRevocationUseCase{sessions, now} +} + +func (s *SessionRevocationUseCase) Revoke(id string, expiresAt time.Time) error { + if err := session.ValidateID(id); err != nil { + return err + } + if !expiresAt.After(s.now()) { + return nil + } + return s.sessions.Revoke(session.Revocation{ID: id, ExpiresAt: expiresAt}) +} + +func (s *SessionRevocationUseCase) IsRevoked(id string) (bool, error) { + if err := session.ValidateID(id); err != nil { + return false, err + } + return s.sessions.IsRevoked(id) +} diff --git a/internal/infrastructure/useCases/auditFailures.go b/internal/infrastructure/useCases/auditFailures.go index 19bee3d..08fb6d1 100644 --- a/internal/infrastructure/useCases/auditFailures.go +++ b/internal/infrastructure/useCases/auditFailures.go @@ -44,6 +44,7 @@ var failureReasons = []failureReason{ {user.ErrVerificationCodeExpired, "The verification code has expired."}, {user.ErrInvalidDisplayName, "That display name is too long."}, {user.ErrUsernameTaken, "That username is already taken."}, + {user.ErrPasswordTooLong, "The password is longer than 72 bytes."}, {invite.ErrEmailHasAccount, "That email already has an account."}, {invite.ErrPendingInvite, "An invitation is already pending for that email."}, {invite.ErrInviteEmailNotSent, "The invitation email could not be sent."}, diff --git a/test/activity_usecases_test.go b/test/activity_usecases_test.go index 5dc3f5a..54b93ff 100644 --- a/test/activity_usecases_test.go +++ b/test/activity_usecases_test.go @@ -370,6 +370,8 @@ func TestActivityExportUseCase(t *testing.T) { {"target", "+1+1", "'+1+1"}, {"target", "-2", "'-2"}, {"actor", "@SUM(A1)", "'@SUM(A1)"}, + {"target", "\t=cmd", "'\t=cmd"}, + {"target", "\r=cmd", "'\r=cmd"}, {"target", "Spring release", "Spring release"}, {"target", "a=b", "a=b"}, } diff --git a/test/api_keys_http_test.go b/test/api_keys_http_test.go index 34cd1c1..331c522 100644 --- a/test/api_keys_http_test.go +++ b/test/api_keys_http_test.go @@ -12,6 +12,7 @@ import ( "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/hashing" + "RenewCMS/internal/infrastructure/imaging" "RenewCMS/internal/infrastructure/useCases" "crypto/sha256" "encoding/hex" @@ -125,7 +126,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv { ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites), GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher, recorder), - CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder), + CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder, imaging.NewMetadataStripper()), GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(), ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo(), plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()), RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder), diff --git a/test/authtoken_test.go b/test/authtoken_test.go index cea53b1..9a305d5 100644 --- a/test/authtoken_test.go +++ b/test/authtoken_test.go @@ -126,7 +126,9 @@ func TestAuthTokenDecodeRejections(t *testing.T) { "nbf not a number": {signedAuthTokenSegments("test-secret", header, `{"nbf":"soon"}`), authtoken.ErrInvalid}, "expired": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`), authtoken.ErrExpired}, "expiring exactly now": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Unix())+`}`), authtoken.ErrExpired}, - "not yet valid": {signedAuthTokenSegments("test-secret", header, `{"nbf":`+authTokenUnix(authTokenEpoch.Add(time.Second).Unix())+`}`), authtoken.ErrNotYetValid}, + "not yet valid": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"nbf":`+authTokenUnix(authTokenEpoch.Add(time.Second).Unix())+`}`), authtoken.ErrNotYetValid}, + "without exp": {signedAuthTokenSegments("test-secret", header, `{"user_id":4,"token_version":1}`), authtoken.ErrInvalid}, + "jti not a string": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"jti":7}`), authtoken.ErrInvalid}, "larger than the size budget": {strings.Repeat("a", 5000), authtoken.ErrInvalid}, } @@ -143,18 +145,8 @@ func TestAuthTokenDecodeRejections(t *testing.T) { }) } - t.Run("Accepts a token with no exp so sessions issued before expiry existed keep working", func(t *testing.T) { - legacy := signedAuthTokenSegments("test-secret", header, `{"user_id":4,"token_version":1}`) - - claims, err := auth.Decode(legacy) - - if err != nil || claims["user_id"] != float64(4) { - t.Errorf("expected legacy token accepted, got %v, %v", claims, err) - } - }) - t.Run("Accepts a token whose nbf has passed", func(t *testing.T) { - token := signedAuthTokenSegments("test-secret", header, `{"nbf":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`) + token := signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"nbf":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`) if _, err := auth.Decode(token); err != nil { t.Errorf("expected acceptance, got %v", err) @@ -179,3 +171,175 @@ func TestAuthTokenDecodeRejections(t *testing.T) { func authTokenUnix(value int64) string { return strconv.FormatInt(value, 10) } + +type memoryRevocationStore struct { + revoked map[string]time.Time + failure error +} + +func newMemoryRevocationStore() *memoryRevocationStore { + return &memoryRevocationStore{revoked: map[string]time.Time{}} +} + +func (m *memoryRevocationStore) Revoke(id string, expiresAt time.Time) error { + if m.failure != nil { + return m.failure + } + m.revoked[id] = expiresAt + return nil +} + +func (m *memoryRevocationStore) IsRevoked(id string) (bool, error) { + if m.failure != nil { + return false, m.failure + } + _, ok := m.revoked[id] + return ok, nil +} + +func TestAuthTokenRevocation(t *testing.T) { + newAuth := func(store *memoryRevocationStore) *authtoken.Auth { + return authtoken.New([]byte("test-secret"), authtoken.WithClock(func() time.Time { return authTokenEpoch }), authtoken.WithRevocationStore(store)) + } + + t.Run("Every encoded token carries a distinct jti", func(t *testing.T) { + auth := authTokenAt(authTokenEpoch) + first, _ := auth.Encode(map[string]any{"user_id": 1}) + second, _ := auth.Encode(map[string]any{"user_id": 1}) + + firstClaims, _ := auth.Decode(first) + secondClaims, _ := auth.Decode(second) + + id, ok := firstClaims["jti"].(string) + if !ok || id == "" { + t.Fatalf("expected a jti, got %v", firstClaims["jti"]) + } + if id == secondClaims["jti"] { + t.Errorf("expected distinct jti values, both were %q", id) + } + }) + + t.Run("An explicit jti is kept", func(t *testing.T) { + auth := authTokenAt(authTokenEpoch) + encoded, _ := auth.Encode(map[string]any{"jti": "fixed-id"}) + + claims, _ := auth.Decode(encoded) + + if claims["jti"] != "fixed-id" { + t.Errorf("expected jti fixed-id, got %v", claims["jti"]) + } + }) + + t.Run("A revoked token is rejected until it would have expired anyway", func(t *testing.T) { + store := newMemoryRevocationStore() + auth := newAuth(store) + encoded, _ := auth.Encode(map[string]any{"user_id": 1}) + claims, err := auth.Decode(encoded) + if err != nil { + t.Fatal(err) + } + + if err := auth.Revoke(claims); err != nil { + t.Fatal(err) + } + + if _, err := auth.Decode(encoded); !errors.Is(err, authtoken.ErrRevoked) { + t.Errorf("expected ErrRevoked, got %v", err) + } + if got := store.revoked[claims["jti"].(string)]; !got.Equal(authTokenEpoch.Add(authtoken.SessionLifetime)) { + t.Errorf("expected the revocation to last until the token's exp, got %v", got) + } + }) + + t.Run("Revoking one token leaves the others valid", func(t *testing.T) { + auth := newAuth(newMemoryRevocationStore()) + revoked, _ := auth.Encode(map[string]any{"user_id": 1}) + kept, _ := auth.Encode(map[string]any{"user_id": 1}) + claims, _ := auth.Decode(revoked) + + _ = auth.Revoke(claims) + + if _, err := auth.Decode(kept); err != nil { + t.Errorf("expected the other token to stay valid, got %v", err) + } + }) + + t.Run("A token is rejected when the revocation store cannot answer", func(t *testing.T) { + store := newMemoryRevocationStore() + auth := newAuth(store) + encoded, _ := auth.Encode(map[string]any{"user_id": 1}) + store.failure = errors.New("database unavailable") + + if _, err := auth.Decode(encoded); !errors.Is(err, authtoken.ErrInvalid) { + t.Errorf("expected ErrInvalid, got %v", err) + } + }) + + t.Run("Revoking surfaces a store failure", func(t *testing.T) { + store := newMemoryRevocationStore() + auth := newAuth(store) + encoded, _ := auth.Encode(map[string]any{"user_id": 1}) + claims, _ := auth.Decode(encoded) + store.failure = errors.New("database unavailable") + + if err := auth.Revoke(claims); err == nil { + t.Error("expected an error") + } + }) + + t.Run("Revoking claims without a jti is a no-op", func(t *testing.T) { + store := newMemoryRevocationStore() + + if err := newAuth(store).Revoke(authtoken.Claims{"user_id": float64(1), "exp": float64(authTokenEpoch.Unix())}); err != nil { + t.Fatal(err) + } + if len(store.revoked) != 0 { + t.Errorf("expected nothing revoked, got %v", store.revoked) + } + }) + + t.Run("Revoking claims without an exp is refused", func(t *testing.T) { + if err := newAuth(newMemoryRevocationStore()).Revoke(authtoken.Claims{"jti": "abc"}); !errors.Is(err, authtoken.ErrInvalid) { + t.Errorf("expected ErrInvalid, got %v", err) + } + }) + + t.Run("Without a store, revoking is a no-op and tokens stay valid", func(t *testing.T) { + auth := authTokenAt(authTokenEpoch) + encoded, _ := auth.Encode(map[string]any{"user_id": 1}) + claims, _ := auth.Decode(encoded) + + if err := auth.Revoke(claims); err != nil { + t.Fatal(err) + } + if _, err := auth.Decode(encoded); err != nil { + t.Errorf("expected the token to stay valid, got %v", err) + } + }) +} + +func TestAuthTokenSizeBudget(t *testing.T) { + auth := authTokenAt(authTokenEpoch) + sized := func(target int) string { + for padding := 0; padding < 4096; padding++ { + encoded, _ := auth.Encode(map[string]any{"jti": "fixed", "pad": strings.Repeat("a", padding)}) + if len(encoded) == target { + return encoded + } + } + t.Fatalf("no token of exactly %d bytes", target) + return "" + } + + t.Run("A token of exactly 4096 bytes is accepted", func(t *testing.T) { + if _, err := auth.Decode(sized(4096)); err != nil { + t.Errorf("expected acceptance, got %v", err) + } + }) + + t.Run("A token of 4097 bytes is refused before it is parsed", func(t *testing.T) { + if _, err := auth.Decode(sized(4097)); !errors.Is(err, authtoken.ErrInvalid) { + t.Errorf("expected ErrInvalid, got %v", err) + } + }) +} diff --git a/test/container_update_guidance_http_test.go b/test/container_update_guidance_http_test.go index 85ba71e..eb31b84 100644 --- a/test/container_update_guidance_http_test.go +++ b/test/container_update_guidance_http_test.go @@ -79,6 +79,7 @@ func TestContainerUpdateGuidanceHTTP(t *testing.T) { }{{"true", true}, {"TRUE", true}, {"false", false}, {"", false}, {"yes", false}} for _, tc := range cases { t.Run("value "+tc.value, func(t *testing.T) { + t.Setenv("JWT_SECRET", testJWTSecret) t.Setenv("SMTP_PORT", "1") t.Setenv("AUTH_RATE_LIMIT_MAX_ATTEMPTS", "1") t.Setenv("AUTH_RATE_LIMIT_WINDOW_SECONDS", "1") diff --git a/test/cross_origin_protection_http_test.go b/test/cross_origin_protection_http_test.go new file mode 100644 index 0000000..65526aa --- /dev/null +++ b/test/cross_origin_protection_http_test.go @@ -0,0 +1,105 @@ +package test + +import ( + "RenewCMS/api/middleware" + "RenewCMS/api/pkg/apperr" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func newCrossOriginProtectedHandler(publicURL string) (http.Handler, *int) { + reached := 0 + handler := middleware.CrossOriginProtection(publicURL)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reached++ + w.WriteHeader(http.StatusOK) + })) + return handler, &reached +} + +func sendCrossOriginRequest(handler http.Handler, method string, headers map[string]string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, "https://cms.example.com/v1/user/invite", strings.NewReader(`{"email":"attacker@evil.example"}`)) + req.Header.Set("Content-Type", "text/plain") + req.AddCookie(&http.Cookie{Name: "jwt", Value: "session"}) + for name, value := range headers { + req.Header.Set(name, value) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} + +func TestCrossOriginProtection(t *testing.T) { + refused := []struct { + name string + method string + headers map[string]string + }{ + {"a cross-site form POST", http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}}, + {"a same-site but cross-origin POST from a sibling subdomain", http.MethodPost, map[string]string{"Sec-Fetch-Site": "same-site", "Origin": "https://blog.example.com"}}, + {"a cross-site DELETE", http.MethodDelete, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}}, + {"an old browser's cross-origin POST without Sec-Fetch-Site", http.MethodPost, map[string]string{"Origin": "https://evil.example"}}, + } + for _, tc := range refused { + t.Run("Refuses "+tc.name, func(t *testing.T) { + handler, reached := newCrossOriginProtectedHandler("https://cms.example.com") + + rec := sendCrossOriginRequest(handler, tc.method, tc.headers) + + if rec.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), apperr.CrossOriginErrorMessage) { + t.Errorf("expected the cross-origin message, got %q", rec.Body.String()) + } + if *reached != 0 { + t.Error("the handler must not run") + } + }) + } + + allowed := []struct { + name string + method string + headers map[string]string + }{ + {"a same-origin POST from the SPA", http.MethodPost, map[string]string{"Sec-Fetch-Site": "same-origin", "Origin": "https://cms.example.com"}}, + {"a request typed in the address bar", http.MethodPost, map[string]string{"Sec-Fetch-Site": "none"}}, + {"a non-browser client with neither header", http.MethodPost, nil}, + {"a cross-site GET", http.MethodGet, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}}, + {"a cross-site preflight", http.MethodOptions, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}}, + {"a cross-origin POST carrying an explicit Authorization header", http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://shop.example", "Authorization": "Bearer rcms_key"}}, + {"an old browser's POST from the public URL behind a proxy that rewrites Host", http.MethodPost, map[string]string{"Origin": "https://cms.example.com", "Host": "127.0.0.1:8080"}}, + } + for _, tc := range allowed { + t.Run("Allows "+tc.name, func(t *testing.T) { + handler, reached := newCrossOriginProtectedHandler("https://cms.example.com") + req := httptest.NewRequest(tc.method, "https://cms.example.com/v1/user/invite", nil) + for name, value := range tc.headers { + if name == "Host" { + req.Host = value + continue + } + req.Header.Set(name, value) + } + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK || *reached != 1 { + t.Errorf("expected the handler to run, got %d (reached %d)", rec.Code, *reached) + } + }) + } + + t.Run("An unparsable public URL still protects against cross-site requests", func(t *testing.T) { + handler, reached := newCrossOriginProtectedHandler("::not a url") + + rec := sendCrossOriginRequest(handler, http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}) + + if rec.Code != http.StatusForbidden || *reached != 0 { + t.Errorf("expected 403 without reaching the handler, got %d (reached %d)", rec.Code, *reached) + } + }) +} diff --git a/test/hasher_test.go b/test/hasher_test.go index ab92dd3..85dd9aa 100644 --- a/test/hasher_test.go +++ b/test/hasher_test.go @@ -106,17 +106,38 @@ func TestAuthenticateUserUseCase(t *testing.T) { } }) - t.Run("unknown username never calls the hasher", func(t *testing.T) { + t.Run("unknown username spends the same hashing work as a wrong password", func(t *testing.T) { repo := newMockUserRepo() hasher := &spyHasher{} uc := useCases.NewAuthenticateUserUseCase(repo, hasher, discardRecorder) - _, err := uc.Authenticate(testCtx, "bob", "hunter2example") - if !errors.Is(err, user.ErrUserNotFound) { - t.Errorf("expected user.ErrUserNotFound, got %v", err) + for attempt := 1; attempt <= 2; attempt++ { + _, err := uc.Authenticate(testCtx, "bob", "hunter2example") + if !errors.Is(err, user.ErrUserNotFound) { + t.Errorf("attempt %d: expected user.ErrUserNotFound, got %v", attempt, err) + } + if hasher.compareCalls != attempt { + t.Errorf("attempt %d: expected %d Compare calls, got %d", attempt, attempt, hasher.compareCalls) + } } - if hasher.compareCalls != 0 { - t.Errorf("expected the hasher's Compare to never be called, got %d calls", hasher.compareCalls) + if hasher.hashCalls != 1 { + t.Errorf("expected the decoy to be hashed once, got %d Hash calls", hasher.hashCalls) + } + }) + + t.Run("a repository failure other than not-found skips the decoy", func(t *testing.T) { + repo := newMockUserRepo() + repo.fail.failNextWith("GetByUsername", errStubHashFailure) + hasher := &spyHasher{} + uc := useCases.NewAuthenticateUserUseCase(repo, hasher, discardRecorder) + + _, err := uc.Authenticate(testCtx, "bob", "hunter2example") + + if !errors.Is(err, errStubHashFailure) { + t.Errorf("expected the repository error, got %v", err) + } + if hasher.compareCalls != 0 || hasher.hashCalls != 0 { + t.Errorf("expected no hashing, got %d Compare and %d Hash calls", hasher.compareCalls, hasher.hashCalls) } }) } diff --git a/test/image_metadata_stripping_test.go b/test/image_metadata_stripping_test.go new file mode 100644 index 0000000..d4509dd --- /dev/null +++ b/test/image_metadata_stripping_test.go @@ -0,0 +1,727 @@ +package test + +import ( + domainImage "RenewCMS/internal/domain/image" + "RenewCMS/internal/infrastructure/imaging" + "RenewCMS/internal/infrastructure/useCases" + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "hash/crc32" + "image" + "image/color" + "image/jpeg" + "image/png" + "strings" + "testing" + + "github.com/gen2brain/webp" + xwebp "golang.org/x/image/webp" +) + +const metadataSecret = "GPS-48.8584N-2.2945E" + +func metadataTestImage() *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, 8, 6)) + for y := 0; y < 6; y++ { + for x := 0; x < 8; x++ { + img.Set(x, y, color.NRGBA{R: uint8(x * 30), G: uint8(y * 40), B: 90, A: 255}) + } + } + return img +} + +type exifByteOrder interface { + binary.ByteOrder + binary.AppendByteOrder +} + +func exifTIFF(order exifByteOrder, orientation uint16) []byte { + marker := []byte("II") + if order.String() == binary.BigEndian.String() { + marker = []byte("MM") + } + text := append([]byte(metadataSecret), 0) + if len(text)%2 == 1 { + text = append(text, 0) + } + ifd := uint32(8 + len(text)) + tiff := append(marker, 0, 0) + order.PutUint16(tiff[2:], 42) + tiff = order.AppendUint32(tiff, ifd) + tiff = append(tiff, text...) + tiff = order.AppendUint16(tiff, 2) + tiff = order.AppendUint16(tiff, 0x010F) + tiff = order.AppendUint16(tiff, 2) + tiff = order.AppendUint32(tiff, uint32(len(metadataSecret)+1)) + tiff = order.AppendUint32(tiff, 8) + tiff = order.AppendUint16(tiff, 0x0112) + tiff = order.AppendUint16(tiff, 3) + tiff = order.AppendUint32(tiff, 1) + tiff = order.AppendUint16(tiff, orientation) + tiff = order.AppendUint16(tiff, 0) + return order.AppendUint32(tiff, 0) +} + +func jpegSegmentBytes(marker byte, payload []byte) []byte { + segment := []byte{0xFF, marker} + segment = binary.BigEndian.AppendUint16(segment, uint16(len(payload)+2)) + return append(segment, payload...) +} + +func encodedJPEG(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + if err := jpeg.Encode(&buf, metadataTestImage(), &jpeg.Options{Quality: 90}); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func jpegWithMetadata(t *testing.T, orientation uint16, extra ...[]byte) []byte { + t.Helper() + plain := encodedJPEG(t) + out := append([]byte{}, plain[:2]...) + for _, segment := range extra { + out = append(out, segment...) + } + out = append(out, jpegSegmentBytes(0xE1, append([]byte("Exif\x00\x00"), exifTIFF(binary.LittleEndian, orientation)...))...) + out = append(out, jpegSegmentBytes(0xE1, []byte("http://ns.adobe.com/xap/1.0/\x00"+metadataSecret+""))...) + out = append(out, jpegSegmentBytes(0xED, []byte("Photoshop 3.0\x008BIM"+metadataSecret))...) + out = append(out, jpegSegmentBytes(0xFE, []byte("comment "+metadataSecret))...) + out = append(out, jpegSegmentBytes(0xE2, []byte("MPF\x00"+metadataSecret))...) + out = append(out, jpegSegmentBytes(0xE2, []byte("ICC_PROFILE\x00\x01\x01profile-bytes"))...) + out = append(out, plain[2:]...) + return append(out, []byte("\xFF\xD8trailing image "+metadataSecret+"\xFF\xD9")...) +} + +func pngChunkBytes(kind string, payload []byte) []byte { + chunk := binary.BigEndian.AppendUint32(nil, uint32(len(payload))) + chunk = append(chunk, kind...) + chunk = append(chunk, payload...) + return binary.BigEndian.AppendUint32(chunk, crc32.ChecksumIEEE(chunk[4:])) +} + +func encodedPNG(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + if err := png.Encode(&buf, metadataTestImage()); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func pngWithMetadata(t *testing.T, orientation uint16) []byte { + t.Helper() + plain := encodedPNG(t) + afterIHDR := 8 + 8 + 13 + 4 + out := append([]byte{}, plain[:afterIHDR]...) + out = append(out, pngChunkBytes("eXIf", exifTIFF(binary.BigEndian, orientation))...) + out = append(out, pngChunkBytes("tEXt", []byte("Comment\x00"+metadataSecret))...) + out = append(out, pngChunkBytes("iTXt", []byte("XML:com.adobe.xmp\x00\x00\x00\x00\x00"+metadataSecret))...) + out = append(out, pngChunkBytes("zTXt", []byte("Author\x00\x00"+metadataSecret))...) + out = append(out, pngChunkBytes("tIME", []byte{0x07, 0xEA, 10, 22, 9, 0, 0})...) + out = append(out, plain[afterIHDR:]...) + return append(out, []byte("trailing "+metadataSecret)...) +} + +func riffChunkBytes(kind string, payload []byte) []byte { + chunk := append([]byte(kind), binary.LittleEndian.AppendUint32(nil, uint32(len(payload)))...) + chunk = append(chunk, payload...) + if len(payload)%2 == 1 { + chunk = append(chunk, 0) + } + return chunk +} + +func webpWithMetadata(t *testing.T, orientation uint16) []byte { + t.Helper() + var buf bytes.Buffer + if err := webp.Encode(&buf, metadataTestImage(), webp.Options{Lossless: true}); err != nil { + t.Fatal(err) + } + simple := buf.Bytes() + vp8x := []byte{0x08 | 0x04, 0, 0, 0} + vp8x = append(vp8x, 7, 0, 0, 5, 0, 0) + body := []byte("WEBP") + body = append(body, riffChunkBytes("VP8X", vp8x)...) + body = append(body, simple[12:]...) + body = append(body, riffChunkBytes("EXIF", exifTIFF(binary.LittleEndian, orientation))...) + body = append(body, riffChunkBytes("XMP ", []byte(""+metadataSecret+"!"))...) + out := append([]byte("RIFF"), binary.LittleEndian.AppendUint32(nil, uint32(len(body)))...) + out = append(out, body...) + return append(out, []byte("trailing "+metadataSecret)...) +} + +func assertSamePixels(t *testing.T, want image.Image, got image.Image) { + t.Helper() + if want.Bounds() != got.Bounds() { + t.Fatalf("bounds changed: %v vs %v", want.Bounds(), got.Bounds()) + } + for y := want.Bounds().Min.Y; y < want.Bounds().Max.Y; y++ { + for x := want.Bounds().Min.X; x < want.Bounds().Max.X; x++ { + if want.At(x, y) != got.At(x, y) { + t.Fatalf("pixel (%d,%d) changed: %v vs %v", x, y, want.At(x, y), got.At(x, y)) + } + } + } +} + +func assertNoSecret(t *testing.T, data []byte) { + t.Helper() + if bytes.Contains(data, []byte(metadataSecret)) { + t.Errorf("the metadata secret survived stripping") + } +} + +func orientationOnlyEntry(orientation uint16) []byte { + entry := []byte{0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01} + return binary.BigEndian.AppendUint16(entry, orientation) +} + +func TestMetadataStripperJPEG(t *testing.T) { + stripper := imaging.NewMetadataStripper() + + t.Run("Drops EXIF, XMP, IPTC, comments, MPF and trailing images but keeps the pixels and the ICC profile", func(t *testing.T) { + original := jpegWithMetadata(t, 1) + + stripped, err := stripper.Strip("image/jpeg", original) + + if err != nil { + t.Fatal(err) + } + assertNoSecret(t, stripped) + if !bytes.Contains(stripped, []byte("ICC_PROFILE\x00")) { + t.Error("the ICC profile must be kept") + } + if bytes.Contains(stripped, []byte("Exif\x00\x00")) { + t.Error("an upright image needs no EXIF at all") + } + if !bytes.HasSuffix(stripped, []byte{0xFF, 0xD9}) || bytes.Count(stripped, []byte{0xFF, 0xD8}) != 1 { + t.Error("the output must end at the primary image's EOI") + } + want, _ := jpeg.Decode(bytes.NewReader(encodedJPEG(t))) + got, err := jpeg.Decode(bytes.NewReader(stripped)) + if err != nil { + t.Fatalf("the stripped JPEG does not decode: %v", err) + } + assertSamePixels(t, want, got) + }) + + t.Run("Keeps only the orientation of a rotated photo, right after SOI", func(t *testing.T) { + stripped, err := stripper.Strip("image/jpeg", jpegWithMetadata(t, 6)) + + if err != nil { + t.Fatal(err) + } + assertNoSecret(t, stripped) + if !bytes.Equal(stripped[2:4], []byte{0xFF, 0xE1}) || !bytes.Contains(stripped[:64], orientationOnlyEntry(6)) { + t.Errorf("expected an orientation-only APP1 after SOI, got % x", stripped[:48]) + } + if _, err := jpeg.Decode(bytes.NewReader(stripped)); err != nil { + t.Errorf("the stripped JPEG does not decode: %v", err) + } + }) + + t.Run("Puts the orientation after a leading JFIF header", func(t *testing.T) { + jfif := jpegSegmentBytes(0xE0, []byte("JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")) + + stripped, err := stripper.Strip("image/jpeg", jpegWithMetadata(t, 8, jfif)) + + if err != nil { + t.Fatal(err) + } + next := 2 + len(jfif) + if !bytes.Equal(stripped[2:next], jfif) || !bytes.Equal(stripped[next:next+2], []byte{0xFF, 0xE1}) { + t.Errorf("expected JFIF then the orientation APP1, got % x", stripped[:next+4]) + } + }) + + t.Run("A JPEG cut off inside its scan keeps what it has", func(t *testing.T) { + plain := encodedJPEG(t) + truncated := plain[:len(plain)-20] + + stripped, err := stripper.Strip("image/jpeg", truncated) + + if err != nil || !bytes.Equal(stripped, truncated) { + t.Errorf("expected the truncated scan kept as is, got %d bytes, %v", len(stripped), err) + } + }) + + malformed := map[string][]byte{ + "not a JPEG": []byte("GIF89a"), + "a segment longer than the file": {0xFF, 0xD8, 0xFF, 0xE1, 0x10, 0x00, 'E'}, + "a segment length below two": {0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x01}, + "a missing marker": {0xFF, 0xD8, 0x00, 0x00}, + "a header that stops early": {0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0}, + } + for name, data := range malformed { + t.Run("Refuses "+name, func(t *testing.T) { + if _, err := stripper.Strip("image/jpeg", data); !errors.Is(err, domainImage.ErrImageUnreadable) { + t.Errorf("expected ErrImageUnreadable, got %v", err) + } + }) + } +} + +func TestMetadataStripperPNG(t *testing.T) { + stripper := imaging.NewMetadataStripper() + + t.Run("Drops text, time and EXIF chunks and trailing data but keeps the pixels", func(t *testing.T) { + stripped, err := stripper.Strip("image/png", pngWithMetadata(t, 1)) + + if err != nil { + t.Fatal(err) + } + assertNoSecret(t, stripped) + for _, kind := range []string{"eXIf", "tEXt", "iTXt", "zTXt", "tIME"} { + if bytes.Contains(stripped, []byte(kind)) { + t.Errorf("%s survived", kind) + } + } + if !bytes.Equal(stripped, encodedPNG(t)) { + t.Error("expected exactly the plain PNG back") + } + }) + + t.Run("Keeps only the orientation in an eXIf chunk right after IHDR", func(t *testing.T) { + stripped, err := stripper.Strip("image/png", pngWithMetadata(t, 3)) + + if err != nil { + t.Fatal(err) + } + assertNoSecret(t, stripped) + afterIHDR := 8 + 8 + 13 + 4 + if string(stripped[afterIHDR+4:afterIHDR+8]) != "eXIf" || !bytes.Contains(stripped, orientationOnlyEntry(3)) { + t.Errorf("expected an orientation-only eXIf after IHDR") + } + want, _ := png.Decode(bytes.NewReader(encodedPNG(t))) + got, err := png.Decode(bytes.NewReader(stripped)) + if err != nil { + t.Fatalf("the stripped PNG does not decode: %v", err) + } + assertSamePixels(t, want, got) + }) + + plain := func(t *testing.T) []byte { return encodedPNG(t) } + malformed := map[string]func(t *testing.T) []byte{ + "not a PNG": func(*testing.T) []byte { return []byte("GIF89a") }, + "a PNG without IEND": func(t *testing.T) []byte { p := plain(t); return p[:len(p)-12] }, + "a chunk longer than the file": func(t *testing.T) []byte { p := plain(t); binary.BigEndian.PutUint32(p[8:], 1<<30); return p }, + "a chunk header that stops early": func(t *testing.T) []byte { return append(plain(t)[:8], 0, 0, 0) }, + } + for name, build := range malformed { + t.Run("Refuses "+name, func(t *testing.T) { + if _, err := stripper.Strip("image/png", build(t)); !errors.Is(err, domainImage.ErrImageUnreadable) { + t.Errorf("expected ErrImageUnreadable, got %v", err) + } + }) + } +} + +func TestMetadataStripperWebP(t *testing.T) { + stripper := imaging.NewMetadataStripper() + + t.Run("Drops XMP and EXIF, clears their flags and keeps the pixels", func(t *testing.T) { + original := webpWithMetadata(t, 1) + + stripped, err := stripper.Strip("image/webp", original) + + if err != nil { + t.Fatal(err) + } + assertNoSecret(t, stripped) + if bytes.Contains(stripped, []byte("EXIF")) || bytes.Contains(stripped, []byte("XMP ")) { + t.Error("metadata chunks survived") + } + if flags := stripped[20]; flags&0x0C != 0 { + t.Errorf("expected the EXIF and XMP flags cleared, got %#x", flags) + } + if size := binary.LittleEndian.Uint32(stripped[4:]); int(size) != len(stripped)-8 { + t.Errorf("RIFF size %d does not match %d bytes", size, len(stripped)-8) + } + want, _ := xwebp.Decode(bytes.NewReader(original)) + got, err := xwebp.Decode(bytes.NewReader(stripped)) + if err != nil { + t.Fatalf("the stripped WebP does not decode: %v", err) + } + assertSamePixels(t, want, got) + }) + + t.Run("Keeps only the orientation of a rotated image", func(t *testing.T) { + stripped, err := stripper.Strip("image/webp", webpWithMetadata(t, 8)) + + if err != nil { + t.Fatal(err) + } + assertNoSecret(t, stripped) + if flags := stripped[20]; flags&0x0C != 0x08 { + t.Errorf("expected only the EXIF flag set, got %#x", flags) + } + if !bytes.Contains(stripped, orientationOnlyEntry(8)) { + t.Error("expected an orientation-only EXIF chunk") + } + if _, err := xwebp.Decode(bytes.NewReader(stripped)); err != nil { + t.Errorf("the stripped WebP does not decode: %v", err) + } + }) + + t.Run("A simple WebP without extended header comes back unchanged", func(t *testing.T) { + var buf bytes.Buffer + if err := webp.Encode(&buf, metadataTestImage(), webp.Options{Lossless: true}); err != nil { + t.Fatal(err) + } + + stripped, err := stripper.Strip("image/webp", buf.Bytes()) + + if err != nil || !bytes.Equal(stripped, buf.Bytes()) { + t.Errorf("expected the same bytes back, got %v", err) + } + }) + + malformed := map[string][]byte{ + "not a WebP": []byte("RIFF\x04\x00\x00\x00WAVE"), + "a RIFF size beyond the file": []byte("RIFF\xff\x00\x00\x00WEBP"), + "a chunk longer than the file": append([]byte("RIFF\x10\x00\x00\x00WEBPVP8L"), 0xff, 0, 0, 0), + "a container with no chunks": []byte("RIFF\x04\x00\x00\x00WEBP"), + "an empty extended header": append([]byte("RIFF\x0c\x00\x00\x00WEBPVP8X"), 0, 0, 0, 0), + "a chunk header that stops early": []byte("RIFF\x08\x00\x00\x00WEBPVP8X"), + } + for name, data := range malformed { + t.Run("Refuses "+name, func(t *testing.T) { + if _, err := stripper.Strip("image/webp", data); !errors.Is(err, domainImage.ErrImageUnreadable) { + t.Errorf("expected ErrImageUnreadable, got %v", err) + } + }) + } +} + +func TestMetadataStripperLeavesSVGAlone(t *testing.T) { + svg := []byte(`keep`) + + stripped, err := imaging.NewMetadataStripper().Strip("image/svg+xml", svg) + + if err != nil || !bytes.Equal(stripped, svg) { + t.Errorf("expected the SVG untouched, got %v", err) + } +} + +func TestUploadStoresTheStrippedImage(t *testing.T) { + repo := newMockImageRepo() + store := newMockImageStorage() + uc := useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper()) + original := jpegWithMetadata(t, 6) + + created, err := uc.CreateImage(testCtx, domainImage.ImageInput{ + Content: bytes.NewReader(original), + Filename: "holiday.jpg", + Size: int64(len(original)), + ContentType: "image/jpeg", + }) + + if err != nil { + t.Fatal(err) + } + stored := store.lastContent + assertNoSecret(t, stored) + if created.Size != int64(len(stored)) { + t.Errorf("expected the recorded size %d to match the stored %d bytes", created.Size, len(stored)) + } + sum := sha256.Sum256(stored) + if created.SHA256 != hex.EncodeToString(sum[:]) { + t.Error("expected the checksum of the stored bytes") + } + if created.Width != 8 || created.Height != 6 { + t.Errorf("expected 8x6, got %dx%d", created.Width, created.Height) + } +} + +func TestUploadRefusesAnImageWhoseContainerIsBroken(t *testing.T) { + store := newMockImageStorage() + uc := useCases.NewCreateImageUseCase(newMockImageRepo(), store, discardRecorder, imaging.NewMetadataStripper()) + broken := encodedPNG(t) + broken = broken[:len(broken)-12] + + _, err := uc.CreateImage(testCtx, domainImage.ImageInput{Content: bytes.NewReader(broken), Filename: "broken.png", ContentType: "image/png"}) + + if !errors.Is(err, domainImage.ErrImageUnreadable) { + t.Errorf("expected ErrImageUnreadable, got %v", err) + } + if store.putCalls != 0 { + t.Error("nothing may be stored") + } +} + +func hexBytes(t *testing.T, value string) []byte { + t.Helper() + decoded, err := hex.DecodeString(strings.ReplaceAll(value, " ", "")) + if err != nil { + t.Fatal(err) + } + return decoded +} + +func jpegWithExif(tiff []byte) []byte { + out := []byte{0xFF, 0xD8} + out = append(out, jpegSegmentBytes(0xE1, append([]byte("Exif\x00\x00"), tiff...))...) + return append(out, 0xFF, 0xD9) +} + +func TestMetadataStripperJPEGStructure(t *testing.T) { + stripper := imaging.NewMetadataStripper() + + kept := map[string]string{ + "the smallest JPEG, SOI then EOI": "FFD8 FFD9", + "standalone TEM and RST markers between segments": "FFD8 FF01 FFD0 FFD7 FFD9", + "restart markers and stuffed bytes inside a scan": "FFD8 FFDA 0002 11 FF00 22 FFD0 33 FFD7 44 FFD9", + "a scan cut off right after a 0xFF byte": "FFD8 FFDA 0002 11 22 FF", + "a scan cut off right after a stuffed byte": "FFD8 FFDA 0002 11 FF00", + "an Adobe APP14 segment": "FFD8 FFEE 0007 41646F6265 FFD9", + "an ICC profile in APP2": "FFD8 FFE2 000E 4943435F50524F46494C4500 FFD9", + "a quantisation table, which is not metadata at all": "FFD8 FFDB 0003 00 FFD9", + } + for name, value := range kept { + t.Run("Keeps "+name, func(t *testing.T) { + data := hexBytes(t, value) + + stripped, err := stripper.Strip("image/jpeg", data) + + if err != nil || !bytes.Equal(stripped, data) { + t.Errorf("expected the bytes back unchanged, got % X, %v", stripped, err) + } + }) + } + + removed := map[string]string{ + "fill bytes before a marker": "FFD8 FFFFFF FFD9", + "an empty comment": "FFD8 FFFE 0002 FFD9", + "an APP3 segment": "FFD8 FFE3 0003 00 FFD9", + "an APP12 segment": "FFD8 FFEC 0003 00 FFD9", + "an APP15 segment": "FFD8 FFEF 0003 00 FFD9", + "a FlashPix APP2 segment": "FFD8 FFE2 0006 46505852 FFD9", + "an XMP APP1 segment": "FFD8 FFE1 0006 68747470 FFD9", + } + for name, value := range removed { + t.Run("Drops "+name, func(t *testing.T) { + stripped, err := stripper.Strip("image/jpeg", hexBytes(t, value)) + + if err != nil || !bytes.Equal(stripped, []byte{0xFF, 0xD8, 0xFF, 0xD9}) { + t.Errorf("expected only SOI and EOI, got % X, %v", stripped, err) + } + }) + } + + refused := map[string]string{ + "only three bytes": "FFD8 FF", + "fill bytes up to the end": "FFD8 FFFF", + "a segment length cut in half": "FFD8 FFE1 00", + "a segment whose length runs past EOF": "FFD8 FFE1 0004 00", + "no EOI after the header": "FFD8 FFDB 0003 00", + } + for name, value := range refused { + t.Run("Refuses "+name, func(t *testing.T) { + if _, err := stripper.Strip("image/jpeg", hexBytes(t, value)); !errors.Is(err, domainImage.ErrImageUnreadable) { + t.Errorf("expected ErrImageUnreadable, got %v", err) + } + }) + } +} + +func TestMetadataStripperReadsOnlyAWellFormedOrientation(t *testing.T) { + stripper := imaging.NewMetadataStripper() + valid := exifTIFF(binary.BigEndian, 8) + ifd := int(binary.BigEndian.Uint32(valid[4:])) + withValue := func(value uint16) []byte { + tiff := bytes.Clone(valid) + binary.BigEndian.PutUint16(tiff[ifd+2+12+8:], value) + return tiff + } + withType := func(kind uint16) []byte { + tiff := bytes.Clone(valid) + binary.BigEndian.PutUint16(tiff[ifd+2+12+2:], kind) + return tiff + } + withIFD := func(offset uint32) []byte { + tiff := bytes.Clone(valid) + binary.BigEndian.PutUint32(tiff[4:], offset) + return tiff + } + withCount := func(count uint16) []byte { + tiff := bytes.Clone(valid) + binary.BigEndian.PutUint16(tiff[ifd:], count) + return tiff[:ifd+2+12] + } + + cases := []struct { + name string + tiff []byte + want uint16 + }{ + {"a valid big-endian orientation", valid, 8}, + {"a valid little-endian orientation", exifTIFF(binary.LittleEndian, 5), 5}, + {"the smallest orientation that needs rotating", withValue(2), 2}, + {"an orientation of 0", withValue(0), 0}, + {"an orientation of 9", withValue(9), 0}, + {"an orientation stored as a LONG", withType(4), 0}, + {"a TIFF shorter than its header", valid[:7], 0}, + {"an unknown byte order", append([]byte("XX"), valid[2:]...), 0}, + {"a wrong magic number", append([]byte("MM\x00\x2B"), valid[4:]...), 0}, + {"an IFD offset inside the header", withIFD(4), 0}, + {"an IFD offset on the last byte", withIFD(uint32(len(valid) - 1)), 0}, + {"an IFD offset two bytes before the end", withIFD(uint32(len(valid) - 2)), 0}, + {"an entry count larger than the entries present", withCount(3), 0}, + {"a TIFF cut inside the orientation entry", valid[:ifd+2+12+11], 0}, + {"a TIFF that ends right after the orientation entry", valid[:ifd+2+24], 8}, + {"an IFD that starts right after the header", hexBytes(t, "4D4D002A 00000008 0001 0112 0003 00000001 0003 0000 00000000"), 3}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stripped, err := stripper.Strip("image/jpeg", jpegWithExif(tc.tiff)) + + if err != nil { + t.Fatal(err) + } + hasOrientation := bytes.Contains(stripped, []byte("Exif\x00\x00")) + if tc.want == 0 && hasOrientation { + t.Errorf("expected no orientation kept, got % X", stripped) + } + if tc.want != 0 && !bytes.Contains(stripped, orientationOnlyEntry(tc.want)) { + t.Errorf("expected orientation %d kept, got % X", tc.want, stripped) + } + }) + } +} + +func TestMetadataStripperPNGStructure(t *testing.T) { + stripper := imaging.NewMetadataStripper() + plain := encodedPNG(t) + iend := plain[len(plain)-12:] + body := plain[:len(plain)-12] + + t.Run("Keeps an empty ancillary chunk it does not know", func(t *testing.T) { + data := append(append(bytes.Clone(body), pngChunkBytes("abCd", nil)...), iend...) + + stripped, err := stripper.Strip("image/png", data) + + if err != nil || !bytes.Equal(stripped, data) { + t.Errorf("expected the bytes back unchanged, got %v", err) + } + }) + + t.Run("Refuses a chunk one byte longer than the file", func(t *testing.T) { + data := bytes.Clone(plain) + binary.BigEndian.PutUint32(data[len(data)-12:], 1) + + if _, err := stripper.Strip("image/png", data); !errors.Is(err, domainImage.ErrImageUnreadable) { + t.Errorf("expected ErrImageUnreadable, got %v", err) + } + }) + + t.Run("Refuses an IEND missing its last CRC byte", func(t *testing.T) { + if _, err := stripper.Strip("image/png", plain[:len(plain)-1]); !errors.Is(err, domainImage.ErrImageUnreadable) { + t.Errorf("expected ErrImageUnreadable, got %v", err) + } + }) + + t.Run("Puts a kept orientation right after IHDR even when IHDR is followed by other chunks", func(t *testing.T) { + afterIHDR := 8 + 8 + 13 + 4 + data := append(bytes.Clone(plain[:afterIHDR]), pngChunkBytes("gAMA", []byte{0, 0, 0xB1, 0x8F})...) + data = append(data, pngChunkBytes("eXIf", exifTIFF(binary.LittleEndian, 6))...) + data = append(data, plain[afterIHDR:]...) + + stripped, err := stripper.Strip("image/png", data) + + if err != nil { + t.Fatal(err) + } + if string(stripped[afterIHDR+4:afterIHDR+8]) != "eXIf" || string(stripped[afterIHDR+12+26+4:afterIHDR+12+26+8]) != "gAMA" { + t.Errorf("expected eXIf then gAMA after IHDR") + } + if _, err := png.Decode(bytes.NewReader(stripped)); err != nil { + t.Errorf("the stripped PNG does not decode: %v", err) + } + }) + + t.Run("Refuses a rotated PNG with no IHDR to put the orientation after", func(t *testing.T) { + data := append(bytes.Clone(pngSignatureBytes), pngChunkBytes("eXIf", exifTIFF(binary.BigEndian, 6))...) + data = append(data, pngChunkBytes("IEND", nil)...) + + if _, err := stripper.Strip("image/png", data); !errors.Is(err, domainImage.ErrImageUnreadable) { + t.Errorf("expected ErrImageUnreadable, got %v", err) + } + }) +} + +var pngSignatureBytes = []byte("\x89PNG\r\n\x1a\n") + +func riffChunkKinds(t *testing.T, data []byte) []string { + t.Helper() + if int(binary.LittleEndian.Uint32(data[4:]))+8 != len(data) { + t.Fatalf("RIFF size %d does not match %d bytes", binary.LittleEndian.Uint32(data[4:]), len(data)-8) + } + var kinds []string + for pos := 12; pos < len(data); { + size := int(binary.LittleEndian.Uint32(data[pos+4:])) + kinds = append(kinds, string(data[pos:pos+4])) + pos += 8 + size + size%2 + if pos > len(data) { + t.Fatalf("chunk %s runs past the end", kinds[len(kinds)-1]) + } + } + return kinds +} + +func TestMetadataStripperWebPStructure(t *testing.T) { + stripper := imaging.NewMetadataStripper() + + t.Run("The rotated output is a well-formed chain of chunks", func(t *testing.T) { + stripped, err := stripper.Strip("image/webp", webpWithMetadata(t, 6)) + + if err != nil { + t.Fatal(err) + } + if got := strings.Join(riffChunkKinds(t, stripped), ","); got != "VP8X,VP8L,EXIF" { + t.Errorf("expected VP8X,VP8L,EXIF, got %s", got) + } + }) + + t.Run("An upright output keeps only the image chunks", func(t *testing.T) { + stripped, err := stripper.Strip("image/webp", webpWithMetadata(t, 1)) + + if err != nil { + t.Fatal(err) + } + if got := strings.Join(riffChunkKinds(t, stripped), ","); got != "VP8X,VP8L" { + t.Errorf("expected VP8X,VP8L, got %s", got) + } + }) + + t.Run("Keeps an empty chunk it does not know at the very end", func(t *testing.T) { + original := webpWithMetadata(t, 1) + declared := int(binary.LittleEndian.Uint32(original[4:])) + 8 + data := append(bytes.Clone(original[:declared]), riffChunkBytes("ABCD", nil)...) + binary.LittleEndian.PutUint32(data[4:], uint32(len(data)-8)) + + stripped, err := stripper.Strip("image/webp", data) + + if err != nil { + t.Fatal(err) + } + if got := strings.Join(riffChunkKinds(t, stripped), ","); got != "VP8X,VP8L,ABCD" { + t.Errorf("expected VP8X,VP8L,ABCD, got %s", got) + } + }) + + t.Run("Refuses an odd-sized chunk missing its padding byte", func(t *testing.T) { + original := webpWithMetadata(t, 1) + declared := int(binary.LittleEndian.Uint32(original[4:])) + 8 + data := append(bytes.Clone(original[:declared]), "ABCD\x01\x00\x00\x00X"...) + binary.LittleEndian.PutUint32(data[4:], uint32(len(data)-8)) + + if _, err := stripper.Strip("image/webp", data); !errors.Is(err, domainImage.ErrImageUnreadable) { + t.Errorf("expected ErrImageUnreadable, got %v", err) + } + }) +} diff --git a/test/image_test.go b/test/image_test.go index 21a7392..53e818d 100644 --- a/test/image_test.go +++ b/test/image_test.go @@ -2,6 +2,7 @@ package test import ( imageDomain "RenewCMS/internal/domain/image" + "RenewCMS/internal/infrastructure/imaging" "RenewCMS/internal/infrastructure/useCases" "errors" "io" @@ -280,7 +281,7 @@ var _ imageDomain.Repository = &mockImageRepo{} func TestImageUseCases(t *testing.T) { repo := newMockImageRepo() - createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder) + createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper()) deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()) t.Run("Create Image", func(t *testing.T) { diff --git a/test/image_upload_rules_test.go b/test/image_upload_rules_test.go index af038fe..b67be71 100644 --- a/test/image_upload_rules_test.go +++ b/test/image_upload_rules_test.go @@ -2,6 +2,7 @@ package test import ( imageDomain "RenewCMS/internal/domain/image" + "RenewCMS/internal/infrastructure/imaging" "RenewCMS/internal/infrastructure/useCases" "bytes" "errors" @@ -39,7 +40,7 @@ func TestImageUploadRules(t *testing.T) { for _, contentType := range []string{"image/png", "image/jpeg", "image/webp", "image/svg+xml"} { t.Run(contentType, func(t *testing.T) { repo := newMockImageRepo() - createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder) + createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper()) result, err := createUc.CreateImage(testCtx, inputOf(paddedTo(validBytesFor(t, contentType), int(imageDomain.MaxImageSize)), contentType)) @@ -58,7 +59,7 @@ func TestImageUploadRules(t *testing.T) { t.Run("CreateImage rejects a disallowed content type before reaching the repository", func(t *testing.T) { repo := newMockImageRepo() - createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder) + createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper()) _, err := createUc.CreateImage(testCtx, inputOf([]byte("%PDF"), "application/pdf")) @@ -72,7 +73,7 @@ func TestImageUploadRules(t *testing.T) { t.Run("CreateImage rejects a file that exceeds the size limit", func(t *testing.T) { repo := newMockImageRepo() - createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder) + createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper()) _, err := createUc.CreateImage(testCtx, inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)+1), "image/png")) @@ -86,7 +87,7 @@ func TestImageUploadRules(t *testing.T) { t.Run("A file exactly at the size limit is accepted, one byte over is not", func(t *testing.T) { repo := newMockImageRepo() - createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder) + createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper()) _, err := createUc.CreateImage(testCtx, inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)), "image/png")) if err != nil { @@ -108,7 +109,7 @@ func TestImageUploadRules(t *testing.T) { t.Run("A repository failure unrelated to type or size still propagates unchanged", func(t *testing.T) { repo := newMockImageRepo() repo.fail.failNextWith("Create", errRepositoryFailure) - createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder) + createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper()) _, err := createUc.CreateImage(testCtx, inputOf(pngBytes(t, 3, 2), "image/png")) diff --git a/test/image_variants_env_test.go b/test/image_variants_env_test.go index e4a1b12..b76c862 100644 --- a/test/image_variants_env_test.go +++ b/test/image_variants_env_test.go @@ -9,6 +9,7 @@ import ( "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/hashing" + "RenewCMS/internal/infrastructure/imaging" "RenewCMS/internal/infrastructure/usage" "RenewCMS/internal/infrastructure/useCases" "bytes" @@ -128,7 +129,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases PurgeVariantCacheUseCase: useCases.NewPurgeVariantCacheUseCase(e.variants, e.images, e.files, e.files, e.storage, clock, recorder), VariantUsageTracker: e.tracker, - CreateImageUseCase: useCases.NewCreateImageUseCase(e.images, e.storage, recorder), + CreateImageUseCase: useCases.NewCreateImageUseCase(e.images, e.storage, recorder, imaging.NewMetadataStripper()), ListMediaUseCase: useCases.NewListMediaUseCase(e.images, e.articles, e.users, e.files, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()), RenameMediaUseCase: useCases.NewRenameMediaUseCase(e.images, recorder), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()), diff --git a/test/media_env_test.go b/test/media_env_test.go index 9e52e03..62b59ba 100644 --- a/test/media_env_test.go +++ b/test/media_env_test.go @@ -7,6 +7,7 @@ import ( domainImage "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/config" + "RenewCMS/internal/infrastructure/imaging" "RenewCMS/internal/infrastructure/useCases" "bytes" "encoding/json" @@ -73,7 +74,7 @@ func newMediaEnv(t *testing.T) *mediaEnv { DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder), GetUserUseCase: useCases.NewGetUserUseCase(env.users), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), - CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder), + CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder, imaging.NewMetadataStripper()), GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(), ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()), RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder), diff --git a/test/media_library_http_test.go b/test/media_library_http_test.go index 16d5ccf..47e6164 100644 --- a/test/media_library_http_test.go +++ b/test/media_library_http_test.go @@ -3,6 +3,7 @@ package test import ( "RenewCMS/api/pkg/apperr" domainImage "RenewCMS/internal/domain/image" + "RenewCMS/internal/infrastructure/imaging" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "errors" @@ -992,7 +993,7 @@ func TestMediaMerging(t *testing.T) { func TestMediaSentinels(t *testing.T) { t.Run("Create reports unreadable bytes as ErrImageUnreadable", func(t *testing.T) { - uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder) + uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper()) for _, contentType := range domainImage.AllowedContentTypes() { _, err := uc.CreateImage(testCtx, inputOf([]byte("not an image"), contentType)) @@ -1003,7 +1004,7 @@ func TestMediaSentinels(t *testing.T) { }) t.Run("An svg without an svg root is unreadable", func(t *testing.T) { - uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder) + uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper()) _, err := uc.CreateImage(testCtx, inputOf([]byte(""), "image/svg+xml")) @@ -1013,7 +1014,7 @@ func TestMediaSentinels(t *testing.T) { }) t.Run("Bytes of another raster format than the declared type are unreadable", func(t *testing.T) { - uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder) + uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper()) _, err := uc.CreateImage(testCtx, inputOf(jpegBytes(t, 3, 2), "image/png")) diff --git a/test/media_storage_orchestration_test.go b/test/media_storage_orchestration_test.go index 4a2e909..b998a79 100644 --- a/test/media_storage_orchestration_test.go +++ b/test/media_storage_orchestration_test.go @@ -2,6 +2,7 @@ package test import ( domainImage "RenewCMS/internal/domain/image" + "RenewCMS/internal/infrastructure/imaging" "RenewCMS/internal/infrastructure/persistence/database" "RenewCMS/internal/infrastructure/persistence/models" "RenewCMS/internal/infrastructure/storage" @@ -35,7 +36,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) { t.Run("The row points at the stored name and the blob holds the bytes", func(t *testing.T) { repo, store := newMockImageRepo(), newMockImageStorage() - created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder)) + created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper())) if !strings.HasPrefix(created.Path, domainImage.PublicPathPrefix) { t.Errorf("unexpected path %q", created.Path) @@ -49,7 +50,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) { repo, store := newMockImageRepo(), newMockImageStorage() store.failPut = errRepositoryFailure - _, err := useCases.NewCreateImageUseCase(repo, store, discardRecorder).CreateImage(testCtx, pngUpload(t)) + _, err := useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper()).CreateImage(testCtx, pngUpload(t)) if !errors.Is(err, errRepositoryFailure) || repo.createCalls != 0 { t.Errorf("got %v with %d Create calls", err, repo.createCalls) @@ -60,7 +61,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) { repo, store := newMockImageRepo(), newMockImageStorage() repo.fail.failNextWith("Create", errRepositoryFailure) - _, err := useCases.NewCreateImageUseCase(repo, store, discardRecorder).CreateImage(testCtx, pngUpload(t)) + _, err := useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper()).CreateImage(testCtx, pngUpload(t)) if !errors.Is(err, errRepositoryFailure) { t.Fatalf("expected the repository failure, got %v", err) @@ -74,7 +75,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) { func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) { t.Run("Both disappear", func(t *testing.T) { repo, store := newMockImageRepo(), newMockImageStorage() - created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder)) + created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper())) if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()).Delete(testCtx, created.ID); err != nil { t.Fatalf("Delete failed: %v", err) @@ -96,7 +97,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) { t.Run("A blob failure keeps the row so the delete can be retried", func(t *testing.T) { repo, store := newMockImageRepo(), newMockImageStorage() - created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder)) + created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper())) store.failDelete = errRepositoryFailure err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()).Delete(testCtx, created.ID) @@ -109,7 +110,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) { func TestMergeMediaRemovesTheMergedBlobs(t *testing.T) { repo, articles, store := newMockImageRepo(), newMockArticleRepo(), newMockImageStorage() - create := useCases.NewCreateImageUseCase(repo, store, discardRecorder) + create := useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper()) kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create) result, err := newTestMergeMedia(repo, articles, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID}) diff --git a/test/mysql_database_driver_test.go b/test/mysql_database_driver_test.go index e8126c9..e01af10 100644 --- a/test/mysql_database_driver_test.go +++ b/test/mysql_database_driver_test.go @@ -447,7 +447,7 @@ func startupEnvironment(t *testing.T, extra ...string) []string { dataDir := t.TempDir() return append(emptyEnvironment(), append([]string{ - "ENVIRONMENT=development", "PORT=" + freeTCPPort(t), "PUBLIC_URL=http://localhost", "JWT_SECRET=test-secret-for-startup", + "ENVIRONMENT=development", "PORT=" + freeTCPPort(t), "PUBLIC_URL=http://localhost", "JWT_SECRET=" + testJWTSecret, "SMTP_LOGIN=a@example.com", "SMTP_SENDER=a@example.com", "SMTP_PASSWORD=x", "SMTP_HOST=localhost", "SMTP_PORT=2525", "DB_FILE=" + filepath.Join(dataDir, "renewcms.db"), "UPLOAD_DIR=" + filepath.Join(dataDir, "uploads"), "UPDATE_FEED_URL=https://git.example", diff --git a/test/not_found_errors_test.go b/test/not_found_errors_test.go index 0b350b9..199869c 100644 --- a/test/not_found_errors_test.go +++ b/test/not_found_errors_test.go @@ -156,7 +156,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep func setJwtCookie(t *testing.T, req *http.Request, token *authtoken.Auth, userId uint32) { t.Helper() - tokenString, err := token.Encode(map[string]any{"user_id": userId}) + tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": 0}) if err != nil { t.Fatalf("failed to encode jwt: %v", err) } diff --git a/test/password_length_limit_test.go b/test/password_length_limit_test.go new file mode 100644 index 0000000..dd5fcf7 --- /dev/null +++ b/test/password_length_limit_test.go @@ -0,0 +1,96 @@ +package test + +import ( + "RenewCMS/api/pkg/apperr" + validator "RenewCMS/api/pkg/validator" + "RenewCMS/internal/domain/user" + "errors" + "net/http" + "strings" + "testing" + "time" +) + +func TestPasswordLengthLimit(t *testing.T) { + validator.InitValidator() + longest := strings.Repeat("p", user.MaxPasswordBytes) + tooLong := longest + "p" + multibyteTooLong := strings.Repeat("é", 37) + + t.Run("The domain accepts 72 bytes and refuses 73, counting bytes rather than characters", func(t *testing.T) { + if err := user.ValidatePassword(longest); err != nil { + t.Errorf("72 bytes: expected no error, got %v", err) + } + if err := user.ValidatePassword(tooLong); !errors.Is(err, user.ErrPasswordTooLong) { + t.Errorf("73 bytes: expected ErrPasswordTooLong, got %v", err) + } + if err := user.ValidatePassword(multibyteTooLong); !errors.Is(err, user.ErrPasswordTooLong) { + t.Errorf("37 two-byte characters: expected ErrPasswordTooLong, got %v", err) + } + }) + + t.Run("Registering with a password over 72 bytes answers 400 and creates nothing", func(t *testing.T) { + userRepo := newMockUserRepo() + mailRepo := newMockMailRepo() + router := newRegistrationGateRouter(userRepo, mailRepo, "https://cms.example.com") + + rec := registerRequest(t, router, "alice", multibyteTooLong, "alice@example.com") + + if rec.Code != http.StatusBadRequest || strings.TrimSpace(rec.Body.String()) != apperr.PasswordTooLongErrorMessage { + t.Errorf("expected 400 %q, got %d %q", apperr.PasswordTooLongErrorMessage, rec.Code, rec.Body.String()) + } + if len(userRepo.users) != 0 || len(mailRepo.SentEmails) != 0 { + t.Errorf("expected no user and no mail, got %d users and %d mails", len(userRepo.users), len(mailRepo.SentEmails)) + } + }) + + t.Run("Registering with exactly 72 bytes still works", func(t *testing.T) { + userRepo := newMockUserRepo() + router := newRegistrationGateRouter(userRepo, newMockMailRepo(), "https://cms.example.com") + + if rec := registerRequest(t, router, "alice", longest, "alice@example.com"); rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + }) + + t.Run("Resetting to a password over 72 bytes answers 400 and keeps the old password and code", func(t *testing.T) { + userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour)) + router := newResetPasswordRouter(userRepo, &stubHasher{}) + + rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "reset-code-1", "password": "`+tooLong+`"}`) + + if rec.Code != http.StatusBadRequest || strings.TrimSpace(rec.Body.String()) != apperr.PasswordTooLongErrorMessage { + t.Errorf("expected 400 %q, got %d %q", apperr.PasswordTooLongErrorMessage, rec.Code, rec.Body.String()) + } + stored := userRepo.users[1] + if stored.Password != "hashed:oldpassword" || stored.PasswordResetCode != "hashed:reset-code-1" { + t.Errorf("expected the account untouched, got %+v", stored) + } + }) + + t.Run("A wrong reset code is reported as such even with an over-long password", func(t *testing.T) { + userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour)) + router := newResetPasswordRouter(userRepo, &stubHasher{}) + + rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "wrong", "password": "`+tooLong+`"}`) + + if rec.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", rec.Code) + } + }) + + t.Run("Accepting an invite with a password over 72 bytes answers 400 and creates nothing", func(t *testing.T) { + f := newAcceptInviteFixture(t, 100) + f.seedPending() + usersBefore := len(f.users.users) + + rec := f.accept(acceptBody(acceptCode, "newadmin", tooLong)) + + if rec.Code != http.StatusBadRequest || strings.TrimSpace(rec.Body.String()) != apperr.PasswordTooLongErrorMessage { + t.Errorf("expected 400 %q, got %d %q", apperr.PasswordTooLongErrorMessage, rec.Code, rec.Body.String()) + } + if len(f.users.users) != usersBefore { + t.Errorf("expected no user created") + } + }) +} diff --git a/test/personal_data_retention_test.go b/test/personal_data_retention_test.go new file mode 100644 index 0000000..f709421 --- /dev/null +++ b/test/personal_data_retention_test.go @@ -0,0 +1,240 @@ +package test + +import ( + "RenewCMS/internal/domain/invite" + "RenewCMS/internal/domain/retention" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/scheduler" + "RenewCMS/internal/infrastructure/useCases" + "context" + "errors" + "log" + "strings" + "testing" + "time" +) + +var retentionNow = time.Date(2026, time.October, 22, 3, 0, 0, 0, time.UTC) + +func TestUnverifiedAccountRetentionRule(t *testing.T) { + cases := []struct { + name string + user user.User + stale bool + }{ + {"verified accounts are never stale", user.User{IsVerified: true, VerificationExpiration: retentionNow.Add(-365 * 24 * time.Hour)}, false}, + {"a pending code is not stale", user.User{VerificationExpiration: retentionNow.Add(time.Hour)}, false}, + {"an expired code inside the grace period is not stale", user.User{VerificationExpiration: retentionNow.Add(-user.UnverifiedRetention)}, false}, + {"an expired code past the grace period is stale", user.User{VerificationExpiration: retentionNow.Add(-user.UnverifiedRetention - time.Second)}, true}, + {"without an expiry, the creation date starts the grace period", user.User{CreatedAt: retentionNow.Add(-user.UnverifiedRetention - time.Second)}, true}, + {"without any date, an account is kept", user.User{}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.user.IsStaleUnverifiedAt(retentionNow); got != tc.stale { + t.Errorf("expected %v, got %v", tc.stale, got) + } + }) + } +} + +func TestInviteRetentionRule(t *testing.T) { + acceptedLongAgo := retentionNow.Add(-invite.Retention - time.Second) + acceptedRecently := retentionNow.Add(-invite.Retention) + cases := []struct { + name string + invite invite.Invite + stale bool + }{ + {"a pending invite is kept", invite.Invite{ExpiresAt: retentionNow.Add(time.Hour)}, false}, + {"an invite that expired inside the retention period is kept", invite.Invite{ExpiresAt: retentionNow.Add(-invite.Retention)}, false}, + {"an invite that expired before the retention period is stale", invite.Invite{ExpiresAt: retentionNow.Add(-invite.Retention - time.Second)}, true}, + {"an invite accepted inside the retention period is kept", invite.Invite{ExpiresAt: retentionNow.Add(-invite.Retention * 2), AcceptedAt: &acceptedRecently}, false}, + {"an invite accepted before the retention period is stale", invite.Invite{ExpiresAt: retentionNow.Add(time.Hour), AcceptedAt: &acceptedLongAgo}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.invite.IsStaleAt(retentionNow); got != tc.stale { + t.Errorf("expected %v, got %v", tc.stale, got) + } + }) + } +} + +type retentionWorld struct { + users *mockUserRepo + invites *inviteFakeRepo + sessions *memorySessionRepo + useCase *useCases.EnforceRetentionUseCase +} + +func newRetentionWorld() *retentionWorld { + w := &retentionWorld{users: newMockUserRepo(), invites: newInviteFakeRepo(), sessions: newMemorySessionRepo()} + w.useCase = useCases.NewEnforceRetentionUseCase(w.users, w.invites, w.sessions) + return w +} + +func (w *retentionWorld) seedEverything() { + longAgo := retentionNow.Add(-60 * 24 * time.Hour) + w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, VerificationExpiration: longAgo} + w.users.users[2] = user.User{ID: 2, Username: "abandoned", VerificationExpiration: longAgo} + w.users.users[3] = user.User{ID: 3, Username: "pending", VerificationExpiration: retentionNow.Add(time.Hour)} + w.invites.seed(invite.Invite{Email: "old-expired@example.com", ExpiresAt: longAgo}) + w.invites.seed(invite.Invite{Email: "pending@example.com", ExpiresAt: retentionNow.Add(time.Hour)}) + w.invites.seed(invite.Invite{Email: "old-accepted@example.com", ExpiresAt: longAgo, AcceptedAt: &longAgo}) + w.sessions.revoked["expired"] = retentionNow.Add(-time.Minute) + w.sessions.revoked["live"] = retentionNow.Add(time.Hour) +} + +func TestEnforceRetentionUseCase(t *testing.T) { + t.Run("Removes only what is past its retention period and reports the counts", func(t *testing.T) { + w := newRetentionWorld() + w.seedEverything() + + result, err := w.useCase.Tick(retentionNow) + + if err != nil { + t.Fatal(err) + } + if result != (retention.Result{RevokedSessions: 1, UnverifiedUsers: 1, Invites: 2}) { + t.Errorf("unexpected result %+v", result) + } + if _, kept := w.users.users[1]; !kept { + t.Error("the verified owner must be kept") + } + if _, kept := w.users.users[3]; !kept { + t.Error("the pending account must be kept") + } + if _, removed := w.users.users[2]; removed { + t.Error("the abandoned account must be removed") + } + remaining := w.invites.GetAll() + if len(remaining) != 1 || remaining[0].Email != "pending@example.com" { + t.Errorf("expected only the pending invite kept, got %+v", remaining) + } + if _, kept := w.sessions.revoked["live"]; !kept || len(w.sessions.revoked) != 1 { + t.Errorf("expected only the live revocation kept, got %v", w.sessions.revoked) + } + }) + + t.Run("A second run has nothing left to remove", func(t *testing.T) { + w := newRetentionWorld() + w.seedEverything() + _, _ = w.useCase.Tick(retentionNow) + + result, err := w.useCase.Tick(retentionNow) + + if err != nil || result.Total() != 0 { + t.Errorf("expected nothing removed, got %+v, %v", result, err) + } + }) + + t.Run("A session store failure stops the run before anything else is touched", func(t *testing.T) { + w := newRetentionWorld() + w.seedEverything() + w.sessions.failure = errors.New("database unavailable") + + _, err := w.useCase.Tick(retentionNow) + + if err == nil { + t.Fatal("expected an error") + } + if len(w.users.users) != 3 || len(w.invites.invites) != 3 { + t.Error("users and invites must be untouched") + } + }) + + t.Run("A user deletion failure is reported with the work done so far", func(t *testing.T) { + w := newRetentionWorld() + w.seedEverything() + w.users.fail.failNext("Delete") + + result, err := w.useCase.Tick(retentionNow) + + if err == nil { + t.Fatal("expected an error") + } + if result.RevokedSessions != 1 || result.UnverifiedUsers != 0 || result.Invites != 0 { + t.Errorf("unexpected partial result %+v", result) + } + if len(w.invites.invites) != 3 { + t.Error("invites must be untouched after the failure") + } + }) + + t.Run("An invite deletion failure is reported with the work done so far", func(t *testing.T) { + w := newRetentionWorld() + w.seedEverything() + w.invites.fail.failNext("Delete") + + result, err := w.useCase.Tick(retentionNow) + + if err == nil { + t.Fatal("expected an error") + } + if result.RevokedSessions != 1 || result.UnverifiedUsers != 1 || result.Invites != 0 { + t.Errorf("unexpected partial result %+v", result) + } + }) +} + +type retentionJobFake struct { + calls chan time.Time + result retention.Result + err error +} + +func (f *retentionJobFake) Tick(now time.Time) (retention.Result, error) { + f.calls <- now + return f.result, f.err +} + +func TestRetentionScheduler(t *testing.T) { + run := func(t *testing.T, job *retentionJobFake) string { + t.Helper() + ticks := make(chan time.Time, 1) + logs := &strings.Builder{} + runner := scheduler.NewRetentionRunner(job, func() time.Time { return retentionNow }, ticks, nil, log.New(logs, "", 0)) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + runner.Run(ctx) + }() + + ticks <- time.Time{} + select { + case got := <-job.calls: + if !got.Equal(retentionNow) { + t.Errorf("expected the injected clock, got %v", got) + } + case <-time.After(2 * time.Second): + t.Fatal("the job was never ticked") + } + cancel() + <-done + return logs.String() + } + + t.Run("A run that removed something is logged with its counts", func(t *testing.T) { + logged := run(t, &retentionJobFake{calls: make(chan time.Time, 1), result: retention.Result{RevokedSessions: 2, UnverifiedUsers: 1, Invites: 3}}) + + if !strings.Contains(logged, "personal data retention completed: revoked_sessions=2 unverified_users=1 invites=3") { + t.Errorf("unexpected log %q", logged) + } + }) + + t.Run("A run that removed nothing stays quiet", func(t *testing.T) { + if logged := run(t, &retentionJobFake{calls: make(chan time.Time, 1)}); logged != "" { + t.Errorf("expected no log, got %q", logged) + } + }) + + t.Run("A failed run is logged", func(t *testing.T) { + logged := run(t, &retentionJobFake{calls: make(chan time.Time, 1), err: errors.New("database unavailable")}) + + if !strings.Contains(logged, "personal data retention failed: database unavailable") { + t.Errorf("unexpected log %q", logged) + } + }) +} diff --git a/test/public_article_read_test.go b/test/public_article_read_test.go index 6380e99..848a7d1 100644 --- a/test/public_article_read_test.go +++ b/test/public_article_read_test.go @@ -67,7 +67,7 @@ func newPublicArticleReadUserRepo(isVerified bool) *mockUserRepo { func mintPublicArticleReadCookie(t *testing.T, token *authtoken.Auth, userId uint32) *http.Cookie { t.Helper() - tokenString, err := token.Encode(map[string]any{"user_id": userId}) + tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": 0}) if err != nil { t.Fatalf("failed to encode jwt: %v", err) } diff --git a/test/rate_limit_connection_key_http_test.go b/test/rate_limit_connection_key_http_test.go new file mode 100644 index 0000000..2e78643 --- /dev/null +++ b/test/rate_limit_connection_key_http_test.go @@ -0,0 +1,105 @@ +package test + +import ( + "RenewCMS/api/middleware" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func newConnectionKeyLimiter(trustedProxies []string, maxAttempts int, window time.Duration) http.Handler { + limited := middleware.NewRateLimiter(maxAttempts, window)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + return middleware.TrustedProxyRealIP(trustedProxies)(limited) +} + +func sendFromConnection(handler http.Handler, remoteAddr string, realIP string) int { + req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", nil) + req.RemoteAddr = remoteAddr + if realIP != "" { + req.Header.Set("X-Real-IP", realIP) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Code +} + +func TestRateLimitKeysOnTheConnectionHost(t *testing.T) { + t.Run("New connections from the same address share one counter whatever their source port", func(t *testing.T) { + handler := newConnectionKeyLimiter(nil, 5, time.Hour) + + blocked := 0 + for port := 40000; port < 40100; port++ { + if sendFromConnection(handler, fmt.Sprintf("203.0.113.7:%d", port), "") == http.StatusTooManyRequests { + blocked++ + } + } + + if blocked != 95 { + t.Errorf("expected 95 of 100 attempts blocked, got %d", blocked) + } + }) + + t.Run("IPv6 connections from the same address share one counter", func(t *testing.T) { + handler := newConnectionKeyLimiter(nil, 1, time.Hour) + + first := sendFromConnection(handler, "[2001:db8::1]:50000", "") + second := sendFromConnection(handler, "[2001:db8::1]:50001", "") + + if first != http.StatusOK || second != http.StatusTooManyRequests { + t.Errorf("expected 200 then 429, got %d then %d", first, second) + } + }) + + t.Run("Different addresses keep independent counters", func(t *testing.T) { + handler := newConnectionKeyLimiter(nil, 1, time.Hour) + + first := sendFromConnection(handler, "203.0.113.7:1", "") + second := sendFromConnection(handler, "203.0.113.8:1", "") + + if first != http.StatusOK || second != http.StatusOK { + t.Errorf("expected both 200, got %d and %d", first, second) + } + }) + + t.Run("A forwarded client keeps one counter across the proxy's connections", func(t *testing.T) { + handler := newConnectionKeyLimiter([]string{"10.0.0.1"}, 1, time.Hour) + + first := sendFromConnection(handler, "10.0.0.1:51000", "198.51.100.9") + second := sendFromConnection(handler, "10.0.0.1:51001", "198.51.100.9") + + if first != http.StatusOK || second != http.StatusTooManyRequests { + t.Errorf("expected 200 then 429, got %d then %d", first, second) + } + }) + + t.Run("A counter starts over once its window has passed", func(t *testing.T) { + handler := newConnectionKeyLimiter(nil, 1, 20*time.Millisecond) + + first := sendFromConnection(handler, "203.0.113.7:1", "") + second := sendFromConnection(handler, "203.0.113.7:2", "") + time.Sleep(30 * time.Millisecond) + third := sendFromConnection(handler, "203.0.113.7:3", "") + + if first != http.StatusOK || second != http.StatusTooManyRequests || third != http.StatusOK { + t.Errorf("expected 200, 429, 200, got %d, %d, %d", first, second, third) + } + }) + + t.Run("Sweeping expired counters never resets a counter whose window is still open", func(t *testing.T) { + handler := newConnectionKeyLimiter(nil, 1, 60*time.Millisecond) + + _ = sendFromConnection(handler, "198.51.100.1:1", "") + time.Sleep(40 * time.Millisecond) + first := sendFromConnection(handler, "203.0.113.7:1", "") + time.Sleep(30 * time.Millisecond) + second := sendFromConnection(handler, "203.0.113.7:2", "") + + if first != http.StatusOK || second != http.StatusTooManyRequests { + t.Errorf("expected 200 then 429 across a sweep, got %d then %d", first, second) + } + }) +} diff --git a/test/runtime_settings_from_env_test.go b/test/runtime_settings_from_env_test.go index 1b4976f..687c7df 100644 --- a/test/runtime_settings_from_env_test.go +++ b/test/runtime_settings_from_env_test.go @@ -2,16 +2,20 @@ package test import ( "RenewCMS/internal/infrastructure/config" + "errors" + "strings" "testing" "time" ) +const testJWTSecret = "test-secret-0123456789abcdef0123456789" + func setBaseSettingsEnv(t *testing.T) { t.Helper() t.Setenv("ENVIRONMENT", "production") t.Setenv("PUBLIC_URL", "https://cms.example.com") t.Setenv("UPLOAD_DIR", "./data/uploads") - t.Setenv("JWT_SECRET", "test-secret") + t.Setenv("JWT_SECRET", testJWTSecret) t.Setenv("DB_FILE", "./data/renewcms.db") t.Setenv("PORT", "8080") t.Setenv("SMTP_LOGIN", "smtp-login@example.com") @@ -42,8 +46,8 @@ func TestNewSettingsFromEnvReadsAllFields(t *testing.T) { if settings.UploadDir != "./data/uploads" { t.Errorf("expected UploadDir %q, got %q", "./data/uploads", settings.UploadDir) } - if settings.JWTSecret != "test-secret" { - t.Errorf("expected JWTSecret %q, got %q", "test-secret", settings.JWTSecret) + if settings.JWTSecret != testJWTSecret { + t.Errorf("expected JWTSecret %q, got %q", testJWTSecret, settings.JWTSecret) } if settings.DBFile != "./data/renewcms.db" { t.Errorf("expected DBFile %q, got %q", "./data/renewcms.db", settings.DBFile) @@ -175,3 +179,66 @@ func TestSettingsIsProductionFalseForNonProductionEnvironment(t *testing.T) { t.Error("expected IsProduction to be false for environment \"development\"") } } + +func TestNewSettingsFromEnvRefusesAWeakJWTSecret(t *testing.T) { + cases := map[string]string{ + "empty": "", + "short": "test-secret", + "one byte short": "0123456789abcdef0123456789abcde", + "the .env.example placeholder": "change-me-to-a-long-random-string", + "the docker.env placeholder": "replace-with-output-of-openssl-rand-hex-32", + } + for name, secret := range cases { + t.Run(name, func(t *testing.T) { + setBaseSettingsEnv(t) + t.Setenv("JWT_SECRET", secret) + + _, err := config.NewSettingsFromEnv() + + if !errors.Is(err, config.ErrWeakJWTSecret) { + t.Errorf("expected ErrWeakJWTSecret, got %v", err) + } + if err != nil && !strings.Contains(err.Error(), "openssl rand -hex 32") { + t.Errorf("expected the error to say how to generate a secret, got %q", err) + } + }) + } + + t.Run("A secret of exactly the minimum length is accepted", func(t *testing.T) { + setBaseSettingsEnv(t) + t.Setenv("JWT_SECRET", strings.Repeat("k", config.MinJWTSecretBytes)) + + if _, err := config.NewSettingsFromEnv(); err != nil { + t.Errorf("expected no error, got %v", err) + } + }) +} + +func TestNewSettingsFromEnvReadsSmtpAllowInsecure(t *testing.T) { + for raw, want := range map[string]bool{"": false, "false": false, "true": true, "TRUE": true} { + t.Run("value "+raw, func(t *testing.T) { + setBaseSettingsEnv(t) + t.Setenv("SMTP_ALLOW_INSECURE", raw) + + settings, err := config.NewSettingsFromEnv() + + if err != nil { + t.Fatal(err) + } + if settings.Smtp.AllowInsecure != want { + t.Errorf("expected AllowInsecure %v, got %v", want, settings.Smtp.AllowInsecure) + } + }) + } + + t.Run("An unparsable value names the variable", func(t *testing.T) { + setBaseSettingsEnv(t) + t.Setenv("SMTP_ALLOW_INSECURE", "maybe") + + _, err := config.NewSettingsFromEnv() + + if err == nil || !strings.Contains(err.Error(), "SMTP_ALLOW_INSECURE") { + t.Errorf("expected an error naming SMTP_ALLOW_INSECURE, got %v", err) + } + }) +} diff --git a/test/runtime_settings_injection_http_test.go b/test/runtime_settings_injection_http_test.go index a076446..0355cfb 100644 --- a/test/runtime_settings_injection_http_test.go +++ b/test/runtime_settings_injection_http_test.go @@ -7,6 +7,7 @@ import ( domainUser "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/hashing" + "RenewCMS/internal/infrastructure/imaging" "RenewCMS/internal/infrastructure/mailer" "RenewCMS/internal/infrastructure/storage" "RenewCMS/internal/infrastructure/useCases" @@ -141,7 +142,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases { CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo), ListUsersUseCase: useCases.NewListUsersUseCase(userRepo), SendMailUseCase: useCases.NewSendMailUseCase(mailRepo), - CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder), + CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper()), GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(), ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo(), plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()), RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder), diff --git a/test/security_headers_test.go b/test/security_headers_test.go index c51c1b0..60b0597 100644 --- a/test/security_headers_test.go +++ b/test/security_headers_test.go @@ -51,6 +51,9 @@ func TestSecurityHeadersContentSecurityPolicy(t *testing.T) { if strings.Contains(csp, "'unsafe-eval'") || strings.Contains(csp, "script-src 'self' 'unsafe-inline'") { t.Errorf("script-src must not allow unsafe sources, got %q", csp) } + if !strings.Contains(csp, "font-src 'self';") || strings.Contains(csp, "googleapis") || strings.Contains(csp, "gstatic") { + t.Errorf("fonts must come from the server itself, never a font CDN, got %q", csp) + } } func TestAllowInlineScriptsHashesOnlyInlineScripts(t *testing.T) { diff --git a/test/session_revocation_http_test.go b/test/session_revocation_http_test.go new file mode 100644 index 0000000..a343ca0 --- /dev/null +++ b/test/session_revocation_http_test.go @@ -0,0 +1,308 @@ +package test + +import ( + "RenewCMS/api" + "RenewCMS/api/pkg/authtoken" + validator "RenewCMS/api/pkg/validator" + "RenewCMS/internal/domain/session" + domainUser "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/config" + "RenewCMS/internal/infrastructure/hashing" + "RenewCMS/internal/infrastructure/persistence/database" + persistence "RenewCMS/internal/infrastructure/persistence/repositories" + "RenewCMS/internal/infrastructure/useCases" + "bytes" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type memorySessionRepo struct { + revoked map[string]time.Time + failure error + revokeFailure error +} + +func newMemorySessionRepo() *memorySessionRepo { + return &memorySessionRepo{revoked: map[string]time.Time{}} +} + +func (m *memorySessionRepo) Revoke(revocation session.Revocation) error { + if m.failure != nil { + return m.failure + } + if m.revokeFailure != nil { + return m.revokeFailure + } + m.revoked[revocation.ID] = revocation.ExpiresAt + return nil +} + +func (m *memorySessionRepo) IsRevoked(id string) (bool, error) { + if m.failure != nil { + return false, m.failure + } + _, ok := m.revoked[id] + return ok, nil +} + +func (m *memorySessionRepo) DeleteExpired(now time.Time) (int, error) { + if m.failure != nil { + return 0, m.failure + } + removed := 0 + for id, expiresAt := range m.revoked { + if !expiresAt.After(now) { + delete(m.revoked, id) + removed++ + } + } + return removed, nil +} + +var _ session.Repository = &memorySessionRepo{} + +func TestSessionRevocationUseCase(t *testing.T) { + now := time.Date(2026, time.October, 22, 9, 0, 0, 0, time.UTC) + clock := func() time.Time { return now } + + t.Run("A revoked session is reported as revoked until it is purged", func(t *testing.T) { + repo := newMemorySessionRepo() + uc := useCases.NewSessionRevocationUseCase(repo, clock) + + if err := uc.Revoke("session-1", now.Add(time.Hour)); err != nil { + t.Fatal(err) + } + + revoked, err := uc.IsRevoked("session-1") + if err != nil || !revoked { + t.Errorf("expected revoked, got %v, %v", revoked, err) + } + if other, _ := uc.IsRevoked("session-2"); other { + t.Error("an unrelated session must not be revoked") + } + }) + + t.Run("A session that has already expired is not stored", func(t *testing.T) { + repo := newMemorySessionRepo() + + if err := useCases.NewSessionRevocationUseCase(repo, clock).Revoke("session-1", now); err != nil { + t.Fatal(err) + } + if len(repo.revoked) != 0 { + t.Errorf("expected nothing stored, got %v", repo.revoked) + } + }) + + for name, id := range map[string]string{"empty": "", "too long": strings.Repeat("a", session.MaxIDLength+1)} { + t.Run("Rejects an "+name+" session id", func(t *testing.T) { + uc := useCases.NewSessionRevocationUseCase(newMemorySessionRepo(), clock) + + if err := uc.Revoke(id, now.Add(time.Hour)); !errors.Is(err, session.ErrInvalidSessionID) { + t.Errorf("Revoke: expected ErrInvalidSessionID, got %v", err) + } + if _, err := uc.IsRevoked(id); !errors.Is(err, session.ErrInvalidSessionID) { + t.Errorf("IsRevoked: expected ErrInvalidSessionID, got %v", err) + } + }) + } + + t.Run("Accepts an id of exactly the maximum length", func(t *testing.T) { + repo := newMemorySessionRepo() + id := strings.Repeat("a", session.MaxIDLength) + + if err := useCases.NewSessionRevocationUseCase(repo, clock).Revoke(id, now.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if _, ok := repo.revoked[id]; !ok { + t.Error("expected the revocation to be stored") + } + }) +} + +func TestRevokedSessionRepository(t *testing.T) { + now := time.Date(2026, time.October, 22, 9, 0, 0, 0, time.UTC) + + t.Run("Revocations survive, revoking twice is harmless, and expired ones are purged", func(t *testing.T) { + db := openRealSqlite(t) + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatalf("migrate: %v", err) + } + repo := persistence.NewRevokedSessionRepository(db) + + for _, revocation := range []session.Revocation{ + {ID: "expired", ExpiresAt: now.Add(-time.Minute)}, + {ID: "exactly-now", ExpiresAt: now}, + {ID: "live", ExpiresAt: now.Add(time.Hour)}, + {ID: "live", ExpiresAt: now.Add(time.Hour)}, + } { + if err := repo.Revoke(revocation); err != nil { + t.Fatalf("revoke %s: %v", revocation.ID, err) + } + } + + removed, err := repo.DeleteExpired(now) + if err != nil || removed != 2 { + t.Fatalf("expected 2 purged, got %d, %v", removed, err) + } + for id, want := range map[string]bool{"expired": false, "exactly-now": false, "live": true, "unknown": false} { + if got, err := repo.IsRevoked(id); err != nil || got != want { + t.Errorf("%s: expected %v, got %v, %v", id, want, got, err) + } + } + }) +} + +type sessionRevocationEnv struct { + router http.Handler + sessions *memorySessionRepo +} + +func newSessionRevocationEnv(t *testing.T) *sessionRevocationEnv { + t.Helper() + validator.InitValidator() + hasher := hashing.NewBcryptHasher() + hashed, err := hasher.Hash("hunter2example") + if err != nil { + t.Fatal(err) + } + userRepo := newMockUserRepo() + userRepo.users[1] = domainUser.User{ID: 1, Username: "alice", Email: "alice@example.com", Password: hashed, IsVerified: true, Role: domainUser.RoleOwner} + sessions := newMemorySessionRepo() + container := &api.UseCases{ + Token: authtoken.New([]byte(testJWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(sessions, time.Now))), + GetUserUseCase: useCases.NewGetUserUseCase(userRepo), + GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), + CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), + AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), + } + settings := config.Settings{ + Environment: "production", + PublicURL: "https://cms.example.com", + AuthRateLimit: config.AuthRateLimitSettings{MaxAttempts: 100, Window: time.Minute}, + } + return &sessionRevocationEnv{router: api.InitRoutes(container, settings), sessions: sessions} +} + +func (e *sessionRevocationEnv) send(method string, target string, body string, cookie *http.Cookie, headers map[string]string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, target, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + if cookie != nil { + req.AddCookie(cookie) + } + for name, value := range headers { + req.Header.Set(name, value) + } + rec := httptest.NewRecorder() + e.router.ServeHTTP(rec, req) + return rec +} + +func (e *sessionRevocationEnv) login(t *testing.T) *http.Cookie { + t.Helper() + rec := e.send(http.MethodPost, "/v1/auth/login", `{"username":"alice","password":"hunter2example"}`, nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("login: expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + cookie, ok := jwtSetCookie(t, rec) + if !ok { + t.Fatal("login: expected a jwt cookie") + } + return cookie +} + +func TestLogoutRevokesTheSession(t *testing.T) { + sameOrigin := map[string]string{"Sec-Fetch-Site": "same-origin", "Origin": "https://cms.example.com"} + + t.Run("The session cookie is HttpOnly, Secure and SameSite=Lax", func(t *testing.T) { + cookie := newSessionRevocationEnv(t).login(t) + + if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode { + t.Errorf("expected HttpOnly, Secure and SameSite=Lax, got %+v", cookie) + } + }) + + t.Run("After logout the same token is refused while other sessions keep working", func(t *testing.T) { + env := newSessionRevocationEnv(t) + loggedOut := env.login(t) + otherDevice := env.login(t) + + if rec := env.send(http.MethodGet, "/v1/auth/me", "", loggedOut, nil); rec.Code != http.StatusOK { + t.Fatalf("before logout: expected 200, got %d", rec.Code) + } + + rec := env.send(http.MethodPost, "/v1/auth/logout", "", loggedOut, sameOrigin) + if rec.Code != http.StatusOK { + t.Fatalf("logout: expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + cleared, ok := jwtSetCookie(t, rec) + if !ok || cleared.Value != "" || cleared.MaxAge >= 0 || cleared.SameSite != http.SameSiteLaxMode { + t.Errorf("expected a cleared SameSite=Lax cookie, got %+v", cleared) + } + + if rec := env.send(http.MethodGet, "/v1/auth/me", "", loggedOut, nil); rec.Code != http.StatusUnauthorized { + t.Errorf("replayed token: expected 401, got %d", rec.Code) + } + if rec := env.send(http.MethodGet, "/v1/auth/me", "", otherDevice, nil); rec.Code != http.StatusOK { + t.Errorf("other session: expected 200, got %d", rec.Code) + } + if len(env.sessions.revoked) != 1 { + t.Errorf("expected one revocation stored, got %v", env.sessions.revoked) + } + }) + + t.Run("Logging out without a session still clears the cookie and stores nothing", func(t *testing.T) { + env := newSessionRevocationEnv(t) + + rec := env.send(http.MethodPost, "/v1/auth/logout", "", nil, sameOrigin) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if len(env.sessions.revoked) != 0 { + t.Errorf("expected nothing stored, got %v", env.sessions.revoked) + } + }) + + t.Run("A failed revocation answers 500 but still clears the cookie", func(t *testing.T) { + env := newSessionRevocationEnv(t) + cookie := env.login(t) + env.sessions.revokeFailure = errors.New("database unavailable") + + rec := env.send(http.MethodPost, "/v1/auth/logout", "", cookie, sameOrigin) + + if rec.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", rec.Code) + } + if cleared, ok := jwtSetCookie(t, rec); !ok || cleared.Value != "" { + t.Errorf("expected the cookie to be cleared, got %+v", cleared) + } + }) + + t.Run("A token whose revocation cannot be checked is refused", func(t *testing.T) { + env := newSessionRevocationEnv(t) + cookie := env.login(t) + env.sessions.failure = errors.New("database unavailable") + + if rec := env.send(http.MethodGet, "/v1/auth/me", "", cookie, nil); rec.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rec.Code) + } + }) + + t.Run("A cross-site logout POST is refused before it reaches the handler", func(t *testing.T) { + env := newSessionRevocationEnv(t) + cookie := env.login(t) + + rec := env.send(http.MethodPost, "/v1/auth/logout", "", cookie, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}) + + if rec.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", rec.Code) + } + if len(env.sessions.revoked) != 0 { + t.Error("the session must not be touched") + } + }) +} diff --git a/test/smtp_mailer_test.go b/test/smtp_mailer_test.go index dae39ad..2cda67e 100644 --- a/test/smtp_mailer_test.go +++ b/test/smtp_mailer_test.go @@ -180,6 +180,46 @@ func TestSMTPMailerDelivery(t *testing.T) { }) } + t.Run("Refuses to deliver in clear text to a non-loopback server that offers no STARTTLS", func(t *testing.T) { + server := newFakeSMTPServer(t, fakeSMTPOptions{listenAddress: "127.0.0.2:0"}) + + err := smtpMailerFor(server, config.SmtpSettings{Host: "127.0.0.2"}).Send("reader@example.org", "verification", verificationData()) + + if err == nil || !strings.Contains(err.Error(), "SMTP_ALLOW_INSECURE") { + t.Fatalf("expected an error naming SMTP_ALLOW_INSECURE, got %v", err) + } + if session := server.received()[0]; session.data != "" || session.from != "" { + t.Errorf("nothing may be sent in clear text: %+v", session) + } + }) + + t.Run("Delivers in clear text to a non-loopback server when SMTP_ALLOW_INSECURE is set", func(t *testing.T) { + server := newFakeSMTPServer(t, fakeSMTPOptions{listenAddress: "127.0.0.2:0"}) + + err := smtpMailerFor(server, config.SmtpSettings{Host: "127.0.0.2", AllowInsecure: true}).Send("reader@example.org", "verification", verificationData()) + + if err != nil { + t.Fatal(err) + } + if session := server.received()[0]; session.data == "" || session.usedTLS { + t.Errorf("expected a clear-text delivery, got %+v", session) + } + }) + + t.Run("Still refuses credentials in clear text when SMTP_ALLOW_INSECURE is set", func(t *testing.T) { + server := newFakeSMTPServer(t, fakeSMTPOptions{authMechanisms: "PLAIN", listenAddress: "127.0.0.2:0"}) + settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret", Host: "127.0.0.2", AllowInsecure: true} + + err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData()) + + if err == nil { + t.Fatal("expected an error") + } + if session := server.received()[0]; session.password != "" || session.data != "" { + t.Errorf("credentials or data leaked: %+v", session) + } + }) + t.Run("Fails when credentials are configured but the server offers no AUTH", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{}) settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"} diff --git a/test/sqlite_schema_unchanged_test.go b/test/sqlite_schema_unchanged_test.go index 88c7c05..8b1a44b 100644 --- a/test/sqlite_schema_unchanged_test.go +++ b/test/sqlite_schema_unchanged_test.go @@ -216,7 +216,7 @@ func assertSiteColumnAdded(t *testing.T, db *gorm.DB) { } func isNewTableObject(name string) bool { - for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_"} { + for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_"} { if strings.HasPrefix(name, prefix) { return true } diff --git a/test/update_dispatch_test.go b/test/update_dispatch_test.go index 5034757..0744307 100644 --- a/test/update_dispatch_test.go +++ b/test/update_dispatch_test.go @@ -142,7 +142,7 @@ func TestServerWritesRunningInfoBeforeServing(t *testing.T) { command := exec.Command(binary) command.Dir = workDir command.Env = append(emptyEnvironment(), - "ENVIRONMENT=development", "PORT="+port, "PUBLIC_URL=http://localhost", "JWT_SECRET=test-secret-for-dispatch", + "ENVIRONMENT=development", "PORT="+port, "PUBLIC_URL=http://localhost", "JWT_SECRET="+testJWTSecret, "SMTP_LOGIN=a@example.com", "SMTP_SENDER=a@example.com", "SMTP_PASSWORD=x", "SMTP_HOST=localhost", "SMTP_PORT=2525", "DB_FILE="+filepath.Join(dataDir, "renewcms.db"), "UPLOAD_DIR="+filepath.Join(dataDir, "uploads"), "UPDATE_FEED_URL=https://git.example") diff --git a/test/use_case_baseline_test.go b/test/use_case_baseline_test.go index 210642b..cb7d1e3 100644 --- a/test/use_case_baseline_test.go +++ b/test/use_case_baseline_test.go @@ -4,6 +4,7 @@ import ( "RenewCMS/internal/domain/article" imageDomain "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/imaging" "RenewCMS/internal/infrastructure/useCases" "bytes" "errors" @@ -335,7 +336,7 @@ func TestCreateImageDelegatesContentTypeCheck(t *testing.T) { for _, tc := range cases { t.Run(strings.ReplaceAll(tc.contentType, "/", "_"), func(t *testing.T) { repo := newMockImageRepo() - uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder) + uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper()) content := []byte("fake-image-data") if tc.valid { @@ -369,7 +370,7 @@ func TestCreateImageDelegatesContentTypeCheck(t *testing.T) { func TestCreateImagePropagatesRepositoryFailure(t *testing.T) { repo := newMockImageRepo() repo.fail.failNext("Create") - uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder) + uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper()) _, err := uc.CreateImage(testCtx, imageDomain.ImageInput{ Content: bytes.NewReader(pngBytes(t, 3, 2)), diff --git a/test/verified_account_required_http_test.go b/test/verified_account_required_http_test.go index 1e89717..6279b58 100644 --- a/test/verified_account_required_http_test.go +++ b/test/verified_account_required_http_test.go @@ -269,13 +269,29 @@ func TestVerifiedAccountRequiredHttp(t *testing.T) { assertOnlyArticleOneListed(t, rec) }) + t.Run("Listing hides offline articles from a verified caller whose token is stale", func(t *testing.T) { + articleRepo := seedListingArticleRepo() + userRepo := seedVerifiedAccountRequiredUserRepo(true) + stale := userRepo.users[1] + stale.TokenVersion = 1 + userRepo.users[1] = stale + router := newVerifiedAccountRequiredRouter(articleRepo, userRepo, token) + + req := newVerifiedAccountRequiredRequest(http.MethodGet, "/v1/article/", "") + req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, map[string]any{"user_id": uint32(1), "token_version": 0})) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + assertOnlyArticleOneListed(t, rec) + }) + t.Run("Listing still returns every article for an authenticated and verified caller", func(t *testing.T) { articleRepo := seedListingArticleRepo() userRepo := seedVerifiedAccountRequiredUserRepo(true) router := newVerifiedAccountRequiredRouter(articleRepo, userRepo, token) req := newVerifiedAccountRequiredRequest(http.MethodGet, "/v1/article/", "") - req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, map[string]any{"user_id": uint32(1)})) + req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, map[string]any{"user_id": uint32(1), "token_version": 0})) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) diff --git a/web/src/assets/fonts/OFL-IBM-Plex.txt b/web/src/assets/fonts/OFL-IBM-Plex.txt new file mode 100644 index 0000000..01497cc --- /dev/null +++ b/web/src/assets/fonts/OFL-IBM-Plex.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/src/assets/fonts/OFL-Source-Serif-4.txt b/web/src/assets/fonts/OFL-Source-Serif-4.txt new file mode 100644 index 0000000..5871e1f --- /dev/null +++ b/web/src/assets/fonts/OFL-Source-Serif-4.txt @@ -0,0 +1,93 @@ +Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/src/assets/fonts/fonts.css b/web/src/assets/fonts/fonts.css new file mode 100644 index 0000000..843add1 --- /dev/null +++ b/web/src/assets/fonts/fonts.css @@ -0,0 +1,251 @@ +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./ibm-plex-mono-cyrillic-ext-normal-400.woff2') format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./ibm-plex-mono-cyrillic-normal-400.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./ibm-plex-mono-vietnamese-normal-400.woff2') format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./ibm-plex-mono-latin-ext-normal-400.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./ibm-plex-mono-latin-normal-400.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./ibm-plex-mono-cyrillic-ext-normal-500.woff2') format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./ibm-plex-mono-cyrillic-normal-500.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./ibm-plex-mono-vietnamese-normal-500.woff2') format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./ibm-plex-mono-latin-ext-normal-500.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./ibm-plex-mono-latin-normal-500.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('./ibm-plex-sans-cyrillic-ext-normal-variable.woff2') format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('./ibm-plex-sans-cyrillic-normal-variable.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('./ibm-plex-sans-greek-normal-variable.woff2') format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} + +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('./ibm-plex-sans-vietnamese-normal-variable.woff2') format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('./ibm-plex-sans-latin-ext-normal-variable.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('./ibm-plex-sans-latin-normal-variable.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('./source-serif-4-cyrillic-ext-italic-400.woff2') format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('./source-serif-4-cyrillic-italic-400.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('./source-serif-4-greek-italic-400.woff2') format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('./source-serif-4-vietnamese-italic-400.woff2') format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('./source-serif-4-latin-ext-italic-400.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('./source-serif-4-latin-italic-400.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('./source-serif-4-cyrillic-ext-normal-variable.woff2') format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('./source-serif-4-cyrillic-normal-variable.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('./source-serif-4-greek-normal-variable.woff2') format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('./source-serif-4-vietnamese-normal-variable.woff2') format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('./source-serif-4-latin-ext-normal-variable.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('./source-serif-4-latin-normal-variable.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/web/src/assets/fonts/ibm-plex-mono-cyrillic-ext-normal-400.woff2 b/web/src/assets/fonts/ibm-plex-mono-cyrillic-ext-normal-400.woff2 new file mode 100644 index 0000000..b54e556 Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-mono-cyrillic-ext-normal-400.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-mono-cyrillic-ext-normal-500.woff2 b/web/src/assets/fonts/ibm-plex-mono-cyrillic-ext-normal-500.woff2 new file mode 100644 index 0000000..7d066e9 Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-mono-cyrillic-ext-normal-500.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-mono-cyrillic-normal-400.woff2 b/web/src/assets/fonts/ibm-plex-mono-cyrillic-normal-400.woff2 new file mode 100644 index 0000000..20e72b0 Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-mono-cyrillic-normal-400.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-mono-cyrillic-normal-500.woff2 b/web/src/assets/fonts/ibm-plex-mono-cyrillic-normal-500.woff2 new file mode 100644 index 0000000..328efe3 Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-mono-cyrillic-normal-500.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-mono-latin-ext-normal-400.woff2 b/web/src/assets/fonts/ibm-plex-mono-latin-ext-normal-400.woff2 new file mode 100644 index 0000000..732c64d Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-mono-latin-ext-normal-400.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-mono-latin-ext-normal-500.woff2 b/web/src/assets/fonts/ibm-plex-mono-latin-ext-normal-500.woff2 new file mode 100644 index 0000000..6f33f8c Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-mono-latin-ext-normal-500.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-mono-latin-normal-400.woff2 b/web/src/assets/fonts/ibm-plex-mono-latin-normal-400.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-mono-latin-normal-400.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-mono-latin-normal-500.woff2 b/web/src/assets/fonts/ibm-plex-mono-latin-normal-500.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-mono-latin-normal-500.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-mono-vietnamese-normal-400.woff2 b/web/src/assets/fonts/ibm-plex-mono-vietnamese-normal-400.woff2 new file mode 100644 index 0000000..21612f4 Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-mono-vietnamese-normal-400.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-mono-vietnamese-normal-500.woff2 b/web/src/assets/fonts/ibm-plex-mono-vietnamese-normal-500.woff2 new file mode 100644 index 0000000..3920890 Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-mono-vietnamese-normal-500.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-sans-cyrillic-ext-normal-variable.woff2 b/web/src/assets/fonts/ibm-plex-sans-cyrillic-ext-normal-variable.woff2 new file mode 100644 index 0000000..5f9ba79 Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-sans-cyrillic-ext-normal-variable.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-sans-cyrillic-normal-variable.woff2 b/web/src/assets/fonts/ibm-plex-sans-cyrillic-normal-variable.woff2 new file mode 100644 index 0000000..92a5629 Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-sans-cyrillic-normal-variable.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-sans-greek-normal-variable.woff2 b/web/src/assets/fonts/ibm-plex-sans-greek-normal-variable.woff2 new file mode 100644 index 0000000..4ffe304 Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-sans-greek-normal-variable.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-sans-latin-ext-normal-variable.woff2 b/web/src/assets/fonts/ibm-plex-sans-latin-ext-normal-variable.woff2 new file mode 100644 index 0000000..394ebbb Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-sans-latin-ext-normal-variable.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-sans-latin-normal-variable.woff2 b/web/src/assets/fonts/ibm-plex-sans-latin-normal-variable.woff2 new file mode 100644 index 0000000..b757bc5 Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-sans-latin-normal-variable.woff2 differ diff --git a/web/src/assets/fonts/ibm-plex-sans-vietnamese-normal-variable.woff2 b/web/src/assets/fonts/ibm-plex-sans-vietnamese-normal-variable.woff2 new file mode 100644 index 0000000..355e941 Binary files /dev/null and b/web/src/assets/fonts/ibm-plex-sans-vietnamese-normal-variable.woff2 differ diff --git a/web/src/assets/fonts/source-serif-4-cyrillic-ext-italic-400.woff2 b/web/src/assets/fonts/source-serif-4-cyrillic-ext-italic-400.woff2 new file mode 100644 index 0000000..c6d7d06 Binary files /dev/null and b/web/src/assets/fonts/source-serif-4-cyrillic-ext-italic-400.woff2 differ diff --git a/web/src/assets/fonts/source-serif-4-cyrillic-ext-normal-variable.woff2 b/web/src/assets/fonts/source-serif-4-cyrillic-ext-normal-variable.woff2 new file mode 100644 index 0000000..ec96a6b Binary files /dev/null and b/web/src/assets/fonts/source-serif-4-cyrillic-ext-normal-variable.woff2 differ diff --git a/web/src/assets/fonts/source-serif-4-cyrillic-italic-400.woff2 b/web/src/assets/fonts/source-serif-4-cyrillic-italic-400.woff2 new file mode 100644 index 0000000..02adb1d Binary files /dev/null and b/web/src/assets/fonts/source-serif-4-cyrillic-italic-400.woff2 differ diff --git a/web/src/assets/fonts/source-serif-4-cyrillic-normal-variable.woff2 b/web/src/assets/fonts/source-serif-4-cyrillic-normal-variable.woff2 new file mode 100644 index 0000000..a4419a2 Binary files /dev/null and b/web/src/assets/fonts/source-serif-4-cyrillic-normal-variable.woff2 differ diff --git a/web/src/assets/fonts/source-serif-4-greek-italic-400.woff2 b/web/src/assets/fonts/source-serif-4-greek-italic-400.woff2 new file mode 100644 index 0000000..c1540c8 Binary files /dev/null and b/web/src/assets/fonts/source-serif-4-greek-italic-400.woff2 differ diff --git a/web/src/assets/fonts/source-serif-4-greek-normal-variable.woff2 b/web/src/assets/fonts/source-serif-4-greek-normal-variable.woff2 new file mode 100644 index 0000000..ac4af84 Binary files /dev/null and b/web/src/assets/fonts/source-serif-4-greek-normal-variable.woff2 differ diff --git a/web/src/assets/fonts/source-serif-4-latin-ext-italic-400.woff2 b/web/src/assets/fonts/source-serif-4-latin-ext-italic-400.woff2 new file mode 100644 index 0000000..a2d5870 Binary files /dev/null and b/web/src/assets/fonts/source-serif-4-latin-ext-italic-400.woff2 differ diff --git a/web/src/assets/fonts/source-serif-4-latin-ext-normal-variable.woff2 b/web/src/assets/fonts/source-serif-4-latin-ext-normal-variable.woff2 new file mode 100644 index 0000000..fada8d3 Binary files /dev/null and b/web/src/assets/fonts/source-serif-4-latin-ext-normal-variable.woff2 differ diff --git a/web/src/assets/fonts/source-serif-4-latin-italic-400.woff2 b/web/src/assets/fonts/source-serif-4-latin-italic-400.woff2 new file mode 100644 index 0000000..f7fbb0b Binary files /dev/null and b/web/src/assets/fonts/source-serif-4-latin-italic-400.woff2 differ diff --git a/web/src/assets/fonts/source-serif-4-latin-normal-variable.woff2 b/web/src/assets/fonts/source-serif-4-latin-normal-variable.woff2 new file mode 100644 index 0000000..b98e6b5 Binary files /dev/null and b/web/src/assets/fonts/source-serif-4-latin-normal-variable.woff2 differ diff --git a/web/src/assets/fonts/source-serif-4-vietnamese-italic-400.woff2 b/web/src/assets/fonts/source-serif-4-vietnamese-italic-400.woff2 new file mode 100644 index 0000000..61e484e Binary files /dev/null and b/web/src/assets/fonts/source-serif-4-vietnamese-italic-400.woff2 differ diff --git a/web/src/assets/fonts/source-serif-4-vietnamese-normal-variable.woff2 b/web/src/assets/fonts/source-serif-4-vietnamese-normal-variable.woff2 new file mode 100644 index 0000000..86b4fca Binary files /dev/null and b/web/src/assets/fonts/source-serif-4-vietnamese-normal-variable.woff2 differ diff --git a/web/src/assets/main.css b/web/src/assets/main.css index b93c5de..659f98f 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -1,4 +1,3 @@ -@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&display=swap'); @import 'tailwindcss'; @import '@nuxt/ui'; diff --git a/web/src/main.ts b/web/src/main.ts index 12173a1..b505468 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -7,6 +7,7 @@ import lucide from 'virtual:lucide-subset' import App from './App.vue' import router from './router' +import '@/assets/fonts/fonts.css' import '@/assets/main.css' addCollection(lucide)