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:
@@ -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/
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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")})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 ¬ification, 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user