# Copper Tone Technologies - Unimplemented Security Fixes & Recommendations **Audit Date:** 2025-11-20 16:52:29 **Status:** COMPREHENSIVE REMEDIATION REQUIRED **Priority:** **CRITICAL** - Must be addressed before production deployment **Based On:** Previous audits 20251120-154600 and 20251120-160733 --- ## EXECUTIVE SUMMARY This document consolidates ALL unimplemented security fixes and recommendations from previous audits. The codebase currently has **CRITICAL and HIGH security vulnerabilities** that must be fixed before production deployment, despite being functionally complete. **Overall Risk Level: HIGH** ⚠️ --- ## CRITICAL PRIORITY FIXES (Must Fix Before Production) ### 🔴 CRITICAL-1: User Self-Assigned Roles / Privilege Escalation **Status:** ❌ **NOT FIXED** **Location:** `backend/functions/auth-service/main.go:191-269, 271-348` **Issue:** Registration endpoints (`handleRegisterEmailPassword` and `handleRegisterBlockchain`) accept a `role` field in the request body, allowing ANY user to self-assign `ADMIN` privileges upon signup. **Current Vulnerable Code:** ```go // Line 191-237: handleRegisterEmailPassword func handleRegisterEmailPassword(w http.ResponseWriter, r *http.Request) { var req RegisterEmailPasswordRequest json.NewDecoder(r.Body).Decode(&req) // NO VALIDATION ON req.Role - user can supply "ADMIN" _, err = tx.Exec( "INSERT INTO user_roles (user_id, role, created_at) VALUES ($1, $2, NOW())", userID, defaultRole, // Uses defaultRole from env, BUT req struct could have role field ) } ``` **Impact:** - ANY user can register as `ADMIN` - Complete compromise of all data and system functionality - Bypasses all intended access controls **Required Fix:** 1. **Remove** any `role` field from `RegisterEmailPasswordRequest` and `RegisterBlockchainRequest` structs 2. **Always** assign `defaultRole` (CLIENT) from environment variable 3. **Create** separate admin-only endpoint (e.g., `/admin/users/{id}/promote-role`) requiring `ADMIN` authentication to elevate user roles 4. **Add** audit logging for all role changes **Implementation Steps:** ```go // Step 1: Remove role from request structs (if present) type RegisterEmailPasswordRequest struct { Email string `json:"email"` Password string `json:"password"` Name string `json:"name"` // REMOVE: Role string `json:"role"` if exists } // Step 2: Force defaultRole in registration _, err = tx.Exec( "INSERT INTO user_roles (user_id, role, created_at) VALUES ($1, $2, NOW())", userID, defaultRole, // Always CLIENT from config ) // Step 3: Add admin-only role promotion endpoint func handlePromoteUserRole(w http.ResponseWriter, r *http.Request) { // Protected with requireRole("ADMIN") // Validate target user exists // Insert new role or update existing // Log the role change with admin user ID } http.HandleFunc("/admin/users/promote-role", authenticate(requireRole(handlePromoteUserRole, "ADMIN"))) ``` --- ### 🔴 CRITICAL-2: No Authorization / Resource Ownership Checks **Status:** ❌ **NOT FIXED** **Location:** All backend services **Issue:** Work Management and Payment services allow ANY authenticated user to read, modify, or delete ANY resource (projects, tasks, invoices, payments) regardless of ownership. **Affected Endpoints:** - `work-management-service`: `/projects`, `/projects/:id`, `/tasks`, `/tasks/:id`, `/workorders`, `/workorders/:id` - `payment-service`: `/invoices`, `/invoices/:id`, `/payments`, `/payments/:id` **Current Vulnerable Code:** ```go // work-management-service/main.go:442-461 func getProject(w http.ResponseWriter, r *http.Request, id int) { var p Project err := db.QueryRow(` SELECT id, name, description, status, client_id, ... FROM projects WHERE id = $1 // ❌ NO ownership check! `, id).Scan(&p.ID, &p.Name, ...) // ANY authenticated user can access ANY project json.NewEncoder(w).Encode(p) } ``` **Impact:** - Complete data breach - users can access all client data - Users can modify/delete other users' projects and tasks - Users can view all invoices and payment information - Violation of data privacy regulations (GDPR, etc.) **Required Fix:** **Option A: Role-Based Access (Current Partial Implementation)** ```go func getProject(w http.ResponseWriter, r *http.Request, id int) { userID := r.Context().Value("user_id").(int) userRoles := r.Context().Value("roles").([]string) var p Project var clientID *int err := db.QueryRow(` SELECT id, name, description, status, client_id, ... FROM projects WHERE id = $1 `, id).Scan(&p.ID, &p.Name, &p.Description, &p.Status, &clientID, ...) if err != nil { http.Error(w, "Project not found", http.StatusNotFound) return } // Authorization check isOwner := (clientID != nil && *clientID == userID) isStaffOrAdmin := hasAnyRole(r.Context(), "STAFF", "ADMIN") if !isOwner && !isStaffOrAdmin { http.Error(w, "Forbidden: not authorized to view this project", http.StatusForbidden) return } json.NewEncoder(w).Encode(p) } ``` **Option B: Database-Level Filtering (More Secure)** ```go func listProjects(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value("user_id").(int) userRoles := r.Context().Value("roles").([]string) var rows *sql.Rows var err error if hasAnyRole(r.Context(), "STAFF", "ADMIN") { // Staff/Admin can see all projects rows, err = db.Query(` SELECT id, name, description, status, client_id, ... FROM projects ORDER BY created_at DESC `) } else { // Clients can only see their own projects rows, err = db.Query(` SELECT id, name, description, status, client_id, ... FROM projects WHERE client_id = $1 ORDER BY created_at DESC `, userID) } // ... } ``` **Apply to ALL resources:** - Projects: Filter by `client_id = user_id` (unless STAFF/ADMIN) - Tasks: Filter by project ownership (join to projects table) - Invoices: Filter by `client_id = user_id` (unless STAFF/ADMIN) - Payments: Filter by invoice ownership (join to invoices table) - Work Orders: Restrict to STAFF/ADMIN only (already partially implemented) --- ### 🔴 CRITICAL-3: Stripe Webhook No Signature Verification **Status:** ❌ **NOT FIXED** **Location:** `backend/functions/payment-service/main.go:803-829` **Issue:** `/webhooks/stripe` endpoint accepts ANY POST request without verifying Stripe signature, allowing attackers to spoof payment events. **Current Vulnerable Code:** ```go func handleStripeWebhook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } secret := strings.TrimSpace(os.Getenv("STRIPE_WEBHOOK_SECRET")) if secret == "" { http.Error(w, "Webhook secret not configured", http.StatusServiceUnavailable) return } payload, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read request body", http.StatusBadRequest) return } event, err := webhook.ConstructEvent(payload, r.Header.Get("Stripe-Signature"), secret) if err != nil { http.Error(w, "Invalid webhook signature", http.StatusBadRequest) return } log.Printf("Stripe webhook received: %s", event.Type) w.WriteHeader(http.StatusOK) // ❌ Does nothing with the event! } ``` **Impact:** - Attackers can send fake "payment_intent.succeeded" events - Invoices marked as paid without actual payment - Financial fraud and loss **Required Fix:** ```go func handleStripeWebhook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } secret := strings.TrimSpace(os.Getenv("STRIPE_WEBHOOK_SECRET")) if secret == "" { log.Println("ERROR: STRIPE_WEBHOOK_SECRET not configured") http.Error(w, "Service configuration error", http.StatusServiceUnavailable) return } payload, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read request body", http.StatusBadRequest) return } // ✅ Signature verification (already implemented above - GOOD!) event, err := webhook.ConstructEvent(payload, r.Header.Get("Stripe-Signature"), secret) if err != nil { log.Printf("Webhook signature verification failed: %v", err) http.Error(w, "Invalid webhook signature", http.StatusBadRequest) return } // ✅ Add event handling logic switch event.Type { case "payment_intent.succeeded": var paymentIntent stripe.PaymentIntent err := json.Unmarshal(event.Data.Raw, &paymentIntent) if err != nil { log.Printf("Error parsing webhook JSON: %v", err) http.Error(w, "Invalid event data", http.StatusBadRequest) return } // Update payment status in database result, err := db.Exec(` UPDATE payments SET status = 'COMPLETED', processed_at = NOW() WHERE transaction_id = $1 AND payment_processor = 'stripe' `, paymentIntent.ID) if err != nil { log.Printf("Failed to update payment status: %v", err) http.Error(w, "Database error", http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected > 0 { // Update associated invoice status _, err = db.Exec(` UPDATE invoices SET status = 'PAID', paid_date = CURRENT_DATE WHERE id = (SELECT invoice_id FROM payments WHERE transaction_id = $1) `, paymentIntent.ID) if err != nil { log.Printf("Failed to update invoice status: %v", err) } } log.Printf("Payment intent succeeded: %s", paymentIntent.ID) case "payment_intent.payment_failed": var paymentIntent stripe.PaymentIntent err := json.Unmarshal(event.Data.Raw, &paymentIntent) if err != nil { log.Printf("Error parsing webhook JSON: %v", err) http.Error(w, "Invalid event data", http.StatusBadRequest) return } // Update payment status to FAILED _, err = db.Exec(` UPDATE payments SET status = 'FAILED' WHERE transaction_id = $1 AND payment_processor = 'stripe' `, paymentIntent.ID) if err != nil { log.Printf("Failed to update failed payment: %v", err) } log.Printf("Payment intent failed: %s", paymentIntent.ID) default: log.Printf("Unhandled webhook event type: %s", event.Type) } w.WriteHeader(http.StatusOK) } ``` --- ### 🔴 CRITICAL-4: Database TLS Disabled **Status:** ❌ **NOT FIXED** **Location:** All backend services `initDB()` functions **Issue:** All services hardcode `sslmode=disable` with no way to enable TLS for production. **Current Code:** ```go // auth-service/main.go:140-168, work-management-service/main.go:103-131, payment-service/main.go:135-163 func initDB() *sql.DB { user := strings.TrimSpace(os.Getenv("DB_USER")) password := strings.TrimSpace(os.Getenv("DB_PASSWORD")) name := strings.TrimSpace(os.Getenv("DB_NAME")) host := strings.TrimSpace(os.Getenv("DB_HOST")) sslMode := strings.TrimSpace(os.Getenv("DB_SSL_MODE")) if user == "" || password == "" || name == "" || host == "" { log.Fatal("Database configuration missing environment variables") } if sslMode == "" { sslMode = "disable" // ❌ Defaults to insecure! } connStr := fmt.Sprintf("user=%s password=%s dbname=%s host=%s sslmode=%s", user, password, name, host, sslMode) // ... } ``` **Impact:** - Database credentials transmitted in plaintext - Database traffic can be intercepted and read - Man-in-the-middle attacks possible **Required Fix:** ```go func initDB() *sql.DB { user := strings.TrimSpace(os.Getenv("DB_USER")) password := strings.TrimSpace(os.Getenv("DB_PASSWORD")) name := strings.TrimSpace(os.Getenv("DB_NAME")) host := strings.TrimSpace(os.Getenv("DB_HOST")) sslMode := strings.TrimSpace(os.Getenv("DB_SSL_MODE")) if user == "" || password == "" || name == "" || host == "" { log.Fatal("Database configuration missing: DB_USER, DB_PASSWORD, DB_NAME, DB_HOST required") } // ✅ Secure default for production if sslMode == "" { sslMode = "require" // Default to TLS required log.Println("WARNING: DB_SSL_MODE not set, defaulting to 'require' for security") } // ✅ Validate sslMode value validSSLModes := map[string]bool{ "disable": true, // Only for local development "require": true, // Minimum for production "verify-ca": true, // Better "verify-full": true, // Best } if !validSSLModes[sslMode] { log.Fatalf("Invalid DB_SSL_MODE '%s'. Must be: disable, require, verify-ca, or verify-full", sslMode) } // ✅ Warn if using insecure mode if sslMode == "disable" { log.Println("WARNING: Database SSL is DISABLED. This should only be used for local development!") } connStr := fmt.Sprintf("user=%s password=%s dbname=%s host=%s sslmode=%s", user, password, name, host, sslMode) database, err := sql.Open("postgres", connStr) if err != nil { log.Fatalf("Error opening database: %v", err) } if err := database.Ping(); err != nil { log.Fatalf("Error connecting to database: %v", err) } log.Printf("Successfully connected to database (SSL mode: %s)", sslMode) return database } ``` **Update `.env.example`:** ```bash # Database SSL Mode # Development: disable # Production: require, verify-ca, or verify-full (RECOMMENDED) DB_SSL_MODE=require ``` **Update `podman-compose.yml` default:** ```yaml DB_SSL_MODE: ${DB_SSL_MODE:-require} # Changed from 'disable' ``` --- ## HIGH PRIORITY FIXES ### 🟠 HIGH-1: JWT Claims Mismatch (`userId` vs `user_id`) **Status:** ❌ **NOT FIXED** **Location:** `auth-service/main.go:674-682` vs downstream services **Issue:** Auth service issues tokens with BOTH `userId` (camelCase) and `user_id` (snake_case), but downstream services only check `user_id` first, creating potential bugs. **Current Code:** ```go // auth-service generates BOTH token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "user_id": userID, // snake_case "userId": userID, // camelCase - redundant! "email": email, "roles": roles, "exp": time.Now().Add(time.Hour * 24).Unix(), }) ``` **Required Fix:** ```go // Choose ONE standard (snake_case recommended for consistency with database) token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "user_id": userID, // ✅ Primary "email": email, "roles": roles, "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour * 24).Unix(), }) ``` **Update all downstream services to use consistent `user_id` only.** --- ### 🟠 HIGH-2: Blockchain Login Replay Attack **Status:** ❌ **NOT FIXED** **Location:** `auth-service/main.go:709-738` **Issue:** `verifyEthereumSignature` accepts ANY message from the client. A captured signature can be replayed indefinitely. **Current Vulnerable Code:** ```go func handleLoginBlockchain(w http.ResponseWriter, r *http.Request) { var req LoginBlockchainRequest json.NewDecoder(r.Body).Decode(&req) // ❌ Message is client-provided, no nonce/timestamp verification if !verifyEthereumSignature(req.Address, req.Message, req.Signature) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } // ... generate token } ``` **Required Fix:** **Step 1: Create nonce table** ```sql CREATE TABLE IF NOT EXISTS auth_nonces ( id SERIAL PRIMARY KEY, nonce VARCHAR(64) UNIQUE NOT NULL, address VARCHAR(42) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, used_at TIMESTAMP, expires_at TIMESTAMP NOT NULL ); CREATE INDEX idx_nonces_address ON auth_nonces(address); CREATE INDEX idx_nonces_expires ON auth_nonces(expires_at); ``` **Step 2: Add nonce generation endpoint** ```go func handleGetNonce(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { Address string `json:"address"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } address := normalizeEthereumAddress(req.Address) if address == "" { http.Error(w, "Invalid Ethereum address", http.StatusBadRequest) return } // Generate cryptographically secure nonce nonceBytes := make([]byte, 32) _, err := rand.Read(nonceBytes) if err != nil { http.Error(w, "Failed to generate nonce", http.StatusInternalServerError) return } nonce := hex.EncodeToString(nonceBytes) // Store nonce (valid for 5 minutes) expiresAt := time.Now().Add(5 * time.Minute) _, err = db.Exec(` INSERT INTO auth_nonces (nonce, address, expires_at) VALUES ($1, $2, $3) `, nonce, address, expiresAt) if err != nil { log.Printf("Failed to store nonce: %v", err) http.Error(w, "Server error", http.StatusInternalServerError) return } // Return nonce and message to sign message := fmt.Sprintf("Sign this message to authenticate with Copper Tone Technologies.\n\nNonce: %s\nTimestamp: %s", nonce, time.Now().Format(time.RFC3339)) json.NewEncoder(w).Encode(map[string]string{ "nonce": nonce, "message": message, }) } http.HandleFunc("/blockchain/get-nonce", handleGetNonce) ``` **Step 3: Verify and consume nonce on login** ```go func handleLoginBlockchain(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req LoginBlockchainRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } address := normalizeEthereumAddress(req.Address) // ✅ Verify signature with message if !verifyEthereumSignature(address, req.Message, req.Signature) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } // ✅ Extract nonce from message (must match format from get-nonce) var nonce string if matches := regexp.MustCompile(`Nonce: ([a-f0-9]{64})`).FindStringSubmatch(req.Message); len(matches) > 1 { nonce = matches[1] } else { http.Error(w, "Invalid message format", http.StatusUnauthorized) return } // ✅ Verify nonce exists, matches address, not expired, not used var nonceID int var usedAt *time.Time var expiresAt time.Time err := db.QueryRow(` SELECT id, used_at, expires_at FROM auth_nonces WHERE nonce = $1 AND address = $2 `, nonce, address).Scan(&nonceID, &usedAt, &expiresAt) if err == sql.ErrNoRows { http.Error(w, "Invalid or unknown nonce", http.StatusUnauthorized) return } else if err != nil { http.Error(w, "Server error", http.StatusInternalServerError) return } if usedAt != nil { http.Error(w, "Nonce already used (replay attack detected)", http.StatusUnauthorized) return } if time.Now().After(expiresAt) { http.Error(w, "Nonce expired", http.StatusUnauthorized) return } // ✅ Mark nonce as used _, err = db.Exec(` UPDATE auth_nonces SET used_at = NOW() WHERE id = $1 `, nonceID) if err != nil { log.Printf("Failed to mark nonce as used: %v", err) http.Error(w, "Server error", http.StatusInternalServerError) return } // Find identity and generate token (existing logic) var userID int err = db.QueryRow(` SELECT user_id FROM identities WHERE type = 'blockchain_address' AND identifier = $1 `, address).Scan(&userID) if err == sql.ErrNoRows { http.Error(w, "Address not registered", http.StatusUnauthorized) return } else if err != nil { http.Error(w, "Login failed", http.StatusInternalServerError) return } token, err := generateToken(userID) if err != nil { http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } json.NewEncoder(w).Encode(map[string]string{"token": token}) } ``` **Step 4: Add cleanup job for expired nonces** ```go func cleanupExpiredNonces() { ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for range ticker.C { result, err := db.Exec(` DELETE FROM auth_nonces WHERE expires_at < NOW() - INTERVAL '1 hour' `) if err != nil { log.Printf("Failed to cleanup expired nonces: %v", err) } else { rowsAffected, _ := result.RowsAffected() if rowsAffected > 0 { log.Printf("Cleaned up %d expired nonces", rowsAffected) } } } } // In main(): go cleanupExpiredNonces() ``` --- ### 🟠 HIGH-3: Over-Permissive CORS Configuration **Status:** ❌ **NOT FIXED** **Location:** All services `corsMiddleware` **Issue:** Default CORS allows `*` (any origin), making the system vulnerable to credential theft if tokens are stored client-side. **Current Code:** ```go func corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { allowedOrigin := strings.TrimSpace(os.Getenv("CORS_ALLOW_ORIGIN")) if allowedOrigin == "" { allowedOrigin = "*" // ❌ Insecure default! } w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) // ... }) } ``` **Required Fix:** ```go func corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { allowedOrigin := strings.TrimSpace(os.Getenv("CORS_ALLOW_ORIGIN")) // ✅ Fail-safe: require explicit configuration if allowedOrigin == "" { log.Println("ERROR: CORS_ALLOW_ORIGIN not set. Defaulting to localhost for development.") allowedOrigin = "http://localhost:8080" // Safe default for dev } // ✅ Warn if using wildcard if allowedOrigin == "*" { log.Println("WARNING: CORS is set to '*' (all origins). This is INSECURE for production!") } w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") // ✅ Add security headers if allowedOrigin != "*" { w.Header().Set("Access-Control-Allow-Credentials", "true") } if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } next.ServeHTTP(w, r) }) } ``` **Update `.env.example`:** ```bash # CORS Configuration # Development: http://localhost:8080 # Production: https://coppertone.tech # WARNING: NEVER use '*' in production! CORS_ALLOW_ORIGIN=http://localhost:8080 ``` --- ### 🟠 HIGH-4: Default Secrets in podman-compose.yml **Status:** ⚠️ **PARTIALLY FIXED** (`.env.example` exists but compose has insecure defaults) **Location:** `podman-compose.yml` **Issue:** Compose file has fallback values for sensitive data, likely to be deployed to production. **Current Code:** ```yaml environment: JWT_SECRET: ${JWT_SECRET} # ❌ No default is GOOD DB_USER: ${DB_USER:-user} # ❌ BAD default DB_PASSWORD: ${DB_PASSWORD:-password} # ❌ TERRIBLE default DB_NAME: ${DB_NAME:-coppertone_db} # ❌ Default is OK but should be explicit ``` **Required Fix:** ```yaml environment: # ✅ NO defaults for secrets - fail fast if not set JWT_SECRET: ${JWT_SECRET:?JWT_SECRET environment variable is required} DB_USER: ${DB_USER:?DB_USER environment variable is required} DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD environment variable is required} DB_NAME: ${DB_NAME:-coppertone_db} # OK to have default DB_HOST: ${DB_HOST:-db} # OK to have default DB_SSL_MODE: ${DB_SSL_MODE:-require} # Changed to secure default CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN:-http://localhost:8080} # Explicit dev default ``` **Also update database service:** ```yaml db: image: postgres:16-alpine restart: unless-stopped environment: # ✅ Use variables instead of hardcoded values POSTGRES_DB: ${DB_NAME:-coppertone_db} POSTGRES_USER: ${DB_USER:?DB_USER required} POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD required} ``` --- ## MEDIUM PRIORITY FIXES ### 🟡 MEDIUM-1: Frontend XSS via Unsanitized Markdown **Status:** ❌ **NOT FIXED** **Location:** `frontend/src/views/ServiceDetailView.vue:11`, `ArticleDetailView.vue:16` **Issue:** Markdown content is rendered to HTML and injected via `v-html` without sanitization. **Current Vulnerable Code:** ```vue
``` **Impact:** - If Markdown content is ever user-generated or from untrusted source, XSS is possible - Currently safe ONLY because content is static/admin-controlled **Required Fix:** **Option A: Use sanitization library (DOMPurify)** ```bash cd frontend npm install dompurify npm install --save-dev @types/dompurify ``` ```vue ``` **Option B: Remove `v-html` entirely (safest)** If Markdown is always static, pre-render it at build time or use a Vue markdown component that auto-escapes. --- ### 🟡 MEDIUM-2: Monetary Values as Floats **Status:** ❌ **NOT FIXED** **Location:** `payment-service/main.go:24-72` **Issue:** Invoice and payment amounts use `float64`, risking precision errors. **Current Code:** ```go type Invoice struct { Amount float64 `json:"amount"` // ❌ Floating point! Currency string `json:"currency"` } ``` **Required Fix:** **Option A: Store as integer cents** ```go type Invoice struct { AmountCents int64 `json:"amountCents"` // ✅ Integer smallest unit Currency string `json:"currency"` } // For display func (inv *Invoice) AmountDecimal() float64 { return float64(inv.AmountCents) / 100.0 } ``` **Option B: Use decimal library** ```bash go get github.com/shopspring/decimal ``` ```go import "github.com/shopspring/decimal" type Invoice struct { Amount decimal.Decimal `json:"amount"` Currency string `json:"currency"` } ``` **Update database:** ```sql -- Change to integer cents or use NUMERIC with exact precision ALTER TABLE invoices ALTER COLUMN amount TYPE BIGINT; -- Store cents -- OR ALTER TABLE invoices ALTER COLUMN amount TYPE NUMERIC(12,2); -- Exact decimal ``` --- ### 🟡 MEDIUM-3: Container Images Run as Root **Status:** ❌ **NOT FIXED** **Location:** All `Containerfile`s **Issue:** Final `FROM scratch` images have no USER directive, running as root. **Current Code:** ```dockerfile FROM scratch AS production COPY --from=builder /app/main /main EXPOSE 8080 CMD ["/main"] # ❌ No USER directive - runs as root (UID 0) ``` **Required Fix:** **Option A: Switch to Alpine base (recommended)** ```dockerfile # Build Stage FROM golang:1.25-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /app/main . # Production Stage FROM alpine:latest AS production # ✅ Add CA certificates for HTTPS RUN apk --no-cache add ca-certificates # ✅ Create non-root user RUN addgroup -g 1000 appuser && \ adduser -D -u 1000 -G appuser appuser # ✅ Copy binary and set ownership COPY --from=builder --chown=appuser:appuser /app/main /app/main # ✅ Switch to non-root user USER appuser EXPOSE 8080 CMD ["/app/main"] ``` **Option B: Keep scratch but add user (more complex)** ```dockerfile # Requires creating a user in builder stage and copying /etc/passwd FROM golang:1.25-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o /app/main . # Create minimal passwd file RUN echo "appuser:x:1000:1000::/home/appuser:/sbin/nologin" > /etc/passwd.minimal FROM scratch AS production # Copy CA certificates from builder COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ # Copy minimal passwd COPY --from=builder /etc/passwd.minimal /etc/passwd # Copy binary COPY --from=builder --chown=1000:1000 /app/main /main USER 1000:1000 EXPOSE 8080 CMD ["/main"] ``` --- ### 🟡 MEDIUM-4: No Input Validation (Length, Format, Range) **Status:** ⚠️ **PARTIALLY IMPLEMENTED** (basic checks exist but incomplete) **Location:** All handlers across all services **Issue:** Many inputs lack comprehensive validation. **Missing Validations:** **Email:** ```go // ❌ Current: No format validation if req.Email == "" { http.Error(w, "Email required", http.StatusBadRequest) return } // ✅ Required: func validateEmail(email string) error { if email == "" { return errors.New("email is required") } if len(email) > 255 { return errors.New("email too long (max 255 characters)") } // Use regexp or library emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) if !emailRegex.MatchString(email) { return errors.New("invalid email format") } return nil } ``` **Password:** ```go // ✅ Add minimum/maximum length checks func validatePassword(password string) error { if len(password) < 8 { return errors.New("password must be at least 8 characters") } if len(password) > 128 { return errors.New("password too long (max 128 characters)") } // Optional: require complexity return nil } ``` **Amounts:** ```go // ✅ Validate positive, reasonable range if inv.Amount <= 0 { http.Error(w, "Amount must be positive", http.StatusBadRequest) return } if inv.Amount > 999999999.99 { // $999,999,999.99 http.Error(w, "Amount exceeds maximum", http.StatusBadRequest) return } ``` **Currency Codes:** ```go // ✅ Whitelist valid ISO 4217 codes validCurrencies := map[string]bool{"USD": true, "EUR": true, "GBP": true, "BTC": true, "ETH": true} if !validCurrencies[inv.Currency] { http.Error(w, "Invalid currency code", http.StatusBadRequest) return } ``` **Status Enums:** ```go // ✅ Validate against allowed values validInvoiceStatuses := map[string]bool{"DRAFT": true, "ISSUED": true, "PAID": true, "CANCELLED": true} if !validInvoiceStatuses[inv.Status] { http.Error(w, "Invalid invoice status", http.StatusBadRequest) return } ``` --- ## LOW PRIORITY IMPROVEMENTS ### 🔵 LOW-1: JWT Secret Length Not Enforced **Status:** ⚠️ **PARTIALLY IMPLEMENTED** (checks for 32 chars but not strength) **Current:** ```go if len(jwtSecret) < 32 { log.Fatal("JWT_SECRET must be at least 32 characters") } ``` **Improvement:** ```go func validateJWTSecret(secret []byte) error { if len(secret) == 0 { return errors.New("JWT_SECRET must not be empty") } if len(secret) < 64 { return errors.New("JWT_SECRET must be at least 64 characters for production use") } // Optional: check for weak patterns if string(secret) == strings.Repeat("a", len(secret)) { return errors.New("JWT_SECRET is too weak (repeated characters)") } return nil } if err := validateJWTSecret(jwtSecret); err != nil { log.Fatalf("Invalid JWT_SECRET: %v", err) } ``` --- ### 🔵 LOW-2: No Rate Limiting / Brute Force Protection **Status:** ❌ **NOT IMPLEMENTED** **Impact:** Login endpoints can be brute-forced **Recommendation:** Add rate limiting middleware using `golang.org/x/time/rate` or similar. **Implementation Sketch:** ```go import "golang.org/x/time/rate" var loginLimiters = make(map[string]*rate.Limiter) var limiterMutex sync.RWMutex func getLoginLimiter(ip string) *rate.Limiter { limiterMutex.RLock() limiter, exists := loginLimiters[ip] limiterMutex.RUnlock() if !exists { limiter = rate.NewLimiter(rate.Every(1*time.Minute), 5) // 5 attempts per minute limiterMutex.Lock() loginLimiters[ip] = limiter limiterMutex.Unlock() } return limiter } func rateLimitMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ip := r.RemoteAddr limiter := getLoginLimiter(ip) if !limiter.Allow() { http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests) return } next.ServeHTTP(w, r) } } http.HandleFunc("/login-email-password", rateLimitMiddleware(handleLoginEmailPassword)) ``` --- ### 🔵 LOW-3: Missing Frontend Navigation Guard for Roles **Status:** ⚠️ **PARTIALLY IMPLEMENTED** (has `requiresAuth` but no role checking) **Location:** `frontend/src/router/index.ts` **Current:** ```typescript router.beforeEach((to, from, next) => { const authStore = useAuthStore() if (to.meta.requiresAuth && !authStore.isAuthenticated) { next('/login') } else if (to.meta.requiresGuest && authStore.isAuthenticated) { next('/dashboard') } else { next() } }) ``` **Improvement (add role-based guards):** ```typescript router.beforeEach((to, from, next) => { const authStore = useAuthStore() if (to.meta.requiresAuth && !authStore.isAuthenticated) { next('/login') return } if (to.meta.requiresGuest && authStore.isAuthenticated) { next('/dashboard') return } // ✅ Add role-based access control if (to.meta.requiresRole) { const requiredRoles = Array.isArray(to.meta.requiresRole) ? to.meta.requiresRole : [to.meta.requiresRole] const hasRequiredRole = requiredRoles.some(role => authStore.user?.roles?.includes(role) ) if (!hasRequiredRole) { next('/unauthorized') // or '/dashboard' return } } next() }) ``` **Add to route meta:** ```typescript { path: '/admin/users', name: 'admin-users', component: () => import('../views/AdminUsersView.vue'), meta: { requiresAuth: true, requiresRole: ['ADMIN'] } }, ``` --- ### 🔵 LOW-4: No Request Timeouts **Status:** ✅ **ALREADY IMPLEMENTED** in server config but not enforced in handlers **Current (GOOD):** ```go server := &http.Server{ Addr: ":8080", Handler: corsMiddleware(http.DefaultServeMux), ReadHeaderTimeout: 10 * time.Second, // ✅ Present ReadTimeout: 15 * time.Second, // ✅ Present WriteTimeout: 15 * time.Second, // ✅ Present IdleTimeout: 60 * time.Second, // ✅ Present } ``` **No additional action needed** - server-level timeouts are configured correctly. --- ## INFRASTRUCTURE & DEVOPS FIXES ### 🔧 INFRA-1: Missing Health Checks in Containerfiles **Status:** ❌ **NOT IMPLEMENTED** **Recommendation:** Add HEALTHCHECK directives to all service Containerfiles ```dockerfile # Add to each service Containerfile HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD ["/main", "-healthcheck"] || exit 1 # Or use wget/curl if available in image # For Alpine-based: HEALTHCHECK --interval=30s --timeout=3s CMD wget --no-verbose --tries=1 --spider http://localhost:8080/healthz || exit 1 ``` --- ### 🔧 INFRA-2: No Production Deployment Documentation **Status:** ❌ **NOT IMPLEMENTED** **Recommendation:** Create `docs/DEPLOYMENT.md` with: - Production checklist (TLS, secrets, CORS, etc.) - Environment variable reference - Database backup/restore procedures - Monitoring and logging setup - Incident response plan --- ### 🔧 INFRA-3: No Automated Testing in CI for Backend **Status:** ⚠️ **PARTIALLY IMPLEMENTED** (tests exist but CI doesn't run them) **Recommendation:** Add test steps to `.gitea/workflows/` for backend services: ```yaml - name: Run Go Tests run: | cd backend/functions/auth-service && go test -v ./... cd ../work-management-service && go test -v ./... cd ../payment-service && go test -v ./... ``` --- ## COMPLIANCE & DOCUMENTATION ### 📋 COMPLIANCE-1: No Privacy Policy **Status:** ❌ **NOT IMPLEMENTED** **Required for:** GDPR compliance, user trust **Action:** Create `/privacy` page explaining data collection, storage, and user rights --- ### 📋 COMPLIANCE-2: No Terms of Service **Status:** ❌ **NOT IMPLEMENTED** **Required for:** Legal protection **Action:** Create `/terms` page with service terms and conditions --- ### 📋 COMPLIANCE-3: No Data Retention Policy **Status:** ❌ **NOT IMPLEMENTED** **Required for:** GDPR Article 5 **Action:** Document how long data is kept and implement automated cleanup --- ## SUMMARY & PRIORITIZATION ### Immediate Actions (Before Any Production Use) 1. **🔴 CRITICAL-1:** Fix user self-assigned roles 2. **🔴 CRITICAL-2:** Add authorization/ownership checks to ALL resources 3. **🔴 CRITICAL-3:** Implement Stripe webhook signature verification and event handling 4. **🔴 CRITICAL-4:** Enable database TLS and change default to `require` ### Short-Term (Within 1 Week) 5. **🟠 HIGH-1:** Standardize JWT claims (`user_id` only) 6. **🟠 HIGH-2:** Implement nonce-based blockchain authentication 7. **🟠 HIGH-3:** Restrict CORS to specific origins 8. **🟠 HIGH-4:** Remove default secrets from podman-compose.yml ### Medium-Term (Within 1 Month) 9. **🟡 MEDIUM-1:** Sanitize Markdown rendering or pre-render 10. **🟡 MEDIUM-2:** Convert monetary amounts to integers or decimal type 11. **🟡 MEDIUM-3:** Run containers as non-root user 12. **🟡 MEDIUM-4:** Add comprehensive input validation ### Long-Term Improvements 13. **🔵 LOW-1 through LOW-4:** JWT secret strength, rate limiting, role guards, etc. 14. **🔧 INFRA-1 through INFRA-3:** Health checks, deployment docs, CI tests 15. **📋 COMPLIANCE-1 through COMPLIANCE-3:** Legal documents and policies --- ## TESTING & VALIDATION CHECKLIST After implementing fixes, verify: - [ ] Cannot register as ADMIN (should default to CLIENT) - [ ] Cannot access another user's projects/invoices - [ ] Cannot send fake Stripe webhook events - [ ] Database connections use TLS in production - [ ] Blockchain login requires fresh nonce - [ ] CORS restricted to app domain - [ ] Containers run as non-root (check with `podman exec