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

@@ -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
}