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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user