Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View file

@ -0,0 +1,74 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/interface/http/middleware/jwt.go
package middleware
import (
"context"
"net/http"
"strings"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
func (mid *middleware) JWTProcessorMiddleware(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract the Authorization header
reqToken := r.Header.Get("Authorization")
// Validate that Authorization header is present
if reqToken == "" {
problem := httperror.NewUnauthorizedError("Authorization not set")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Extract the token from the Authorization header
// Support both "Bearer" (RFC 6750 standard) and "JWT" schemes for compatibility
var token string
if strings.HasPrefix(reqToken, "Bearer ") {
token = strings.TrimPrefix(reqToken, "Bearer ")
} else if strings.HasPrefix(reqToken, "JWT ") {
token = strings.TrimPrefix(reqToken, "JWT ")
} else {
problem := httperror.NewBadRequestError("Not properly formatted authorization header")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Validate the token is not empty after prefix removal
if token == "" {
problem := httperror.NewBadRequestError("Not properly formatted authorization header")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Process the JWT token
sessionID, err := mid.jwt.ProcessJWTToken(token)
if err != nil {
// Log the actual error for debugging but return generic message to client
mid.logger.Error("JWT processing failed", zap.Error(err))
problem := httperror.NewUnauthorizedError("Invalid or expired token")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Update our context to save our JWT token content information
ctx = context.WithValue(ctx, constants.SessionIsAuthorized, true)
ctx = context.WithValue(ctx, constants.SessionID, sessionID)
// Flow to the next middleware with our JWT token saved
fn(w, r.WithContext(ctx))
}
}

View file

@ -0,0 +1,95 @@
package middleware
import (
"context"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
func (mid *middleware) PostJWTProcessorMiddleware(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get our authorization information.
isAuthorized, ok := ctx.Value(constants.SessionIsAuthorized).(bool)
if ok && isAuthorized {
// CWE-391: Safe type assertion to prevent panic-based DoS
// OWASP A09:2021: Security Logging and Monitoring - Prevents service crashes
sessionID, ok := ctx.Value(constants.SessionID).(string)
if !ok {
mid.logger.Error("Invalid session ID type in context")
problem := httperror.NewInternalServerError("Invalid session context")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Parse the user ID from the session ID (which is actually the user ID string from JWT)
userID, err := gocql.ParseUUID(sessionID)
if err != nil {
problem := httperror.NewUnauthorizedError("Invalid user ID in token")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Lookup our user profile by ID or return 500 error.
user, err := mid.userGetByIDUseCase.Execute(ctx, userID)
if err != nil {
// Log the actual error for debugging but return generic message to client
mid.logger.Error("Failed to get user by ID",
zap.Error(err),
zap.String("user_id", userID.String()))
problem := httperror.NewInternalServerError("Unable to verify session")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// If no user was found then that means our session expired and the
// user needs to login or use the refresh token.
if user == nil {
problem := httperror.NewUnauthorizedError("Session expired")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// // If system administrator disabled the user account then we need
// // to generate a 403 error letting the user know their account has
// // been disabled and you cannot access the protected API endpoint.
// if user.State == 0 {
// http.Error(w, "Account disabled - please contact admin", http.StatusForbidden)
// return
// }
// Save our user information to the context.
// Save our user.
ctx = context.WithValue(ctx, constants.SessionUser, user)
// Save individual pieces of the user profile.
ctx = context.WithValue(ctx, constants.SessionID, sessionID)
ctx = context.WithValue(ctx, constants.SessionUserID, user.ID)
ctx = context.WithValue(ctx, constants.SessionUserRole, user.Role)
ctx = context.WithValue(ctx, constants.SessionUserName, user.Name)
ctx = context.WithValue(ctx, constants.SessionUserFirstName, user.FirstName)
ctx = context.WithValue(ctx, constants.SessionUserLastName, user.LastName)
ctx = context.WithValue(ctx, constants.SessionUserTimezone, user.Timezone)
// ctx = context.WithValue(ctx, constants.SessionUserStoreID, user.StoreID)
// ctx = context.WithValue(ctx, constants.SessionUserStoreName, user.StoreName)
// ctx = context.WithValue(ctx, constants.SessionUserStoreLevel, user.StoreLevel)
// ctx = context.WithValue(ctx, constants.SessionUserStoreTimezone, user.StoreTimezone)
}
fn(w, r.WithContext(ctx))
}
}

View file

@ -0,0 +1,87 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware/middleware.go
package middleware
import (
"context"
"net/http"
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/security/jwt"
"go.uber.org/zap"
)
type Middleware interface {
Attach(fn http.HandlerFunc) http.HandlerFunc
Shutdown(ctx context.Context)
}
type middleware struct {
logger *zap.Logger
jwt jwt.JWTProvider
userGetByIDUseCase uc_user.UserGetByIDUseCase
}
func NewMiddleware(
logger *zap.Logger,
jwtp jwt.JWTProvider,
uc1 uc_user.UserGetByIDUseCase,
) Middleware {
logger = logger.With(zap.String("module", "maplefile"))
logger = logger.Named("MapleFile Middleware")
return &middleware{
logger: logger,
jwt: jwtp,
userGetByIDUseCase: uc1,
}
}
// Attach function attaches to HTTP router to apply for every API call.
func (mid *middleware) Attach(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Apply base middleware to all requests
handler := mid.applyBaseMiddleware(fn)
// Check if the path requires authentication
if isProtectedPath(mid.logger, r.URL.Path) {
// Apply auth middleware for protected paths
handler = mid.PostJWTProcessorMiddleware(handler)
handler = mid.JWTProcessorMiddleware(handler)
// handler = mid.EnforceBlacklistMiddleware(handler)
}
handler(w, r)
}
}
// Attach function attaches to HTTP router to apply for every API call.
func (mid *middleware) applyBaseMiddleware(fn http.HandlerFunc) http.HandlerFunc {
// Apply middleware in reverse order (bottom up)
handler := fn
handler = mid.URLProcessorMiddleware(handler)
handler = mid.RequestBodySizeLimitMiddleware(handler)
return handler
}
// RequestBodySizeLimitMiddleware limits the size of request bodies to prevent DoS attacks.
// Default limit is 10MB for most requests, which is sufficient for JSON metadata payloads.
func (mid *middleware) RequestBodySizeLimitMiddleware(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 10MB limit for request bodies
// This is sufficient for JSON metadata while preventing abuse
const maxBodySize = 10 * 1024 * 1024 // 10MB
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
}
fn(w, r)
}
}
// Shutdown shuts down the middleware.
func (mid *middleware) Shutdown(ctx context.Context) {
// Log a message to indicate that the HTTP server is shutting down.
}

View file

@ -0,0 +1,35 @@
package middleware
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/ratelimit"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/security/jwt"
)
// Wire provider for middleware
func ProvideMiddleware(
logger *zap.Logger,
jwtProvider jwt.JWTProvider,
userGetByIDUseCase uc_user.UserGetByIDUseCase,
) Middleware {
return NewMiddleware(logger, jwtProvider, userGetByIDUseCase)
}
// ProvideRateLimitMiddleware provides the rate limit middleware for Wire DI
func ProvideRateLimitMiddleware(
logger *zap.Logger,
loginRateLimiter ratelimit.LoginRateLimiter,
) *RateLimitMiddleware {
return NewRateLimitMiddleware(logger, loginRateLimiter)
}
// ProvideSecurityHeadersMiddleware provides the security headers middleware for Wire DI
func ProvideSecurityHeadersMiddleware(
config *config.Config,
) *SecurityHeadersMiddleware {
return NewSecurityHeadersMiddleware(config)
}

View file

@ -0,0 +1,175 @@
// Package middleware provides HTTP middleware for the MapleFile backend.
package middleware
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/ratelimit"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
// RateLimitMiddleware provides rate limiting functionality for HTTP endpoints
type RateLimitMiddleware struct {
logger *zap.Logger
loginRateLimiter ratelimit.LoginRateLimiter
}
// NewRateLimitMiddleware creates a new rate limit middleware
func NewRateLimitMiddleware(logger *zap.Logger, loginRateLimiter ratelimit.LoginRateLimiter) *RateLimitMiddleware {
return &RateLimitMiddleware{
logger: logger.Named("RateLimitMiddleware"),
loginRateLimiter: loginRateLimiter,
}
}
// LoginRateLimit applies login-specific rate limiting to auth endpoints
// CWE-307: Protects against brute force attacks on authentication endpoints
func (m *RateLimitMiddleware) LoginRateLimit(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract client IP
clientIP := m.extractClientIP(r)
// Extract email from request body (need to buffer and restore)
email := m.extractEmailFromRequest(r)
// Check rate limit
allowed, isLocked, remainingAttempts, err := m.loginRateLimiter.CheckAndRecordAttempt(ctx, email, clientIP)
if err != nil {
// Log error but allow request (fail open for availability)
m.logger.Warn("Rate limiter error, allowing request",
zap.Error(err),
zap.String("ip", validation.MaskIP(clientIP)))
next(w, r)
return
}
// Check if account is locked
if isLocked {
m.logger.Warn("Login attempt on locked account",
zap.String("ip", validation.MaskIP(clientIP)),
zap.String("path", r.URL.Path))
problem := httperror.NewTooManyRequestsError(
"Account temporarily locked due to too many failed attempts. Please try again later.")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Check if IP rate limit exceeded
if !allowed {
m.logger.Warn("Rate limit exceeded",
zap.String("ip", validation.MaskIP(clientIP)),
zap.String("path", r.URL.Path),
zap.Int("remaining_attempts", remainingAttempts))
problem := httperror.NewTooManyRequestsError(
"Too many requests. Please slow down and try again later.")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Add remaining attempts to response header for client awareness
if remainingAttempts > 0 && remainingAttempts <= 3 {
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", remainingAttempts))
}
next(w, r)
}
}
// AuthRateLimit applies general rate limiting to auth endpoints
// For endpoints like registration, email verification, etc.
func (m *RateLimitMiddleware) AuthRateLimit(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Extract client IP for rate limiting key
clientIP := m.extractClientIP(r)
// Use the login rate limiter for IP-based checking only
// This provides basic protection against automated attacks
ctx := r.Context()
allowed, _, _, err := m.loginRateLimiter.CheckAndRecordAttempt(ctx, "", clientIP)
if err != nil {
// Fail open
m.logger.Warn("Rate limiter error, allowing request", zap.Error(err))
next(w, r)
return
}
if !allowed {
m.logger.Warn("Auth rate limit exceeded",
zap.String("ip", validation.MaskIP(clientIP)),
zap.String("path", r.URL.Path))
problem := httperror.NewTooManyRequestsError(
"Too many requests from this IP. Please try again later.")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
next(w, r)
}
}
// extractClientIP extracts the real client IP from the request
func (m *RateLimitMiddleware) extractClientIP(r *http.Request) string {
// Check X-Forwarded-For header first (for reverse proxies)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Take the first IP in the chain
ips := strings.Split(xff, ",")
if len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}
// Check X-Real-IP header
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
// Fall back to RemoteAddr
// Remove port if present
ip := r.RemoteAddr
if idx := strings.LastIndex(ip, ":"); idx != -1 {
ip = ip[:idx]
}
return ip
}
// extractEmailFromRequest extracts email from JSON request body
// It buffers the body so it can be read again by the handler
func (m *RateLimitMiddleware) extractEmailFromRequest(r *http.Request) string {
// Read body
body, err := io.ReadAll(r.Body)
if err != nil {
return ""
}
// Restore body for handler
r.Body = io.NopCloser(bytes.NewBuffer(body))
// Parse JSON to extract email
var req struct {
Email string `json:"email"`
}
if err := json.Unmarshal(body, &req); err != nil {
return ""
}
return strings.ToLower(strings.TrimSpace(req.Email))
}

View file

@ -0,0 +1,64 @@
package middleware
import (
"net/http"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
)
// SecurityHeadersMiddleware adds security headers to all HTTP responses.
// These headers help protect against common web vulnerabilities.
type SecurityHeadersMiddleware struct {
config *config.Config
}
// NewSecurityHeadersMiddleware creates a new security headers middleware.
func NewSecurityHeadersMiddleware(config *config.Config) *SecurityHeadersMiddleware {
return &SecurityHeadersMiddleware{
config: config,
}
}
// Handler wraps an http.Handler to add security headers to all responses.
func (m *SecurityHeadersMiddleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// X-Content-Type-Options: Prevents MIME-type sniffing attacks
// Browser will strictly follow the declared Content-Type
w.Header().Set("X-Content-Type-Options", "nosniff")
// X-Frame-Options: Prevents clickjacking attacks
// DENY = page cannot be displayed in any iframe
w.Header().Set("X-Frame-Options", "DENY")
// X-XSS-Protection: Enables browser's built-in XSS filter
// mode=block = block the entire page if attack is detected
// Note: Largely superseded by CSP, but still useful for older browsers
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Referrer-Policy: Controls how much referrer information is sent
// strict-origin-when-cross-origin = full URL for same-origin, origin only for cross-origin
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Cache-Control: Prevent caching of sensitive responses
// Especially important for auth endpoints
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
// Permissions-Policy: Restricts browser features (formerly Feature-Policy)
// Disables potentially dangerous features like geolocation, camera, microphone
w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
// Content-Security-Policy: Prevents XSS and other code injection attacks
// For API-only backend: deny all content sources and frame embedding
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
// Strict-Transport-Security (HSTS): Forces HTTPS for the specified duration
// Only set in production where HTTPS is properly configured
// max-age=31536000 = 1 year in seconds
// includeSubDomains = applies to all subdomains
if m.config.App.Environment == "production" {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,29 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware/url.go
package middleware
import (
"context"
"net/http"
"strings"
)
// URLProcessorMiddleware Middleware will split the full URL path into slash-sperated parts and save to
// the context to flow downstream in the app for this particular request.
func (mid *middleware) URLProcessorMiddleware(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Split path into slash-separated parts, for example, path "/foo/bar"
// gives p==["foo", "bar"] and path "/" gives p==[""]. Our API starts with
// "/api", as a result we will start the array slice at "1".
p := strings.Split(r.URL.Path, "/")[1:]
// log.Println(p) // For debugging purposes only.
// Open our program's context based on the request and save the
// slash-seperated array from our URL path.
ctx := r.Context()
ctx = context.WithValue(ctx, "url_split", p)
// Flow to the next middleware.
fn(w, r.WithContext(ctx))
}
}

View file

@ -0,0 +1,111 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware/utils.go
package middleware
import (
"regexp"
"go.uber.org/zap"
)
type protectedRoute struct {
pattern string
regex *regexp.Regexp
}
var (
exactPaths = make(map[string]bool)
patternRoutes []protectedRoute
)
func init() {
// Exact matches
exactPaths = map[string]bool{
"/api/v1/me": true,
"/api/v1/me/delete": true,
"/api/v1/me/blocked-emails": true,
"/api/v1/dashboard": true,
"/api/v1/collections": true,
"/api/v1/collections/filtered": true,
"/api/v1/collections/root": true,
"/api/v1/collections/shared": true,
"/api/v1/collections/sync": true, // Sync collections endpoint
"/api/v1/files": true,
"/api/v1/files/pending": true, // Three-step workflow file-create endpoint: Start
"/api/v1/files/recent": true,
"/api/v1/files/sync": true, // Sync files endpoint
"/api/v1/files/delete-multiple": true, // Delete multiple files endpoint
"/api/v1/invites/send-email": true, // Send invitation email to non-registered user
"/api/v1/tags": true, // List and create tags
"/api/v1/tags/search": true, // Search by tags
"/iam/api/v1/users/lookup": true, // User public key lookup (requires auth)
}
// Pattern matches
patterns := []string{
// Blocked Email patterns
"^/api/v1/me/blocked-emails/[^/]+$", // Delete specific blocked email
// Collection patterns (plural routes)
"^/api/v1/collections/[a-zA-Z0-9-]+$", // Individual collection operations
"^/api/v1/collections/[a-zA-Z0-9-]+/move$", // Move collection
"^/api/v1/collections/[a-zA-Z0-9-]+/share$", // Share collection
"^/api/v1/collections/[a-zA-Z0-9-]+/members$", // Collection members
"^/api/v1/collections/[a-zA-Z0-9-]+/members/[a-zA-Z0-9-]+$", // Remove specific member
"^/api/v1/collections/[a-zA-Z0-9-]+/archive$", // Archive collection
"^/api/v1/collections/[a-zA-Z0-9-]+/restore$", // Restore collection
"^/api/v1/collections-by-parent/[a-zA-Z0-9-]+$", // Collections by parent
// Collection patterns (singular routes for files)
"^/api/v1/collection/[a-zA-Z0-9-]+/files$", // Collection files (singular)
// File patterns (singular routes)
"^/api/v1/file/[a-zA-Z0-9-]+$", // Individual file operations
"^/api/v1/file/[a-zA-Z0-9-]+/data$", // File data
"^/api/v1/file/[a-zA-Z0-9-]+/upload-url$", // File upload URL
"^/api/v1/file/[a-zA-Z0-9-]+/download-url$", // File download URL
"^/api/v1/file/[a-zA-Z0-9-]+/complete$", // Complete file upload
"^/api/v1/file/[a-zA-Z0-9-]+/archive$", // Archive file
"^/api/v1/file/[a-zA-Z0-9-]+/restore$", // Restore file
// Tag patterns
"^/api/v1/tags/[a-zA-Z0-9-]+$", // Individual tag operations (GET, PUT, DELETE)
"^/api/v1/tags/[a-zA-Z0-9-]+/assign$", // Assign tag to entity
"^/api/v1/tags/[a-zA-Z0-9-]+/entities/[a-zA-Z0-9-]+$", // Unassign tag from entity
"^/api/v1/tags/for/collection/[a-zA-Z0-9-]+$", // Get tags for collection
"^/api/v1/tags/for/file/[a-zA-Z0-9-]+$", // Get tags for file
"^/api/v1/tags/collections$", // List collections by tag
"^/api/v1/tags/files$", // List files by tag
}
// Precompile patterns
patternRoutes = make([]protectedRoute, len(patterns))
for i, pattern := range patterns {
patternRoutes[i] = protectedRoute{
pattern: pattern,
regex: regexp.MustCompile(pattern),
}
}
}
func isProtectedPath(logger *zap.Logger, path string) bool {
// Check exact matches first (O(1) lookup)
if exactPaths[path] {
logger.Debug("✅ found via map - url is protected",
zap.String("path", path))
return true
}
// Check patterns
for _, route := range patternRoutes {
if route.regex.MatchString(path) {
logger.Debug("✅ found via regex - url is protected",
zap.String("path", path))
return true
}
}
logger.Debug("❌ not found",
zap.String("path", path))
return false
}