veza/veza-backend-api/internal/handlers/sell_handler.go
senke 29cb93767f feat(security): open-redirect protection on Stripe Connect + KYC return URLs
v1.0.10 sécu item 7. The SSRF audit flagged callbacks on Hyperswitch +
distribution submissions ; investigating those revealed a different
risk class on the user-supplied return_url fields :

  * sell_handler.ConnectOnboard accepts return_url + refresh_url and
    forwards them to Stripe Connect.
  * kyc_handler.StartVerification accepts return_url and forwards it
    to Stripe Identity.

Stripe doesn't fetch these URLs server-side (so SSRF is not the
risk), but it redirects the user's browser there after the flow
completes. Without an allow-list, an attacker can craft an onboarding
or verification link with `return_url=https://attacker.com/phishing`
and a victim who clicks the resulting Stripe URL lands on the
attacker's page after Stripe finishes — open-redirect attack
disguised as a legitimate Stripe flow.

Hyperswitch + distribution were already protected :
  * Webhook URLs go through validators.ValidateWebhookURL
    (services/webhook_service.go:54) which blocks private IPs +
    requires HTTPS — pre-existing SSRF guard from SEC-07.
  * Hyperswitch's own callback URL is configured server-side, not
    user-supplied (cf. hyperswitch/client.go) — no SSRF surface.
  * Distribution submissions don't carry user-supplied callbacks —
    the destination platforms are hard-coded.

