Harden API security: input validation, safe auth extraction, new tests, and deploy config

Comprehensive security hardening from audit findings:
- Add validation tags to all DTO request structs (max lengths, ranges, enums)
- Replace unsafe type assertions with MustGetAuthUser helper across all handlers
- Remove query-param token auth from admin middleware (prevents URL token leakage)
- Add request validation calls in handlers that were missing c.Validate()
- Remove goroutines in handlers (timezone update now synchronous)
- Add sanitize middleware and path traversal protection (path_utils)
- Stop resetting admin passwords on migration restart
- Warn on well-known default SECRET_KEY
- Add ~30 new test files covering security regressions, auth safety, repos, and services
- Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-02 09:48:01 -06:00
parent 56d6fa4514
commit 7690f07a2b
123 changed files with 8321 additions and 750 deletions

View File

@@ -3,9 +3,9 @@ package services
import (
"context"
"errors"
"log"
"time"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/apperrors"
@@ -74,11 +74,11 @@ func NewSubscriptionService(
appleClient, err := NewAppleIAPClient(cfg.AppleIAP)
if err != nil {
if !errors.Is(err, ErrIAPNotConfigured) {
log.Printf("Warning: Failed to initialize Apple IAP client: %v", err)
log.Warn().Err(err).Msg("Failed to initialize Apple IAP client")
}
} else {
svc.appleClient = appleClient
log.Println("Apple IAP validation client initialized")
log.Info().Msg("Apple IAP validation client initialized")
}
// Initialize Google IAP client
@@ -86,11 +86,11 @@ func NewSubscriptionService(
googleClient, err := NewGoogleIAPClient(ctx, cfg.GoogleIAP)
if err != nil {
if !errors.Is(err, ErrIAPNotConfigured) {
log.Printf("Warning: Failed to initialize Google IAP client: %v", err)
log.Warn().Err(err).Msg("Failed to initialize Google IAP client")
}
} else {
svc.googleClient = googleClient
log.Println("Google IAP validation client initialized")
log.Info().Msg("Google IAP validation client initialized")
}
}
@@ -173,7 +173,8 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
return resp, nil
}
// getUserUsage calculates current usage for a user
// getUserUsage calculates current usage for a user.
// Uses batch COUNT queries (O(1) queries) instead of per-residence queries (O(N)).
func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error) {
residences, err := s.residenceRepo.FindOwnedByUser(userID)
if err != nil {
@@ -181,26 +182,26 @@ func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error)
}
propertiesCount := int64(len(residences))
// Count tasks, contractors, and documents across all user's residences
var tasksCount, contractorsCount, documentsCount int64
for _, r := range residences {
tc, err := s.taskRepo.CountByResidence(r.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
tasksCount += tc
// Collect residence IDs for batch queries
residenceIDs := make([]uint, len(residences))
for i, r := range residences {
residenceIDs[i] = r.ID
}
cc, err := s.contractorRepo.CountByResidence(r.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
contractorsCount += cc
// Count tasks, contractors, and documents across all residences with single queries each
tasksCount, err := s.taskRepo.CountByResidenceIDs(residenceIDs)
if err != nil {
return nil, apperrors.Internal(err)
}
dc, err := s.documentRepo.CountByResidence(r.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
documentsCount += dc
contractorsCount, err := s.contractorRepo.CountByResidenceIDs(residenceIDs)
if err != nil {
return nil, apperrors.Internal(err)
}
documentsCount, err := s.documentRepo.CountByResidenceIDs(residenceIDs)
if err != nil {
return nil, apperrors.Internal(err)
}
return &UsageResponse{
@@ -342,46 +343,40 @@ func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData stri
return nil, apperrors.Internal(err)
}
// Validate with Apple if client is configured
var expiresAt time.Time
if s.appleClient != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var result *AppleValidationResult
var err error
// Prefer transaction ID (StoreKit 2), fall back to receipt data (StoreKit 1)
if transactionID != "" {
result, err = s.appleClient.ValidateTransaction(ctx, transactionID)
} else if receiptData != "" {
result, err = s.appleClient.ValidateReceipt(ctx, receiptData)
}
if err != nil {
// Log the validation error
log.Printf("Apple validation warning for user %d: %v", userID, err)
// Check if it's a fatal error
if errors.Is(err, ErrInvalidReceipt) || errors.Is(err, ErrSubscriptionCancelled) {
return nil, err
}
// For other errors (network, etc.), fall back with shorter expiry
expiresAt = time.Now().UTC().AddDate(0, 1, 0) // 1 month fallback
} else if result != nil {
// Use the expiration date from Apple
expiresAt = result.ExpiresAt
log.Printf("Apple purchase validated for user %d: product=%s, expires=%v, env=%s",
userID, result.ProductID, result.ExpiresAt, result.Environment)
}
} else {
// Apple validation not configured - trust client but log warning
log.Printf("Warning: Apple IAP validation not configured, trusting client for user %d", userID)
expiresAt = time.Now().UTC().AddDate(1, 0, 0) // 1 year default
// Apple IAP client must be configured to validate purchases.
// Without server-side validation, we cannot trust client-provided receipts.
if s.appleClient == nil {
log.Error().Uint("user_id", userID).Msg("Apple IAP validation not configured, rejecting purchase")
return nil, apperrors.BadRequest("error.iap_validation_not_configured")
}
// Upgrade to Pro with the determined expiration
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var result *AppleValidationResult
var err error
// Prefer transaction ID (StoreKit 2), fall back to receipt data (StoreKit 1)
if transactionID != "" {
result, err = s.appleClient.ValidateTransaction(ctx, transactionID)
} else if receiptData != "" {
result, err = s.appleClient.ValidateReceipt(ctx, receiptData)
}
if err != nil {
// Validation failed -- do NOT fall through to grant Pro.
log.Error().Err(err).Uint("user_id", userID).Msg("Apple validation failed")
return nil, err
}
if result == nil {
return nil, apperrors.BadRequest("error.no_receipt_or_transaction")
}
expiresAt := result.ExpiresAt
log.Info().Uint("user_id", userID).Str("product", result.ProductID).Time("expires", result.ExpiresAt).Str("env", result.Environment).Msg("Apple purchase validated")
// Upgrade to Pro with the validated expiration
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil {
return nil, apperrors.Internal(err)
}
@@ -397,59 +392,48 @@ func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken s
return nil, apperrors.Internal(err)
}
// Validate the purchase with Google if client is configured
var expiresAt time.Time
if s.googleClient != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var result *GoogleValidationResult
var err error
// If productID is provided, use it directly; otherwise try known IDs
if productID != "" {
result, err = s.googleClient.ValidateSubscription(ctx, productID, purchaseToken)
} else {
result, err = s.googleClient.ValidatePurchaseToken(ctx, purchaseToken, KnownSubscriptionIDs)
}
if err != nil {
// Log the validation error
log.Printf("Google purchase validation warning for user %d: %v", userID, err)
// Check if it's a fatal error
if errors.Is(err, ErrInvalidPurchaseToken) || errors.Is(err, ErrSubscriptionCancelled) {
return nil, err
}
if errors.Is(err, ErrSubscriptionExpired) {
// Subscription expired - still allow but set past expiry
expiresAt = time.Now().UTC().Add(-1 * time.Hour)
} else {
// For other errors, fall back with shorter expiry
expiresAt = time.Now().UTC().AddDate(0, 1, 0) // 1 month fallback
}
} else if result != nil {
// Use the expiration date from Google
expiresAt = result.ExpiresAt
log.Printf("Google purchase validated for user %d: product=%s, expires=%v, autoRenew=%v",
userID, result.ProductID, result.ExpiresAt, result.AutoRenewing)
// Acknowledge the subscription if not already acknowledged
if !result.AcknowledgedState {
if err := s.googleClient.AcknowledgeSubscription(ctx, result.ProductID, purchaseToken); err != nil {
log.Printf("Warning: Failed to acknowledge subscription for user %d: %v", userID, err)
// Don't fail the purchase, just log the warning
}
}
}
} else {
// Google validation not configured - trust client but log warning
log.Printf("Warning: Google IAP validation not configured, trusting client for user %d", userID)
expiresAt = time.Now().UTC().AddDate(1, 0, 0) // 1 year default
// Google IAP client must be configured to validate purchases.
// Without server-side validation, we cannot trust client-provided tokens.
if s.googleClient == nil {
log.Error().Uint("user_id", userID).Msg("Google IAP validation not configured, rejecting purchase")
return nil, apperrors.BadRequest("error.iap_validation_not_configured")
}
// Upgrade to Pro with the determined expiration
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var result *GoogleValidationResult
var err error
// If productID is provided, use it directly; otherwise try known IDs
if productID != "" {
result, err = s.googleClient.ValidateSubscription(ctx, productID, purchaseToken)
} else {
result, err = s.googleClient.ValidatePurchaseToken(ctx, purchaseToken, KnownSubscriptionIDs)
}
if err != nil {
// Validation failed -- do NOT fall through to grant Pro.
log.Error().Err(err).Uint("user_id", userID).Msg("Google purchase validation failed")
return nil, err
}
if result == nil {
return nil, apperrors.BadRequest("error.no_purchase_token")
}
expiresAt := result.ExpiresAt
log.Info().Uint("user_id", userID).Str("product", result.ProductID).Time("expires", result.ExpiresAt).Bool("auto_renew", result.AutoRenewing).Msg("Google purchase validated")
// Acknowledge the subscription if not already acknowledged
if !result.AcknowledgedState {
if err := s.googleClient.AcknowledgeSubscription(ctx, result.ProductID, purchaseToken); err != nil {
log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to acknowledge Google subscription")
// Don't fail the purchase, just log the warning
}
}
// Upgrade to Pro with the validated expiration
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "android"); err != nil {
return nil, apperrors.Internal(err)
}
@@ -654,5 +638,5 @@ type ProcessPurchaseRequest struct {
TransactionID string `json:"transaction_id"` // iOS StoreKit 2 transaction ID
PurchaseToken string `json:"purchase_token"` // Android
ProductID string `json:"product_id"` // Android (optional, helps identify subscription)
Platform string `json:"platform" binding:"required,oneof=ios android"`
Platform string `json:"platform" validate:"required,oneof=ios android"`
}