40 KiB
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:
// 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:
- Remove any
rolefield fromRegisterEmailPasswordRequestandRegisterBlockchainRequeststructs - Always assign
defaultRole(CLIENT) from environment variable - Create separate admin-only endpoint (e.g.,
/admin/users/{id}/promote-role) requiringADMINauthentication to elevate user roles - Add audit logging for all role changes
Implementation Steps:
// 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/:idpayment-service:/invoices,/invoices/:id,/payments,/payments/:id
Current Vulnerable Code:
// 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)
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)
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:
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:
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:
// 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:
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:
# Database SSL Mode
# Development: disable
# Production: require, verify-ca, or verify-full (RECOMMENDED)
DB_SSL_MODE=require
Update podman-compose.yml default:
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:
// 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:
// 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:
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
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
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
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
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:
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:
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:
# 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:
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:
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:
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:
<div class="prose max-w-none" v-html="service.content"></div>
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)
cd frontend
npm install dompurify
npm install --save-dev @types/dompurify
<script setup lang="ts">
import DOMPurify from 'dompurify'
import { computed } from 'vue'
const service = ref({ content: '' })
const sanitizedContent = computed(() => {
return DOMPurify.sanitize(service.value.content, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'code', 'pre', 'blockquote'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class']
})
})
</script>
<template>
<div class="prose max-w-none" v-html="sanitizedContent"></div>
</template>
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:
type Invoice struct {
Amount float64 `json:"amount"` // ❌ Floating point!
Currency string `json:"currency"`
}
Required Fix:
Option A: Store as integer cents
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
go get github.com/shopspring/decimal
import "github.com/shopspring/decimal"
type Invoice struct {
Amount decimal.Decimal `json:"amount"`
Currency string `json:"currency"`
}
Update database:
-- 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 Containerfiles
Issue: Final FROM scratch images have no USER directive, running as root.
Current Code:
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)
# 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)
# 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:
// ❌ 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:
// ✅ 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:
// ✅ 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:
// ✅ 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:
// ✅ 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:
if len(jwtSecret) < 32 {
log.Fatal("JWT_SECRET must be at least 32 characters")
}
Improvement:
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:
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:
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):
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:
{
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):
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
# 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:
- 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)
- 🔴 CRITICAL-1: Fix user self-assigned roles
- 🔴 CRITICAL-2: Add authorization/ownership checks to ALL resources
- 🔴 CRITICAL-3: Implement Stripe webhook signature verification and event handling
- 🔴 CRITICAL-4: Enable database TLS and change default to
require
Short-Term (Within 1 Week)
- 🟠 HIGH-1: Standardize JWT claims (
user_idonly) - 🟠 HIGH-2: Implement nonce-based blockchain authentication
- 🟠 HIGH-3: Restrict CORS to specific origins
- 🟠 HIGH-4: Remove default secrets from podman-compose.yml
Medium-Term (Within 1 Month)
- 🟡 MEDIUM-1: Sanitize Markdown rendering or pre-render
- 🟡 MEDIUM-2: Convert monetary amounts to integers or decimal type
- 🟡 MEDIUM-3: Run containers as non-root user
- 🟡 MEDIUM-4: Add comprehensive input validation
Long-Term Improvements
- 🔵 LOW-1 through LOW-4: JWT secret strength, rate limiting, role guards, etc.
- 🔧 INFRA-1 through INFRA-3: Health checks, deployment docs, CI tests
- 📋 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 <container> whoami) - All tests pass (
go test ./...,npm run test:unit) - Static analysis passes (
go vet,npm run lint) - No secrets in git history or environment defaults
CONCLUSION
The Copper Tone Technologies codebase is architecturally sound and feature-complete, but has critical security vulnerabilities that MUST be addressed before production deployment.
Estimated remediation time: 40-60 hours for critical and high-priority fixes.
Risk if deployed as-is: Complete system compromise, data breach, financial fraud.
Recommendation: DO NOT DEPLOY until at minimum all CRITICAL fixes are implemented and tested.