What's added :

  validators/url_validator.go
    * ValidateRedirectURL(rawURL, allowedHosts) — accepts http or
      https (since Stripe-redirect targets may be local dev hosts),
      requires hostname to match one of allowedHosts exactly OR be
      a subdomain of one. Empty allowedHosts ⇒ permissive (used in
      dev / unconfigured envs ; only checks for non-internal IPs).
    * Reuses the existing IsInternalOrPrivateURL guard so SSRF
      protection still applies for the permissive branch.

  handlers/sell_handler.go + handlers/kyc_handler.go
    * Both handlers now take an allowedRedirectHosts []string param
      at construction. Validation runs after the URL defaults are
      applied so the caller's submitted URL is checked, not the
      backend-derived fallback.
    * Validation failure → 400 with a clear message ("invalid
      return_url: <reason>") so the SPA can render the right error.

  api/routes_marketplace.go
    * Both handlers receive the existing
      cfg.OAuthAllowedRedirectDomains list at construction. Same
      list as the OAuth callback validation, same operator config,
      single source of truth.

Tests pass : go test ./internal/{handlers,validators} -short.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:42:41 +02:00

205 lines
7.1 KiB
Go

package handlers
import (
"errors"
"net/http"
"veza-backend-api/internal/core/marketplace"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/services"
"veza-backend-api/internal/validators"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
)
// SellHandler handles Stripe Connect seller payout endpoints
type SellHandler struct {
db *gorm.DB
stripeConnect *services.StripeConnectService
logger *zap.Logger
allowedRedirectHosts []string // v1.0.10 sécu item 7: open-redirect protection on return_url / refresh_url
}
// NewSellHandler creates a new SellHandler. allowedRedirectHosts is
// the per-environment list of hosts that may receive the user's
// browser after Stripe Connect onboarding completes ; empty slice
// = permissive (used in dev / unconfigured envs).
func NewSellHandler(db *gorm.DB, stripeConnect *services.StripeConnectService, logger *zap.Logger, allowedRedirectHosts []string) *SellHandler {
return &SellHandler{
db: db,
stripeConnect: stripeConnect,
logger: logger,
allowedRedirectHosts: allowedRedirectHosts,
}
}
// ConnectOnboardRequest is the request body for onboarding
type ConnectOnboardRequest struct {
ReturnURL string `json:"return_url"`
RefreshURL string `json:"refresh_url"`
}
// ConnectOnboard starts Stripe Connect onboarding and returns the onboarding URL
// @Summary Start Stripe Connect onboarding
// @Description Initiate the Stripe Connect onboarding process and get a redirection link.
// @Tags Sell
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param data body ConnectOnboardRequest true "Return and Refresh URLs"
// @Success 200 {object} handlers.APIResponse{data=object{onboarding_url=string}}
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Router /api/v1/sell/connect/onboard [post]
func (h *SellHandler) ConnectOnboard(c *gin.Context) {
if h.stripeConnect == nil {
RespondWithAppError(c, apperrors.NewServiceUnavailableError("Stripe Connect is not enabled"))
return
}
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
var req ConnectOnboardRequest
_ = c.ShouldBindJSON(&req)
returnURL := req.ReturnURL
if returnURL == "" {
returnURL = c.Request.URL.Scheme + "://" + c.Request.Host + "/sell/dashboard?onboarded=success"
}
refreshURL := req.RefreshURL
if refreshURL == "" {
refreshURL = c.Request.URL.Scheme + "://" + c.Request.Host + "/sell/dashboard?onboarded=refresh"
}
// v1.0.10 sécu item 7 — open-redirect protection. The user submits
// these URLs and they end up in a Stripe redirect ; without an
// allow-list, an attacker can craft a return_url that lands the
// victim on a phishing page after Stripe completes.
if err := validators.ValidateRedirectURL(returnURL, h.allowedRedirectHosts); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid return_url: "+err.Error()))
return
}
if err := validators.ValidateRedirectURL(refreshURL, h.allowedRedirectHosts); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid refresh_url: "+err.Error()))
return
}
url, err := h.stripeConnect.CreateOnboardingLink(c.Request.Context(), userID, returnURL, refreshURL)
if err != nil {
if errors.Is(err, services.ErrStripeConnectDisabled) {
RespondWithAppError(c, apperrors.NewServiceUnavailableError("Stripe Connect is not enabled"))
return
}
h.logger.Error("CreateOnboardingLink failed", zap.Error(err), zap.String("user_id", userID.String()))
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to create onboarding link", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"onboarding_url": url})
}
// ConnectCallback syncs account status after Stripe redirect (called by frontend after return)
// @Summary Sync Stripe account
// @Description Callback endpoint to sync Stripe account status after redirection.
// @Tags Sell
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} handlers.APIResponse "Account synced"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Router /api/v1/sell/connect/callback [get]
func (h *SellHandler) ConnectCallback(c *gin.Context) {
if h.stripeConnect == nil {
RespondWithAppError(c, apperrors.NewServiceUnavailableError("Stripe Connect is not enabled"))
return
}
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
if err := h.stripeConnect.HandleOnboardingCallback(c.Request.Context(), userID); err != nil {
if errors.Is(err, services.ErrNoStripeAccount) {
RespondWithAppError(c, apperrors.NewNotFoundError("Stripe account"))
return
}
if errors.Is(err, services.ErrStripeConnectDisabled) {
RespondWithAppError(c, apperrors.NewServiceUnavailableError("Stripe Connect is not enabled"))
return
}
h.logger.Error("HandleOnboardingCallback failed", zap.Error(err), zap.String("user_id", userID.String()))
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to sync account status", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "Account synced"})
}
// GetBalance returns the seller's Stripe Connect balance
// @Summary Get seller balance
// @Description Get the current Stripe Connect balance (connected, available, pending) for the seller.
// @Tags Sell
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} handlers.APIResponse{data=object}
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Router /api/v1/sell/balance [get]
func (h *SellHandler) GetBalance(c *gin.Context) {
if h.stripeConnect == nil {
RespondWithAppError(c, apperrors.NewServiceUnavailableError("Stripe Connect is not enabled"))
return
}
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
bal, err := h.stripeConnect.GetBalance(c.Request.Context(), userID)
if err != nil {
if errors.Is(err, services.ErrStripeConnectDisabled) {
RespondWithAppError(c, apperrors.NewServiceUnavailableError("Stripe Connect is not enabled"))
return
}
h.logger.Error("GetBalance failed", zap.Error(err), zap.String("user_id", userID.String()))
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get balance", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{
"connected": bal.Connected,
"available": bal.Available,
"pending": bal.Pending,
})
}
// GetSellerTransfers returns the transfer history for the authenticated seller (v0.603)
// @Summary Get transfer history
// @Description Get a list of all Stripe transfers for the authenticated seller.
// @Tags Sell
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {array} marketplace.SellerTransfer
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Router /api/v1/sell/transfers [get]
func (h *SellHandler) GetSellerTransfers(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
var transfers []marketplace.SellerTransfer
if err := h.db.Where("seller_id = ?", userID).Order("created_at DESC").Find(&transfers).Error; err != nil {
h.logger.Error("GetSellerTransfers failed", zap.Error(err), zap.String("user_id", userID.String()))
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to fetch transfers", err))
return
}
RespondSuccess(c, http.StatusOK, transfers)
}