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>
130 lines
4.4 KiB
Go
130 lines
4.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
|
|
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"
|
|
)
|
|
|
|
// KYCHandler handles seller identity verification endpoints (v0.13.5 TASK-MKT-001)
|
|
type KYCHandler struct {
|
|
kycService *services.KYCService
|
|
logger *zap.Logger
|
|
allowedRedirectHosts []string // v1.0.10 sécu item 7: open-redirect protection on return_url
|
|
}
|
|
|
|
// NewKYCHandler creates a new KYC handler. allowedRedirectHosts is
|
|
// the per-environment list of hosts that may receive the user's
|
|
// browser after KYC completes (Stripe Identity redirect).
|
|
func NewKYCHandler(kycService *services.KYCService, logger *zap.Logger, allowedRedirectHosts []string) *KYCHandler {
|
|
return &KYCHandler{
|
|
kycService: kycService,
|
|
logger: logger,
|
|
allowedRedirectHosts: allowedRedirectHosts,
|
|
}
|
|
}
|
|
|
|
// CreateVerificationRequest is the request body for starting KYC
|
|
type CreateVerificationRequest struct {
|
|
ReturnURL string `json:"return_url"`
|
|
}
|
|
|
|
// StartVerification creates a Stripe Identity verification session
|
|
// @Summary Start KYC verification
|
|
// @Description Initiate a Stripe Identity verification session for the seller.
|
|
// @Tags Sell
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param data body CreateVerificationRequest true "Return URL"
|
|
// @Success 201 {object} handlers.APIResponse{data=object}
|
|
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
|
|
// @Router /api/v1/sell/kyc/start [post]
|
|
func (h *KYCHandler) StartVerification(c *gin.Context) {
|
|
if h.kycService == nil {
|
|
RespondWithAppError(c, apperrors.NewServiceUnavailableError("KYC verification is not available"))
|
|
return
|
|
}
|
|
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req CreateVerificationRequest
|
|
_ = c.ShouldBindJSON(&req)
|
|
returnURL := req.ReturnURL
|
|
if returnURL == "" {
|
|
returnURL = c.Request.URL.Scheme + "://" + c.Request.Host + "/sell?kyc=complete"
|
|
}
|
|
|
|
// v1.0.10 sécu item 7 — open-redirect protection. The user submits
|
|
// this URL and it ends up in a Stripe Identity redirect ; allow-list
|
|
// gates the host so a phishing return_url can't be smuggled through.
|
|
if err := validators.ValidateRedirectURL(returnURL, h.allowedRedirectHosts); err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("invalid return_url: "+err.Error()))
|
|
return
|
|
}
|
|
|
|
session, err := h.kycService.CreateVerificationSession(c.Request.Context(), userID, returnURL)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrKYCAlreadyDone) {
|
|
RespondSuccess(c, http.StatusOK, gin.H{"status": "verified", "message": "Already verified"})
|
|
return
|
|
}
|
|
if errors.Is(err, services.ErrKYCNotAvailable) {
|
|
RespondWithAppError(c, apperrors.NewServiceUnavailableError("KYC verification is not available"))
|
|
return
|
|
}
|
|
if errors.Is(err, services.ErrNoStripeAccount) {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Complete Stripe Connect onboarding first"))
|
|
return
|
|
}
|
|
h.logger.Error("StartVerification failed", zap.Error(err), zap.String("user_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to start verification", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusCreated, session)
|
|
}
|
|
|
|
// GetVerificationStatus returns the current KYC status for a seller
|
|
// @Summary Get KYC status
|
|
// @Description Get the current identity verification status for the authenticated seller.
|
|
// @Tags Sell
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Success 200 {object} handlers.APIResponse{data=object{status=string}}
|
|
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
|
|
// @Router /api/v1/sell/kyc/status [get]
|
|
func (h *KYCHandler) GetVerificationStatus(c *gin.Context) {
|
|
if h.kycService == nil {
|
|
RespondSuccess(c, http.StatusOK, gin.H{"status": "not_available"})
|
|
return
|
|
}
|
|
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
status, err := h.kycService.GetVerificationStatus(c.Request.Context(), userID)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrKYCNotAvailable) {
|
|
RespondSuccess(c, http.StatusOK, gin.H{"status": "not_available"})
|
|
return
|
|
}
|
|
h.logger.Error("GetVerificationStatus failed", zap.Error(err), zap.String("user_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get verification status", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"status": status})
|
|
}
|