Fix 113 hardening issues across entire Go backend

Security:
- Replace all binding: tags with validate: + c.Validate() in admin handlers
- Add rate limiting to auth endpoints (login, register, password reset)
- Add security headers (HSTS, XSS protection, nosniff, frame options)
- Wire Google Pub/Sub token verification into webhook handler
- Replace ParseUnverified with proper OIDC/JWKS key verification
- Verify inner Apple JWS signatures in webhook handler
- Add io.LimitReader (1MB) to all webhook body reads
- Add ownership verification to file deletion
- Move hardcoded admin credentials to env vars
- Add uniqueIndex to User.Email
- Hide ConfirmationCode from JSON serialization
- Mask confirmation codes in admin responses
- Use http.DetectContentType for upload validation
- Fix path traversal in storage service
- Replace os.Getenv with Viper in stripe service
- Sanitize Redis URLs before logging
- Separate DEBUG_FIXED_CODES from DEBUG flag
- Reject weak SECRET_KEY in production
- Add host check on /_next/* proxy routes
- Use explicit localhost CORS origins in debug mode
- Replace err.Error() with generic messages in all admin error responses

Critical fixes:
- Rewrite FCM to HTTP v1 API with OAuth 2.0 service account auth
- Fix user_customuser -> auth_user table names in raw SQL
- Fix dashboard verified query to use UserProfile model
- Add escapeLikeWildcards() to prevent SQL wildcard injection

Bug fixes:
- Add bounds checks for days/expiring_soon query params (1-3650)
- Add receipt_data/transaction_id empty-check to RestoreSubscription
- Change Active bool -> *bool in device handler
- Check all unchecked GORM/FindByIDWithProfile errors
- Add validation for notification hour fields (0-23)
- Add max=10000 validation on task description updates

Transactions & data integrity:
- Wrap registration flow in transaction
- Wrap QuickComplete in transaction
- Move image creation inside completion transaction
- Wrap SetSpecialties in transaction
- Wrap GetOrCreateToken in transaction
- Wrap completion+image deletion in transaction

Performance:
- Batch completion summaries (2 queries vs 2N)
- Reuse single http.Client in IAP validation
- Cache dashboard counts (30s TTL)
- Batch COUNT queries in admin user list
- Add Limit(500) to document queries
- Add reminder_stage+due_date filters to reminder queries
- Parse AllowedTypes once at init
- In-memory user cache in auth middleware (30s TTL)
- Timezone change detection cache
- Optimize P95 with per-endpoint sorted buffers
- Replace crypto/md5 with hash/fnv for ETags

Code quality:
- Add sync.Once to all monitoring Stop()/Close() methods
- Replace 8 fmt.Printf with zerolog in auth service
- Log previously discarded errors
- Standardize delete response shapes
- Route hardcoded English through i18n
- Remove FileURL from DocumentResponse (keep MediaURL only)
- Thread user timezone through kanban board responses
- Initialize empty slices to prevent null JSON
- Extract shared field map for task Update/UpdateTx
- Delete unused SoftDeleteModel, min(), formatCron, legacy handlers

Worker & jobs:
- Wire Asynq email infrastructure into worker
- Register HandleReminderLogCleanup with daily 3AM cron
- Use per-user timezone in HandleSmartReminder
- Replace direct DB queries with repository calls
- Delete legacy reminder handlers (~200 lines)
- Delete unused task type constants

Dependencies:
- Replace archived jung-kurt/gofpdf with go-pdf/fpdf
- Replace unmaintained gomail.v2 with wneessen/go-mail
- Add TODO for Echo jwt v3 transitive dep removal

Test infrastructure:
- Fix MakeRequest/SeedLookupData error handling
- Replace os.Exit(0) with t.Skip() in scope/consistency tests
- Add 11 new FCM v1 tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-18 23:14:13 -05:00
parent 3b86d0aae1
commit 42a5533a56
95 changed files with 2892 additions and 1783 deletions

View File

@@ -8,6 +8,8 @@ import (
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/dto/requests"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/services"
)
@@ -115,7 +117,7 @@ func (h *ContractorHandler) DeleteContractor(c echo.Context) error {
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Contractor deleted successfully"})
return c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.contractor_deleted")})
}
// ToggleFavorite handles POST /api/contractors/:id/toggle-favorite/

View File

@@ -12,6 +12,8 @@ import (
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/dto/requests"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
@@ -60,6 +62,9 @@ func (h *DocumentHandler) ListDocuments(c echo.Context) error {
}
if es := c.QueryParam("expiring_soon"); es != "" {
if parsed, err := strconv.Atoi(es); err == nil {
if parsed < 1 || parsed > 3650 {
return apperrors.BadRequest("error.days_out_of_range")
}
filter.ExpiringSoon = &parsed
}
}
@@ -192,7 +197,10 @@ func (h *DocumentHandler) CreateDocument(c echo.Context) error {
}
}
if uploadedFile != nil && h.storageService != nil {
if uploadedFile != nil {
if h.storageService == nil {
return apperrors.Internal(nil)
}
result, err := h.storageService.Upload(uploadedFile, "documents")
if err != nil {
return apperrors.BadRequest("error.failed_to_upload_file")
@@ -262,7 +270,7 @@ func (h *DocumentHandler) DeleteDocument(c echo.Context) error {
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document deleted successfully"})
return c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.document_deleted")})
}
// ActivateDocument handles POST /api/documents/:id/activate/
@@ -280,7 +288,7 @@ func (h *DocumentHandler) ActivateDocument(c echo.Context) error {
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document activated successfully", "document": response})
return c.JSON(http.StatusOK, response)
}
// DeactivateDocument handles POST /api/documents/:id/deactivate/
@@ -298,7 +306,7 @@ func (h *DocumentHandler) DeactivateDocument(c echo.Context) error {
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document deactivated successfully", "document": response})
return c.JSON(http.StatusOK, response)
}
// UploadDocumentImage handles POST /api/documents/:id/images/

View File

@@ -7,6 +7,8 @@ import (
"github.com/labstack/echo/v4"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/services"
)
@@ -87,7 +89,7 @@ func (h *NotificationHandler) MarkAsRead(c echo.Context) error {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "message.notification_marked_read"})
return c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.notification_marked_read")})
}
// MarkAllAsRead handles POST /api/notifications/mark-all-read/
@@ -102,7 +104,7 @@ func (h *NotificationHandler) MarkAllAsRead(c echo.Context) error {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "message.all_notifications_marked_read"})
return c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.all_notifications_marked_read")})
}
// GetPreferences handles GET /api/notifications/preferences/
@@ -200,7 +202,10 @@ func (h *NotificationHandler) UnregisterDevice(c echo.Context) error {
return apperrors.BadRequest("error.registration_id_required")
}
if req.Platform == "" {
req.Platform = "ios" // Default to iOS
return apperrors.BadRequest("error.platform_required")
}
if req.Platform != "ios" && req.Platform != "android" {
return apperrors.BadRequest("error.invalid_platform")
}
err = h.notificationService.UnregisterDevice(req.RegistrationID, req.Platform, user.ID)
@@ -208,7 +213,7 @@ func (h *NotificationHandler) UnregisterDevice(c echo.Context) error {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "message.device_unregistered"})
return c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.device_removed")})
}
// DeleteDevice handles DELETE /api/notifications/devices/:id/
@@ -225,7 +230,10 @@ func (h *NotificationHandler) DeleteDevice(c echo.Context) error {
platform := c.QueryParam("platform")
if platform == "" {
platform = "ios" // Default to iOS
return apperrors.BadRequest("error.platform_required")
}
if platform != "ios" && platform != "android" {
return apperrors.BadRequest("error.invalid_platform")
}
err = h.notificationService.DeleteDevice(uint(deviceID), platform, user.ID)
@@ -233,5 +241,5 @@ func (h *NotificationHandler) DeleteDevice(c echo.Context) error {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "message.device_removed"})
return c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.device_removed")})
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/services"
)
@@ -139,7 +140,7 @@ func (h *SubscriptionHandler) ProcessPurchase(c echo.Context) error {
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "message.subscription_upgraded",
"message": i18n.LocalizedMessage(c, "message.subscription_upgraded"),
"subscription": subscription,
})
}
@@ -157,7 +158,7 @@ func (h *SubscriptionHandler) CancelSubscription(c echo.Context) error {
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "message.subscription_cancelled",
"message": i18n.LocalizedMessage(c, "message.subscription_cancelled"),
"subscription": subscription,
})
}
@@ -182,8 +183,15 @@ func (h *SubscriptionHandler) RestoreSubscription(c echo.Context) error {
switch req.Platform {
case "ios":
// B-14: Validate that at least one of receipt_data or transaction_id is provided
if req.ReceiptData == "" && req.TransactionID == "" {
return apperrors.BadRequest("error.receipt_data_required")
}
subscription, err = h.subscriptionService.ProcessApplePurchase(user.ID, req.ReceiptData, req.TransactionID)
case "android":
if req.PurchaseToken == "" {
return apperrors.BadRequest("error.purchase_token_required")
}
subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken, req.ProductID)
default:
return apperrors.BadRequest("error.invalid_platform")
@@ -194,7 +202,7 @@ func (h *SubscriptionHandler) RestoreSubscription(c echo.Context) error {
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "message.subscription_restored",
"message": i18n.LocalizedMessage(c, "message.subscription_restored"),
"subscription": subscription,
})
}

View File

@@ -2,15 +2,18 @@ package handlers
import (
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -23,6 +26,11 @@ import (
"github.com/treytartt/honeydue-api/internal/services"
)
// maxWebhookBodySize is the maximum allowed request body size for webhook
// payloads (1 MB). This prevents a malicious or misbehaving sender from
// forcing the server to allocate unbounded memory.
const maxWebhookBodySize = 1 << 20 // 1 MB
// SubscriptionWebhookHandler handles subscription webhook callbacks
type SubscriptionWebhookHandler struct {
subscriptionRepo *repositories.SubscriptionRepository
@@ -112,7 +120,7 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{"status": "webhooks_disabled"})
}
body, err := io.ReadAll(c.Request().Body)
body, err := io.ReadAll(io.LimitReader(c.Request().Body, maxWebhookBodySize))
if err != nil {
log.Error().Err(err).Msg("Apple Webhook: Failed to read body")
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"})
@@ -211,13 +219,22 @@ func (h *SubscriptionWebhookHandler) decodeAppleSignedPayload(signedPayload stri
return &notification, nil
}
// decodeAppleTransaction decodes a signed transaction info JWS
// decodeAppleTransaction decodes and verifies a signed transaction info JWS.
// The inner JWS signature is verified using the same Apple certificate chain
// validation as the outer notification payload.
func (h *SubscriptionWebhookHandler) decodeAppleTransaction(signedTransaction string) (*AppleTransactionInfo, error) {
parts := strings.Split(signedTransaction, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWS format")
}
// S-16: Verify the inner JWS signature for signedTransactionInfo.
// Apple signs each inner JWS independently with the same x5c certificate
// chain as the outer notification, so the same verification applies.
if err := h.VerifyAppleSignature(signedTransaction); err != nil {
return nil, fmt.Errorf("Apple transaction JWS signature verification failed: %w", err)
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode payload: %w", err)
@@ -231,13 +248,20 @@ func (h *SubscriptionWebhookHandler) decodeAppleTransaction(signedTransaction st
return &info, nil
}
// decodeAppleRenewalInfo decodes signed renewal info JWS
// decodeAppleRenewalInfo decodes and verifies a signed renewal info JWS.
// The inner JWS signature is verified using the same Apple certificate chain
// validation as the outer notification payload.
func (h *SubscriptionWebhookHandler) decodeAppleRenewalInfo(signedRenewal string) (*AppleRenewalInfo, error) {
parts := strings.Split(signedRenewal, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWS format")
}
// S-16: Verify the inner JWS signature for signedRenewalInfo.
if err := h.VerifyAppleSignature(signedRenewal); err != nil {
return nil, fmt.Errorf("Apple renewal JWS signature verification failed: %w", err)
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode payload: %w", err)
@@ -484,7 +508,12 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{"status": "webhooks_disabled"})
}
body, err := io.ReadAll(c.Request().Body)
// C-01: Verify the Google Pub/Sub push authentication token before processing
if !h.VerifyGooglePubSubToken(c) {
return c.JSON(http.StatusUnauthorized, map[string]interface{}{"error": "unauthorized"})
}
body, err := io.ReadAll(io.LimitReader(c.Request().Body, maxWebhookBodySize))
if err != nil {
log.Error().Err(err).Msg("Google Webhook: Failed to read body")
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"})
@@ -781,7 +810,7 @@ func (h *SubscriptionWebhookHandler) HandleStripeWebhook(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{"status": "not_configured"})
}
body, err := io.ReadAll(c.Request().Body)
body, err := io.ReadAll(io.LimitReader(c.Request().Body, maxWebhookBodySize))
if err != nil {
log.Error().Err(err).Msg("Stripe Webhook: Failed to read body")
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"})
@@ -884,10 +913,109 @@ func (h *SubscriptionWebhookHandler) VerifyAppleSignature(signedPayload string)
return nil
}
// googleOIDCCertsURL is the endpoint that serves Google's public OAuth2
// certificates used to verify JWTs issued by accounts.google.com (including
// Pub/Sub push tokens).
const googleOIDCCertsURL = "https://www.googleapis.com/oauth2/v3/certs"
// googleJWKSCache caches the fetched Google JWKS keys so we don't hit the
// network on every webhook request. Keys are refreshed when the cache expires.
var (
googleJWKSCache map[string]*rsa.PublicKey
googleJWKSCacheMu sync.RWMutex
googleJWKSCacheTime time.Time
googleJWKSCacheTTL = 1 * time.Hour
)
// googleJWKSResponse represents the JSON Web Key Set response from Google.
type googleJWKSResponse struct {
Keys []googleJWK `json:"keys"`
}
// googleJWK represents a single JSON Web Key from Google's OIDC endpoint.
type googleJWK struct {
Kid string `json:"kid"` // Key ID
Kty string `json:"kty"` // Key type (RSA)
Alg string `json:"alg"` // Algorithm (RS256)
N string `json:"n"` // RSA modulus (base64url)
E string `json:"e"` // RSA exponent (base64url)
}
// fetchGoogleOIDCKeys fetches Google's public OIDC keys from their well-known
// endpoint, returning a map of key-id to RSA public key. Results are cached
// for googleJWKSCacheTTL to avoid excessive network calls.
func fetchGoogleOIDCKeys() (map[string]*rsa.PublicKey, error) {
googleJWKSCacheMu.RLock()
if googleJWKSCache != nil && time.Since(googleJWKSCacheTime) < googleJWKSCacheTTL {
cached := googleJWKSCache
googleJWKSCacheMu.RUnlock()
return cached, nil
}
googleJWKSCacheMu.RUnlock()
googleJWKSCacheMu.Lock()
defer googleJWKSCacheMu.Unlock()
// Double-check after acquiring write lock
if googleJWKSCache != nil && time.Since(googleJWKSCacheTime) < googleJWKSCacheTTL {
return googleJWKSCache, nil
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(googleOIDCCertsURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch Google OIDC keys: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Google OIDC keys endpoint returned status %d", resp.StatusCode)
}
var jwks googleJWKSResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, maxWebhookBodySize)).Decode(&jwks); err != nil {
return nil, fmt.Errorf("failed to decode Google OIDC keys: %w", err)
}
keys := make(map[string]*rsa.PublicKey, len(jwks.Keys))
for _, k := range jwks.Keys {
if k.Kty != "RSA" {
continue
}
nBytes, err := base64.RawURLEncoding.DecodeString(k.N)
if err != nil {
log.Warn().Str("kid", k.Kid).Err(err).Msg("Google OIDC: failed to decode modulus")
continue
}
eBytes, err := base64.RawURLEncoding.DecodeString(k.E)
if err != nil {
log.Warn().Str("kid", k.Kid).Err(err).Msg("Google OIDC: failed to decode exponent")
continue
}
n := new(big.Int).SetBytes(nBytes)
e := 0
for _, b := range eBytes {
e = e<<8 + int(b)
}
keys[k.Kid] = &rsa.PublicKey{N: n, E: e}
}
if len(keys) == 0 {
return nil, fmt.Errorf("no usable RSA keys found in Google OIDC response")
}
googleJWKSCache = keys
googleJWKSCacheTime = time.Now()
return keys, nil
}
// VerifyGooglePubSubToken verifies the Pub/Sub push authentication token.
// Returns false (deny) when the Authorization header is missing or the token
// cannot be validated. This prevents unauthenticated callers from injecting
// webhook events.
// The token is a JWT signed by Google (accounts.google.com). This function
// verifies the signature against Google's published OIDC public keys, checks
// the issuer claim, and validates the email claim is a Google service account.
// Returns false (deny) when verification fails for any reason.
func (h *SubscriptionWebhookHandler) VerifyGooglePubSubToken(c echo.Context) bool {
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
@@ -907,12 +1035,52 @@ func (h *SubscriptionWebhookHandler) VerifyGooglePubSubToken(c echo.Context) boo
return false
}
// Parse the token as a JWT. Google Pub/Sub push tokens are signed JWTs
// issued by accounts.google.com. We verify the claims to ensure the
// token was intended for our service.
token, _, err := jwt.NewParser().ParseUnverified(bearerToken, jwt.MapClaims{})
// Fetch Google's OIDC public keys for signature verification
googleKeys, err := fetchGoogleOIDCKeys()
if err != nil {
log.Warn().Err(err).Msg("Google Webhook: failed to parse Bearer token")
log.Error().Err(err).Msg("Google Webhook: failed to fetch OIDC keys, denying request")
return false
}
// Parse and verify the JWT signature against Google's published public keys
token, err := jwt.Parse(bearerToken, func(token *jwt.Token) (interface{}, error) {
// Ensure the signing method is RSA (Google uses RS256)
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
kid, _ := token.Header["kid"].(string)
if kid == "" {
return nil, fmt.Errorf("missing kid header in token")
}
key, ok := googleKeys[kid]
if !ok {
// Key may have rotated; try refreshing once
googleJWKSCacheMu.Lock()
googleJWKSCacheTime = time.Time{} // Force refresh
googleJWKSCacheMu.Unlock()
refreshedKeys, err := fetchGoogleOIDCKeys()
if err != nil {
return nil, fmt.Errorf("failed to refresh Google OIDC keys: %w", err)
}
key, ok = refreshedKeys[kid]
if !ok {
return nil, fmt.Errorf("unknown key ID: %s", kid)
}
}
return key, nil
}, jwt.WithValidMethods([]string{"RS256"}))
if err != nil {
log.Warn().Err(err).Msg("Google Webhook: JWT signature verification failed")
return false
}
if !token.Valid {
log.Warn().Msg("Google Webhook: token is invalid after verification")
return false
}

View File

@@ -38,19 +38,29 @@ func (h *TaskHandler) ListTasks(c echo.Context) error {
userNow := middleware.GetUserNow(c)
// Auto-capture timezone from header for background job calculations (e.g., daily digest)
// Runs synchronously — this is a lightweight DB upsert that should complete quickly
// Only write to DB if the timezone has actually changed from the cached value
if tzHeader := c.Request().Header.Get("X-Timezone"); tzHeader != "" {
h.taskService.UpdateUserTimezone(user.ID, tzHeader)
cachedTZ, _ := c.Get("user_timezone").(string)
if cachedTZ != tzHeader {
h.taskService.UpdateUserTimezone(user.ID, tzHeader)
c.Set("user_timezone", tzHeader)
}
}
daysThreshold := 30
// Support "days" param first, fall back to "days_threshold" for backward compatibility
if d := c.QueryParam("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil {
if parsed < 1 || parsed > 3650 {
return apperrors.BadRequest("error.days_out_of_range")
}
daysThreshold = parsed
}
} else if d := c.QueryParam("days_threshold"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil {
if parsed < 1 || parsed > 3650 {
return apperrors.BadRequest("error.days_out_of_range")
}
daysThreshold = parsed
}
}
@@ -97,10 +107,16 @@ func (h *TaskHandler) GetTasksByResidence(c echo.Context) error {
// Support "days" param first, fall back to "days_threshold" for backward compatibility
if d := c.QueryParam("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil {
if parsed < 1 || parsed > 3650 {
return apperrors.BadRequest("error.days_out_of_range")
}
daysThreshold = parsed
}
} else if d := c.QueryParam("days_threshold"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil {
if parsed < 1 || parsed > 3650 {
return apperrors.BadRequest("error.days_out_of_range")
}
daysThreshold = parsed
}
}

View File

@@ -7,19 +7,31 @@ import (
"github.com/rs/zerolog/log"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/services"
)
// FileOwnershipChecker verifies whether a user owns a file referenced by URL.
// Implementations should check associated records (e.g., task completion images,
// document files, document images) to determine ownership.
type FileOwnershipChecker interface {
IsFileOwnedByUser(fileURL string, userID uint) (bool, error)
}
// UploadHandler handles file upload endpoints
type UploadHandler struct {
storageService *services.StorageService
storageService *services.StorageService
fileOwnershipChecker FileOwnershipChecker
}
// NewUploadHandler creates a new upload handler
func NewUploadHandler(storageService *services.StorageService) *UploadHandler {
return &UploadHandler{storageService: storageService}
func NewUploadHandler(storageService *services.StorageService, fileOwnershipChecker FileOwnershipChecker) *UploadHandler {
return &UploadHandler{
storageService: storageService,
fileOwnershipChecker: fileOwnershipChecker,
}
}
// UploadImage handles POST /api/uploads/image
@@ -83,13 +95,14 @@ type DeleteFileRequest struct {
// DeleteFile handles DELETE /api/uploads
// Expects JSON body with "url" field.
//
// TODO(SEC-18): Add ownership verification. Currently any authenticated user can delete
// any file if they know the URL. The upload system does not track which user uploaded
// which file, so a proper fix requires adding an uploads table or file ownership metadata.
// For now, deletions are logged with user ID for audit trail, and StorageService.Delete
// enforces path containment to prevent deleting files outside the upload directory.
// Verifies that the requesting user owns the file by checking associated records
// (task completion images, document files/images) before allowing deletion.
func (h *UploadHandler) DeleteFile(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
var req DeleteFileRequest
if err := c.Bind(&req); err != nil {
@@ -100,17 +113,28 @@ func (h *UploadHandler) DeleteFile(c echo.Context) error {
return apperrors.BadRequest("error.url_required")
}
// Log the deletion with user ID for audit trail
if user, ok := c.Get(middleware.AuthUserKey).(*models.User); ok {
log.Info().
Uint("user_id", user.ID).
Str("file_url", req.URL).
Msg("File deletion requested")
// Verify ownership: the user must own a record that references this file URL
if h.fileOwnershipChecker != nil {
owned, err := h.fileOwnershipChecker.IsFileOwnedByUser(req.URL, user.ID)
if err != nil {
log.Error().Err(err).Uint("user_id", user.ID).Str("file_url", req.URL).Msg("Failed to check file ownership")
return apperrors.Internal(err)
}
if !owned {
log.Warn().Uint("user_id", user.ID).Str("file_url", req.URL).Msg("Unauthorized file deletion attempt")
return apperrors.Forbidden("error.file_access_denied")
}
}
// Log the deletion with user ID for audit trail
log.Info().
Uint("user_id", user.ID).
Str("file_url", req.URL).
Msg("File deletion requested")
if err := h.storageService.Delete(req.URL); err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "File deleted successfully"})
return c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.file_deleted")})
}

View File

@@ -5,6 +5,7 @@ import (
"testing"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/testutil"
)
@@ -18,12 +19,16 @@ func init() {
func TestDeleteFile_MissingURL_Returns400(t *testing.T) {
// Use a test storage service — DeleteFile won't reach storage since validation fails first
storageSvc := newTestStorageService("/var/uploads")
handler := NewUploadHandler(storageSvc)
handler := NewUploadHandler(storageSvc, nil)
e := testutil.SetupTestRouter()
// Register route
e.DELETE("/api/uploads/", handler.DeleteFile)
// Register route with mock auth middleware
testUser := &models.User{FirstName: "Test", Email: "test@test.com"}
testUser.ID = 1
authGroup := e.Group("/api")
authGroup.Use(testutil.MockAuthMiddleware(testUser))
authGroup.DELETE("/uploads/", handler.DeleteFile)
// Send request with empty JSON body (url field missing)
w := testutil.MakeRequest(e, http.MethodDelete, "/api/uploads/", map[string]string{}, "test-token")
@@ -32,10 +37,16 @@ func TestDeleteFile_MissingURL_Returns400(t *testing.T) {
func TestDeleteFile_EmptyURL_Returns400(t *testing.T) {
storageSvc := newTestStorageService("/var/uploads")
handler := NewUploadHandler(storageSvc)
handler := NewUploadHandler(storageSvc, nil)
e := testutil.SetupTestRouter()
e.DELETE("/api/uploads/", handler.DeleteFile)
// Register route with mock auth middleware
testUser := &models.User{FirstName: "Test", Email: "test@test.com"}
testUser.ID = 1
authGroup := e.Group("/api")
authGroup.Use(testutil.MockAuthMiddleware(testUser))
authGroup.DELETE("/uploads/", handler.DeleteFile)
// Send request with empty url field
w := testutil.MakeRequest(e, http.MethodDelete, "/api/uploads/", map[string]string{"url": ""}, "test-token")